diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..b78b1417a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,45 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ open-pull-requests-limit: 15
+ groups:
+ actions:
+ patterns:
+ - "*"
+ ignore:
+ # git-town/action v1.3.x crashes on cyclic PR graphs (self-loop main->main
+ # fork PRs) via its topological-sort visualization. Pinned to v1.2.1 in
+ # git-town.yml; block v1.3.x until upstream tolerates cyclic edges.
+ - dependency-name: "git-town/action"
+ update-types:
+ - "version-update:semver-minor"
+ - "version-update:semver-major"
+
+ - package-ecosystem: "gomod"
+ directories:
+ - "/"
+ schedule:
+ interval: "daily"
+ open-pull-requests-limit: 15
+ groups:
+ aws-sdk:
+ patterns:
+ - "github.com/aws/aws-sdk-go-v2/*"
+ pion:
+ patterns:
+ - "github.com/pion/*"
+ gorm:
+ patterns:
+ - "gorm.io/*"
+ otel:
+ patterns:
+ - "go.opentelemetry.io/*"
+ testcontainers:
+ patterns:
+ - "github.com/testcontainers/testcontainers-go/*"
+ wireguard:
+ patterns:
+ - "golang.zx2c4.com/wireguard*"
diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml
index a721cb516..8acd645e2 100644
--- a/.github/workflows/check-license-dependencies.yml
+++ b/.github/workflows/check-license-dependencies.yml
@@ -2,16 +2,16 @@ name: Check License Dependencies
on:
push:
- branches: [ main ]
+ branches: [main]
paths:
- - 'go.mod'
- - 'go.sum'
- - '.github/workflows/check-license-dependencies.yml'
+ - "go.mod"
+ - "go.sum"
+ - ".github/workflows/check-license-dependencies.yml"
pull_request:
paths:
- - 'go.mod'
- - 'go.sum'
- - '.github/workflows/check-license-dependencies.yml'
+ - "go.mod"
+ - "go.sum"
+ - ".github/workflows/check-license-dependencies.yml"
jobs:
check-internal-dependencies:
@@ -19,7 +19,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Check for problematic license dependencies
run: |
@@ -56,55 +59,57 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version-file: 'go.mod'
- cache: true
+ - name: Set up Go
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
+ with:
+ go-version-file: "go.mod"
+ cache: true
- - name: Install go-licenses
- run: go install github.com/google/go-licenses@v1.6.0
+ - name: Install go-licenses
+ run: go install github.com/google/go-licenses@v1.6.0
- - name: Check for GPL/AGPL licensed dependencies
- run: |
- echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
- echo ""
-
- # Check all Go packages for copyleft licenses, excluding internal netbird packages
- COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
-
- if [ -n "$COPYLEFT_DEPS" ]; then
- echo "Found copyleft licensed dependencies:"
- echo "$COPYLEFT_DEPS"
+ - name: Check for GPL/AGPL licensed dependencies
+ run: |
+ echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
echo ""
- # Filter out dependencies that are only pulled in by internal AGPL packages
- INCOMPATIBLE=""
- while IFS=',' read -r package url license; do
- if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
- # Find ALL packages that import this GPL package using go list
- IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
+ # Check all Go packages for copyleft licenses, excluding internal netbird packages
+ COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
- # Check if any importer is NOT in management/signal/relay
- BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
-
- if [ -n "$BSD_IMPORTER" ]; then
- echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
- INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
- else
- echo "✓ $package ($license) is only used by internal AGPL packages - OK"
- fi
- fi
- done <<< "$COPYLEFT_DEPS"
-
- if [ -n "$INCOMPATIBLE" ]; then
+ if [ -n "$COPYLEFT_DEPS" ]; then
+ echo "Found copyleft licensed dependencies:"
+ echo "$COPYLEFT_DEPS"
echo ""
- echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
- echo -e "$INCOMPATIBLE"
- exit 1
- fi
- fi
- echo "✅ All external license dependencies are compatible with BSD-3-Clause"
+ # Filter out dependencies that are only pulled in by internal AGPL packages
+ INCOMPATIBLE=""
+ while IFS=',' read -r package url license; do
+ if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
+ # Find ALL packages that import this GPL package using go list
+ IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
+
+ # Check if any importer is NOT in management/signal/relay
+ BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
+
+ if [ -n "$BSD_IMPORTER" ]; then
+ echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
+ INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
+ else
+ echo "✓ $package ($license) is only used by internal AGPL packages - OK"
+ fi
+ fi
+ done <<< "$COPYLEFT_DEPS"
+
+ if [ -n "$INCOMPATIBLE" ]; then
+ echo ""
+ echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
+ echo -e "$INCOMPATIBLE"
+ exit 1
+ fi
+ fi
+
+ echo "✅ All external license dependencies are compatible with BSD-3-Clause"
diff --git a/.github/workflows/docs-ack.yml b/.github/workflows/docs-ack.yml
index f11142a36..7e34e2f8a 100644
--- a/.github/workflows/docs-ack.yml
+++ b/.github/workflows/docs-ack.yml
@@ -83,7 +83,7 @@ jobs:
- name: Verify docs PR exists (and is open or merged)
if: steps.validate.outputs.mode == 'added'
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
id: verify
with:
pr_number: ${{ steps.extract.outputs.pr_number }}
diff --git a/.github/workflows/forum.yml b/.github/workflows/forum.yml
index a26a72586..75543ef8b 100644
--- a/.github/workflows/forum.yml
+++ b/.github/workflows/forum.yml
@@ -8,11 +8,10 @@ jobs:
post:
runs-on: ubuntu-latest
steps:
- - uses: roots/discourse-topic-github-release-action@main
+ - uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0
with:
discourse-api-key: ${{ secrets.DISCOURSE_RELEASES_API_KEY }}
discourse-base-url: https://forum.netbird.io
discourse-author-username: NetBird
discourse-category: 17
- discourse-tags:
- releases
+ discourse-tags: releases
diff --git a/.github/workflows/git-town.yml b/.github/workflows/git-town.yml
index 699ed7d93..3f145020f 100644
--- a/.github/workflows/git-town.yml
+++ b/.github/workflows/git-town.yml
@@ -3,7 +3,7 @@ name: Git Town
on:
pull_request:
branches:
- - '**'
+ - "**"
jobs:
git-town:
@@ -15,7 +15,9 @@ jobs:
pull-requests: write
steps:
- - uses: actions/checkout@v4
- - uses: git-town/action@v1.2.1
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - uses: git-town/action@3d8b878379abb1ee393fb49865a28b4a6c2cd3b0 # v1.2.1
with:
skip-single-stacks: true
diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml
index 0528ed086..ad84840a2 100644
--- a/.github/workflows/golang-test-darwin.yml
+++ b/.github/workflows/golang-test-darwin.yml
@@ -16,16 +16,18 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/go/pkg/mod
key: macos-gotest-${{ hashFiles('**/go.sum') }}
@@ -43,5 +45,11 @@ jobs:
run: git --no-pager diff --exit-code
- name: Test
- run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+ run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+ - name: Upload coverage reports to Codecov
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,client
diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml
index 2c029b117..9a81d3e4c 100644
--- a/.github/workflows/golang-test-freebsd.yml
+++ b/.github/workflows/golang-test-freebsd.yml
@@ -15,20 +15,31 @@ jobs:
name: "Client / Unit"
runs-on: ubuntu-22.04
steps:
- - uses: actions/checkout@v4
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Read Go version from go.mod
+ id: goversion
+ run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
+
- name: Test in FreeBSD
id: test
- uses: vmactions/freebsd-vm@v1
+ env:
+ GO_VERSION: ${{ steps.goversion.outputs.version }}
+ uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
with:
usesh: true
copyback: false
- release: "14.2"
+ release: "15.0"
+ envs: "GO_VERSION"
prepare: |
pkg install -y curl pkgconf xorg
- GO_TARBALL="go1.25.3.freebsd-amd64.tar.gz"
+ GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
GO_URL="https://go.dev/dl/$GO_TARBALL"
curl -vLO "$GO_URL"
- tar -C /usr/local -vxzf "$GO_TARBALL"
+ tar -C /usr/local -vxzf "$GO_TARBALL"
# -x - to print all executed commands
# -e - to faile on first error
diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml
index 450c44aea..c17f83222 100644
--- a/.github/workflows/golang-test-linux.yml
+++ b/.github/workflows/golang-test-linux.yml
@@ -18,9 +18,11 @@ jobs:
management: ${{ steps.filter.outputs.management }}
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- - uses: dorny/paths-filter@v3
+ - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
filters: |
@@ -28,7 +30,7 @@ jobs:
- 'management/**'
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -36,10 +38,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache
with:
path: |
@@ -113,14 +115,16 @@ jobs:
strategy:
fail-fast: false
matrix:
- arch: [ '386','amd64' ]
+ arch: ["386", "amd64"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -128,10 +132,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -154,18 +158,29 @@ jobs:
run: git --no-pager diff --exit-code
- name: Test
- run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+ run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,client
+
test_client_on_docker:
name: "Client (Docker) / Unit"
- needs: [ build-cache ]
+ needs: [build-cache]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -177,7 +192,7 @@ jobs:
echo "modcache_dir=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: cache-restore
with:
path: |
@@ -231,10 +246,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -246,10 +263,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -268,23 +285,33 @@ jobs:
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
go test ${{ matrix.raceFlag }} \
- -exec 'sudo' \
+ -exec 'sudo' -coverprofile=coverage.txt \
-timeout 10m -p 1 ./relay/... ./shared/relay/...
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,relay
+
test_proxy:
name: "Proxy / Unit"
needs: [build-cache]
strategy:
fail-fast: false
matrix:
- arch: [ '386','amd64' ]
+ arch: ["386", "amd64"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -298,7 +325,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -316,7 +343,15 @@ jobs:
- name: Test
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
- go test -timeout 10m -p 1 ./proxy/...
+ go test -timeout 10m -p 1 -coverprofile=coverage.txt ./proxy/...
+
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,proxy
test_signal:
name: "Signal / Unit"
@@ -324,14 +359,16 @@ jobs:
strategy:
fail-fast: false
matrix:
- arch: [ '386','amd64' ]
+ arch: ["386", "amd64"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -343,10 +380,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -365,24 +402,34 @@ jobs:
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
go test \
- -exec 'sudo' \
+ -exec 'sudo' -coverprofile=coverage.txt \
-timeout 10m ./signal/... ./shared/signal/...
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,signal
+
test_management:
name: "Management / Unit"
- needs: [ build-cache ]
+ needs: [build-cache]
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres', 'mysql' ]
+ arch: ["amd64"]
+ store: ["sqlite", "postgres", "mysql"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -390,10 +437,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -410,7 +457,7 @@ jobs:
- name: Login to Docker hub
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
@@ -427,23 +474,31 @@ jobs:
run: docker pull mlsmaycon/warmed-mysql:8
- name: Test
- run: |
+ run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
- go test -tags=devcert \
+ go test -tags=devcert -coverprofile=coverage.txt \
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
-timeout 20m ./management/... ./shared/management/...
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,management
+
benchmark:
name: "Management / Benchmark"
- needs: [ build-cache ]
+ needs: [build-cache]
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres' ]
+ arch: ["amd64"]
+ store: ["sqlite", "postgres"]
runs-on: ubuntu-22.04
steps:
- name: Create Docker network
@@ -474,10 +529,12 @@ jobs:
prom/prometheus
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -485,10 +542,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -505,7 +562,7 @@ jobs:
- name: Login to Docker hub
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
@@ -529,13 +586,13 @@ jobs:
api_benchmark:
name: "Management / Benchmark (API)"
- needs: [ build-cache ]
+ needs: [build-cache]
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres' ]
+ arch: ["amd64"]
+ store: ["sqlite", "postgres"]
runs-on: ubuntu-22.04
steps:
- name: Create Docker network
@@ -566,10 +623,12 @@ jobs:
prom/prometheus
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -577,10 +636,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -597,7 +656,7 @@ jobs:
- name: Login to Docker hub
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
@@ -623,20 +682,22 @@ jobs:
api_integration_test:
name: "Management / Integration"
- needs: [ build-cache ]
+ needs: [build-cache]
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres']
+ arch: ["amd64"]
+ store: ["sqlite", "postgres"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -644,10 +705,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -667,6 +728,14 @@ jobs:
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
- go test -tags=integration \
+ go test -tags=integration -coverprofile=coverage.txt \
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
-timeout 20m ./management/server/http/...
+
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: integration,management
diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml
index 8e672043d..8712cc879 100644
--- a/.github/workflows/golang-test-windows.yml
+++ b/.github/workflows/golang-test-windows.yml
@@ -18,10 +18,12 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
id: go
with:
go-version-file: "go.mod"
@@ -33,7 +35,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
${{ env.cache }}
@@ -44,16 +46,15 @@ jobs:
${{ runner.os }}-go-
- name: Download wintun
- uses: carlosperate/download-file-action@v2
id: download-wintun
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
- file-name: wintun.zip
- location: ${{ env.downloadPath }}
- sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
+ url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
+ destination: ${{ env.downloadPath }}\wintun.zip
+ sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
- name: Decompressing wintun files
- run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
+ run: tar -xvf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
- run: mv ${{ env.downloadPath }}/wintun/bin/amd64/wintun.dll 'C:\Windows\System32\'
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 7b7b32ec0..8f6d1ddb0 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -15,9 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: codespell
- uses: codespell-project/actions-codespell@v2
+ uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
with:
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
skip: go.mod,go.sum,**/proxy/web/**
@@ -38,13 +40,15 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Check for duplicate constants
if: matrix.os == 'ubuntu-latest'
run: |
! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep .
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
@@ -52,7 +56,7 @@ jobs:
if: matrix.os == 'ubuntu-latest'
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
- name: golangci-lint
- uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
+ uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
with:
version: latest
skip-cache: true
diff --git a/.github/workflows/install-script-test.yml b/.github/workflows/install-script-test.yml
index 22d002a48..aec9f6300 100644
--- a/.github/workflows/install-script-test.yml
+++ b/.github/workflows/install-script-test.yml
@@ -22,7 +22,9 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: run install script
env:
diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml
index 8325fbf2d..8e0538104 100644
--- a/.github/workflows/mobile-build-validation.yml
+++ b/.github/workflows/mobile-build-validation.yml
@@ -16,23 +16,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: Setup Android SDK
- uses: android-actions/setup-android@v3
+ uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
with:
cmdline-tools-version: 8512546
- name: Setup Java
- uses: actions/setup-java@v4
+ uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654
with:
java-version: "11"
distribution: "adopt"
- name: NDK Cache
id: ndk-cache
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: /usr/local/lib/android/sdk/ndk
key: ndk-cache-23.1.7779620
@@ -52,9 +54,11 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: install gomobile
diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml
index a2e6ce219..67d65356c 100644
--- a/.github/workflows/pr-title-check.yml
+++ b/.github/workflows/pr-title-check.yml
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Validate PR title prefix
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const title = context.payload.pull_request.title;
diff --git a/.github/workflows/proto-version-check.yml b/.github/workflows/proto-version-check.yml
index ea300419d..fd2c2c908 100644
--- a/.github/workflows/proto-version-check.yml
+++ b/.github/workflows/proto-version-check.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check for proto tool version changes
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const files = await github.paginate(github.rest.pulls.listFiles, {
@@ -20,34 +20,83 @@ jobs:
per_page: 100,
});
- const pbFiles = files.filter(f => f.filename.endsWith('.pb.go'));
- const missingPatch = pbFiles.filter(f => !f.patch).map(f => f.filename);
- if (missingPatch.length > 0) {
- core.setFailed(
- `Cannot inspect patch data for:\n` +
- missingPatch.map(f => `- ${f}`).join('\n') +
- `\nThis can happen with very large PRs. Verify proto versions manually.`
- );
+ // Cover renamed .pb.go files in addition to plain edits.
+ // Renamed entries land under the new path with previous_filename
+ // pointing at the base-side name, so we read the base content
+ // from the old path when present.
+ const changedPbFiles = files
+ .filter(f => (f.status === 'modified' || f.status === 'renamed')
+ && f.filename.endsWith('.pb.go'))
+ .map(f => ({
+ headPath: f.filename,
+ basePath: f.previous_filename || f.filename,
+ }));
+ if (changedPbFiles.length === 0) {
+ console.log('No modified or renamed .pb.go files to check');
return;
}
- const versionPattern = /^[+-]\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
- const violations = [];
- for (const file of pbFiles) {
- const changed = file.patch
- .split('\n')
- .filter(line => versionPattern.test(line));
- if (changed.length > 0) {
+ // Matches the generator version headers protoc writes at the top
+ // of generated files:
+ // // protoc v3.21.12
+ // // protoc-gen-go v1.26.0
+ // // - protoc-gen-go-grpc v1.6.1 (grpc files prefix with "- ")
+ // The optional "- " prefix and the optional -gen-go / -gen-go-grpc
+ // suffixes keep the *_grpc.pb.go headers in scope.
+ const versionPattern = /^\s*\/\/\s+(?:-\s+)?protoc(?:-gen-go(?:-grpc)?)?\s+v[\d.]+/;
+ const baseSha = context.payload.pull_request.base.sha;
+ const headSha = context.payload.pull_request.head.sha;
+
+ async function getVersionHeader(path, ref) {
+ try {
+ const res = await github.rest.repos.getContent({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ path,
+ ref,
+ });
+ if (!res.data.content) {
+ return { ok: false, reason: 'no inline content (file too large)' };
+ }
+ const content = Buffer.from(res.data.content, 'base64').toString('utf8');
+ const lines = content
+ .split('\n')
+ .slice(0, 20)
+ .filter(line => versionPattern.test(line));
+ return { ok: true, lines };
+ } catch (e) {
+ return { ok: false, reason: e.message };
+ }
+ }
+
+ const violations = [];
+ for (const file of changedPbFiles) {
+ const [base, head] = await Promise.all([
+ getVersionHeader(file.basePath, baseSha),
+ getVersionHeader(file.headPath, headSha),
+ ]);
+ if (!base.ok || !head.ok) {
+ core.warning(
+ `Skipping ${file.headPath}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
+ );
+ continue;
+ }
+ if (base.lines.join('\n') !== head.lines.join('\n')) {
violations.push({
- file: file.filename,
- lines: changed,
+ file: file.basePath === file.headPath
+ ? file.headPath
+ : `${file.basePath} → ${file.headPath}`,
+ base: base.lines,
+ head: head.lines,
});
}
}
if (violations.length > 0) {
const details = violations.map(v =>
- `${v.file}:\n${v.lines.map(l => ' ' + l).join('\n')}`
+ `${v.file}:\n` +
+ ` base:\n${v.base.map(l => ' ' + l).join('\n') || ' (none)'}\n` +
+ ` head:\n${v.head.map(l => ' ' + l).join('\n') || ' (none)'}`
).join('\n\n');
core.setFailed(
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c1ae01a98..b15185198 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,7 +9,7 @@ on:
pull_request:
env:
- SIGN_PIPE_VER: "v0.1.4"
+ SIGN_PIPE_VER: "v0.1.5"
GORELEASER_VER: "v2.14.3"
PRODUCT_NAME: "NetBird"
COPYRIGHT: "NetBird GmbH"
@@ -24,13 +24,15 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Generate FreeBSD port diff
- run: bash release_files/freebsd-port-diff.sh
+ run: bash -x release_files/freebsd-port-diff.sh
- name: Generate FreeBSD port issue body
- run: bash release_files/freebsd-port-issue-body.sh
+ run: bash -x release_files/freebsd-port-issue-body.sh
- name: Check if diff was generated
id: check_diff
@@ -51,19 +53,26 @@ jobs:
echo "Generated files for version: $VERSION"
cat netbird-*.diff
+ - name: Read Go version from go.mod
+ id: goversion
+ run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
+
- name: Test FreeBSD port
if: steps.check_diff.outputs.diff_exists == 'true'
- uses: vmactions/freebsd-vm@v1
+ env:
+ GO_VERSION: ${{ steps.goversion.outputs.version }}
+ uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
with:
usesh: true
copyback: false
release: "15.0"
+ envs: "GO_VERSION"
prepare: |
# Install required packages
- pkg install -y git curl portlint go
+ pkg install -y git curl portlint
# Install Go for building
- GO_TARBALL="go1.25.5.freebsd-amd64.tar.gz"
+ GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
GO_URL="https://go.dev/dl/$GO_TARBALL"
curl -LO "$GO_URL"
tar -C /usr/local -xzf "$GO_TARBALL"
@@ -93,19 +102,19 @@ jobs:
# Show patched Makefile
version=$(cat security/netbird/Makefile | grep -E '^DISTVERSION=' | awk '{print $NF}')
-
+
cd /usr/ports/security/netbird
export BATCH=yes
make package
pkg add ./work/pkg/netbird-*.pkg
-
+
netbird version | grep "$version"
echo "FreeBSD port test completed successfully!"
- name: Upload FreeBSD port files
if: steps.check_diff.outputs.diff_exists == 'true'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: freebsd-port-files
path: |
@@ -124,26 +133,25 @@ jobs:
env:
flags: ""
steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0 # It is required for GoReleaser to work properly
+ persist-credentials: false
+
- name: Parse semver string
id: semver_parser
- uses: booxmedialtd/ws-action-parse-semver@v1
- with:
- input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
- version_extractor_regex: '\/v(.*)$'
+ uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: echo "flags=--snapshot" >> $GITHUB_ENV
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0 # It is required for GoReleaser to work properly
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/go/pkg/mod
@@ -156,18 +164,18 @@ jobs:
- name: check git status
run: git --no-pager diff --exit-code
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a #v4.0.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v2
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
- name: Login to Docker hub
if: github.event_name != 'pull_request'
- uses: docker/login-action@v1
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to the GitHub container registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -191,7 +199,7 @@ jobs:
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
- name: Run GoReleaser
id: goreleaser
- uses: goreleaser/goreleaser-action@v4
+ uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
with:
version: ${{ env.GORELEASER_VER }}
args: release --clean ${{ env.flags }}
@@ -282,28 +290,28 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: upload non tags for debug purposes
id: upload_release
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: release
path: dist/
retention-days: 7
- name: upload linux packages
id: upload_linux_packages
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: linux-packages
path: dist/netbird_linux**
retention-days: 7
- name: upload windows packages
id: upload_windows_packages
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: windows-packages
path: dist/netbird_windows**
retention-days: 7
- name: upload macos packages
id: upload_macos_packages
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: macos-packages
path: dist/netbird_darwin**
@@ -314,27 +322,26 @@ jobs:
outputs:
release_ui_artifact_url: ${{ steps.upload_release_ui.outputs.artifact-url }}
steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0 # It is required for GoReleaser to work properly
+ persist-credentials: false
+
- name: Parse semver string
id: semver_parser
- uses: booxmedialtd/ws-action-parse-semver@v1
- with:
- input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
- version_extractor_regex: '\/v(.*)$'
+ uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: echo "flags=--snapshot" >> $GITHUB_ENV
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0 # It is required for GoReleaser to work properly
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/go/pkg/mod
@@ -375,7 +382,7 @@ jobs:
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
- name: Run GoReleaser
- uses: goreleaser/goreleaser-action@v4
+ uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
with:
version: ${{ env.GORELEASER_VER }}
args: release --config .goreleaser_ui.yaml --clean ${{ env.flags }}
@@ -404,7 +411,7 @@ jobs:
run: rm -f /tmp/gpg-rpm-signing-key.asc
- name: upload non tags for debug purposes
id: upload_release_ui
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: release-ui
path: dist/
@@ -418,16 +425,17 @@ jobs:
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: echo "flags=--snapshot" >> $GITHUB_ENV
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0 # It is required for GoReleaser to work properly
+ persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/go/pkg/mod
@@ -441,7 +449,7 @@ jobs:
run: git --no-pager diff --exit-code
- name: Run GoReleaser
id: goreleaser
- uses: goreleaser/goreleaser-action@v4
+ uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
with:
version: ${{ env.GORELEASER_VER }}
args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }}
@@ -449,7 +457,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: upload non tags for debug purposes
id: upload_release_ui_darwin
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: release-ui-darwin
path: dist/
@@ -474,27 +482,26 @@ jobs:
PackageWorkdir: netbird_windows_${{ matrix.arch }}
downloadPath: '${{ github.workspace }}\temp'
steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
- name: Parse semver string
id: semver_parser
- uses: booxmedialtd/ws-action-parse-semver@v1
- with:
- input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
- version_extractor_regex: '\/v(.*)$'
-
- - name: Checkout
- uses: actions/checkout@v4
+ uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
- name: Add 7-Zip to PATH
run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Download release artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
with:
name: release
path: release
- name: Download UI release artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
with:
name: release-ui
path: release-ui
@@ -514,29 +521,27 @@ jobs:
Get-ChildItem $workdir
- name: Download wintun
- uses: carlosperate/download-file-action@v2
id: download-wintun
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
- file-name: wintun.zip
- location: ${{ env.downloadPath }}
- sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
+ url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
+ destination: ${{ env.downloadPath }}\wintun.zip
+ sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
- name: Decompress wintun files
- run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
+ run: tar -xvf "${{ env.downloadPath }}\wintun.zip" -C ${{ env.downloadPath }}
- name: Move wintun.dll into dist
run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
- name: Download Mesa3D (amd64 only)
- uses: carlosperate/download-file-action@v2
id: download-mesa3d
if: matrix.arch == 'amd64'
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://downloads.fdossena.com/Projects/Mesa3D/Builds/MesaForWindows-x64-20.1.8.7z
- file-name: mesa3d.7z
- location: ${{ env.downloadPath }}
- sha256: '71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9'
+ url: https://pkgs.netbird.io/mesa3d/MesaForWindows-x64-20.1.8.7z
+ destination: ${{ env.downloadPath }}\mesa3d.7z
+ sha256: 71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9
- name: Extract Mesa3D driver (amd64 only)
if: matrix.arch == 'amd64'
@@ -547,35 +552,38 @@ jobs:
run: mv ${{ env.downloadPath }}\opengl32.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
- name: Download EnVar plugin for NSIS
- uses: carlosperate/download-file-action@v2
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://nsis.sourceforge.io/mediawiki/images/7/7f/EnVar_plugin.zip
- file-name: envar_plugin.zip
- location: ${{ github.workspace }}
+ url: https://pkgs.netbird.io/nsis/EnVar_plugin.zip
+ destination: ${{ github.workspace }}\envar_plugin.zip
+ sha256: e9aa92de351345ed82795251d838f1ae9041ba35af9d381a5780c7843b01f56a
- name: Extract EnVar plugin
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/envar_plugin.zip"
- name: Download ShellExecAsUser plugin for NSIS (amd64 only)
- uses: carlosperate/download-file-action@v2
if: matrix.arch == 'amd64'
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://nsis.sourceforge.io/mediawiki/images/6/68/ShellExecAsUser_amd64-Unicode.7z
- file-name: ShellExecAsUser_amd64-Unicode.7z
- location: ${{ github.workspace }}
+ url: https://pkgs.netbird.io/nsis/ShellExecAsUser_amd64-Unicode.7z
+ destination: ${{ github.workspace }}\ShellExecAsUser_amd64-Unicode.7z
+ sha256: 0a55ea25c7330a92cec028eda8afcaf1b1a7092e0dfb77c21c8f654564b4ff9d
- name: Extract ShellExecAsUser plugin (amd64 only)
if: matrix.arch == 'amd64'
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
- name: Build NSIS installer
- uses: joncloud/makensis-action@v3.3
- with:
- additional-plugin-paths: ${{ github.workspace }}/NSIS_Plugins/Plugins
- script-file: client/installer.nsis
- arguments: "/V4 /DARCH=${{ matrix.arch }}"
+ shell: pwsh
env:
APPVER: ${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}.${{ steps.semver_parser.outputs.patch }}.${{ github.run_id }}
+ run: |
+ $nsisPluginDir = "C:\Program Files (x86)\NSIS\Plugins\x86-unicode"
+ $srcPlugins = "${{ github.workspace }}\NSIS_Plugins\Plugins"
+ Get-ChildItem -Path $srcPlugins -Recurse -Filter *.dll |
+ Copy-Item -Destination $nsisPluginDir -Force
+ & "C:\Program Files (x86)\NSIS\makensis.exe" /V4 "/DARCH=${{ matrix.arch }}" client\installer.nsis
+ if ($LASTEXITCODE -ne 0) { throw "makensis failed with exit code $LASTEXITCODE" }
- name: Rename NSIS installer
run: mv netbird-installer.exe netbird_installer_test_windows_${{ matrix.arch }}.exe
@@ -592,7 +600,7 @@ jobs:
- name: Upload installer artifacts
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: windows-installer-test-${{ matrix.arch }}
path: |
@@ -611,7 +619,7 @@ jobs:
pull-requests: write
steps:
- name: Create or update PR comment
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
RELEASE_RESULT: ${{ needs.release.result }}
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
@@ -703,7 +711,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Trigger binaries sign pipelines
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: Sign bin and installer
repo: netbirdio/sign-pipelines
diff --git a/.github/workflows/sync-main.yml b/.github/workflows/sync-main.yml
index e36e35a2d..5805fcf57 100644
--- a/.github/workflows/sync-main.yml
+++ b/.github/workflows/sync-main.yml
@@ -14,9 +14,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger main branch sync
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: sync-main.yml
repo: ${{ secrets.UPSTREAM_REPO }}
token: ${{ secrets.NC_GITHUB_TOKEN }}
- inputs: '{ "sha": "${{ github.sha }}" }'
\ No newline at end of file
+ inputs: '{ "sha": "${{ github.sha }}" }'
diff --git a/.github/workflows/sync-tag.yml b/.github/workflows/sync-tag.yml
index a75d9a9d5..d99f88b54 100644
--- a/.github/workflows/sync-tag.yml
+++ b/.github/workflows/sync-tag.yml
@@ -3,7 +3,7 @@ name: sync tag
on:
push:
tags:
- - 'v*'
+ - "v*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger release tag sync
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: sync-tag.yml
ref: main
@@ -29,7 +29,7 @@ jobs:
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
steps:
- name: Trigger android-client submodule bump
- uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: bump-netbird.yml
ref: main
@@ -42,10 +42,10 @@ jobs:
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
steps:
- name: Trigger ios-client submodule bump
- uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: bump-netbird.yml
ref: main
repo: netbirdio/ios-client
token: ${{ secrets.NC_GITHUB_TOKEN }}
- inputs: '{ "tag": "${{ github.ref_name }}" }'
\ No newline at end of file
+ inputs: '{ "tag": "${{ github.ref_name }}" }'
diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml
index e2f950731..9ad1f2f67 100644
--- a/.github/workflows/test-infrastructure-files.yml
+++ b/.github/workflows/test-infrastructure-files.yml
@@ -6,10 +6,10 @@ on:
- main
pull_request:
paths:
- - 'infrastructure_files/**'
- - '.github/workflows/test-infrastructure-files.yml'
- - 'management/cmd/**'
- - 'signal/cmd/**'
+ - "infrastructure_files/**"
+ - ".github/workflows/test-infrastructure-files.yml"
+ - "management/cmd/**"
+ - "signal/cmd/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- store: [ 'sqlite', 'postgres', 'mysql' ]
+ store: ["sqlite", "postgres", "mysql"]
services:
postgres:
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
@@ -68,15 +68,17 @@ jobs:
run: sudo apt-get install -y curl
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
@@ -139,8 +141,8 @@ jobs:
CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret
CI_NETBIRD_SIGNAL_PORT: 12345
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
- NETBIRD_STORE_ENGINE_POSTGRES_DSN: '${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$'
- NETBIRD_STORE_ENGINE_MYSQL_DSN: '${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$'
+ NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$"
+ NETBIRD_STORE_ENGINE_MYSQL_DSN: "${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$"
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
@@ -254,7 +256,9 @@ jobs:
run: sudo apt-get install -y jq
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: run script with Zitadel PostgreSQL
run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh
diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml
index 26f3b8f02..ff4f0a86a 100644
--- a/.github/workflows/update-docs.yml
+++ b/.github/workflows/update-docs.yml
@@ -3,9 +3,9 @@ name: update docs
on:
push:
tags:
- - 'v*'
+ - "v*"
paths:
- - 'shared/management/http/api/openapi.yml'
+ - "shared/management/http/api/openapi.yml"
jobs:
trigger_docs_api_update:
@@ -13,10 +13,10 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Trigger API pages generation
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: generate api pages
repo: netbirdio/docs
ref: "refs/heads/main"
token: ${{ secrets.SIGN_GITHUB_TOKEN }}
- inputs: '{ "tag": "${{ github.ref }}" }'
\ No newline at end of file
+ inputs: '{ "tag": "${{ github.ref }}" }'
diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml
index 6644840fe..318a127dd 100644
--- a/.github/workflows/wasm-build-validation.yml
+++ b/.github/workflows/wasm-build-validation.yml
@@ -19,15 +19,17 @@ jobs:
GOARCH: wasm
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: Install dependencies
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
- name: Install golangci-lint
- uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
+ uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
with:
version: latest
install-mode: binary
@@ -42,9 +44,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: "go.mod"
- name: Build Wasm client
@@ -65,4 +69,3 @@ jobs:
echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!"
exit 1
fi
-
diff --git a/README.md b/README.md
index dc84af2fd..cc27e2d28 100644
--- a/README.md
+++ b/README.md
@@ -1,147 +1,134 @@
-
-
- Start using NetBird at netbird.io
+
+ Start using NetBird at netbird.io
+
+ See Documentation
+
+ Join our Slack channel or our Community forum
+
- See Documentation
- Join our Slack channel or our Community forum
-
-
-
-
-
- 🚀 We are hiring! Join us at careers.netbird.io
-
-
-
-
- New: NetBird terraform provider
-
+
+ 🚀 We are hiring! Join us at careers.netbird.io
+
-
-
**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.
**Secure.** NetBird enables secure remote access by applying granular access policies while allowing you to manage them intuitively from a single place. Works universally on any infrastructure.
-### Open Source Network Security in a Single Platform
-
https://github.com/user-attachments/assets/10cec749-bb56-4ab3-97af-4e38850108d2
-### Self-Host NetBird (Video)
+### Self-host NetBird (video)
+
[](https://youtu.be/bZAgpT6nzaQ)
### Key features
-| Connectivity | Management | Security | Automation| Platforms |
-|----|----|----|----|----|
-| | - - \[x] [Admin Web UI](https://github.com/netbirdio/dashboard)
| - - \[x] [SSO & MFA support](https://docs.netbird.io/how-to/installation#running-net-bird-with-sso-login)
| - - \[x] [Public API](https://docs.netbird.io/api)
| |
-| - - \[x] Peer-to-peer connections
| - - \[x] Auto peer discovery and configuration
| - - \[x] [Access control - groups & rules](https://docs.netbird.io/how-to/manage-network-access)
| - - \[x] [Setup keys for bulk network provisioning](https://docs.netbird.io/how-to/register-machines-using-setup-keys)
| - - \[x] Mac
|
-| - - \[x] Connection relay fallback
| - - \[x] [IdP integrations](https://docs.netbird.io/selfhosted/identity-providers)
| - - \[x] [Activity logging](https://docs.netbird.io/how-to/audit-events-logging)
| - - \[x] [Self-hosting quickstart script](https://docs.netbird.io/selfhosted/selfhosted-quickstart)
| - - \[x] Windows
|
-| - - \[x] [Routes to external networks](https://docs.netbird.io/how-to/routing-traffic-to-private-networks)
| - - \[x] [Private DNS](https://docs.netbird.io/how-to/manage-dns-in-your-network)
| - - \[x] [Device posture checks](https://docs.netbird.io/how-to/manage-posture-checks)
| - - \[x] IdP groups sync with JWT
| - - \[x] Android
|
-| - - \[x] NAT traversal with BPF
| - - \[x] [Multiuser support](https://docs.netbird.io/how-to/add-users-to-your-network)
| - - \[x] Peer-to-peer encryption
|| - - \[x] iOS
|
-||| - - \[x] [Quantum-resistance with Rosenpass](https://netbird.io/knowledge-hub/the-first-quantum-resistant-mesh-vpn)
|| - - \[x] OpenWRT
|
-||| - - \[x] [Periodic re-authentication](https://docs.netbird.io/how-to/enforce-periodic-user-authentication)
|| - - \[x] [Serverless](https://docs.netbird.io/how-to/netbird-on-faas)
|
-||||| - - \[x] Docker
|
+| Connectivity | Management | Security | Automation | Platforms |
+|---|---|---|---|---|
+| ✓ [Kernel WireGuard](https://docs.netbird.io/about-netbird/why-wireguard-with-netbird) | ✓ [Admin Web UI](https://github.com/netbirdio/dashboard) | ✓ [SSO & MFA support](https://docs.netbird.io/how-to/installation#running-net-bird-with-sso-login) | ✓ [Public API](https://docs.netbird.io/api) | ✓ [Linux](https://docs.netbird.io/get-started/install/linux) |
+| ✓ [Peer-to-peer connections](https://docs.netbird.io/about-netbird/how-netbird-works) | ✓ Auto peer discovery and configuration | ✓ [Access control: groups & rules](https://docs.netbird.io/how-to/manage-network-access) | ✓ [Setup keys for bulk provisioning](https://docs.netbird.io/how-to/register-machines-using-setup-keys) | ✓ [macOS](https://docs.netbird.io/get-started/install/macos) |
+| ✓ Connection relay fallback | ✓ [IdP integrations](https://docs.netbird.io/selfhosted/identity-providers) | ✓ [Activity logging](https://docs.netbird.io/how-to/audit-events-logging) | ✓ [Self-hosting quickstart script](https://docs.netbird.io/selfhosted/selfhosted-quickstart) | ✓ [Windows](https://docs.netbird.io/get-started/install/windows) |
+| ✓ [Routes to external networks](https://docs.netbird.io/how-to/routing-traffic-to-private-networks) | ✓ [Private DNS](https://docs.netbird.io/how-to/manage-dns-in-your-network) | ✓ [Traffic events](https://docs.netbird.io/manage/activity/traffic-events-logging) | ✓ [IdP groups sync with JWT](https://docs.netbird.io/manage/team/idp-sync) | ✓ [Android](https://docs.netbird.io/get-started/install/android) |
+| ✓ [Domain-based DNS routes](https://docs.netbird.io/manage/dns/dns-aliases-for-routed-networks) | ✓ [Custom DNS zones](https://docs.netbird.io/manage/dns/custom-zones) | ✓ [Device posture checks](https://docs.netbird.io/how-to/manage-posture-checks) | ✓ [Terraform provider](https://registry.terraform.io/providers/netbirdio/netbird/latest) | ✓ [Android TV](https://docs.netbird.io/get-started/install/android-tv) |
+| ✓ [Exit nodes](https://docs.netbird.io/manage/network-routes/use-cases/exit-nodes) | ✓ [Multiuser support](https://docs.netbird.io/how-to/add-users-to-your-network) | ✓ Peer-to-peer encryption | ✓ [Ansible collection](https://github.com/netbirdio/ansible-netbird) | ✓ [iOS](https://docs.netbird.io/get-started/install/ios) |
+| ✓ [IPv6 dual-stack overlay](https://docs.netbird.io/manage/settings/ipv6) | ✓ [Multi-account profile switching](https://docs.netbird.io/client/profiles) | ✓ [SSH with central access policies](https://docs.netbird.io/manage/peers/ssh) | | ✓ [Apple TV](https://docs.netbird.io/get-started/install/tvos) |
+| ✓ [Browser SSH & RDP](https://docs.netbird.io/manage/peers/browser-client) | | ✓ [Quantum-resistance with Rosenpass](https://netbird.io/knowledge-hub/the-first-quantum-resistant-mesh-vpn) | | ✓ FreeBSD |
+| ✓ [Reverse proxy with auto-TLS](https://docs.netbird.io/manage/reverse-proxy) | | ✓ [Periodic re-authentication](https://docs.netbird.io/how-to/enforce-periodic-user-authentication) | | ✓ [pfSense](https://docs.netbird.io/get-started/install/pfsense) |
+| | | | | ✓ [OPNsense](https://docs.netbird.io/get-started/install/opnsense) |
+| | | | | ✓ [MikroTik RouterOS](https://docs.netbird.io/use-cases/homelab/client-on-mikrotik-router) |
+| | | | | ✓ OpenWRT |
+| | | | | ✓ [Synology](https://docs.netbird.io/get-started/install/synology) |
+| | | | | ✓ [TrueNAS](https://docs.netbird.io/get-started/install/truenas) |
+| | | | | ✓ [Proxmox](https://docs.netbird.io/get-started/install/proxmox-ve) |
+| | | | | ✓ [Raspberry Pi](https://docs.netbird.io/get-started/install/raspberrypi) |
+| | | | | ✓ [Serverless](https://docs.netbird.io/how-to/netbird-on-faas) |
+| | | | | ✓ [Container](https://docs.netbird.io/get-started/install/docker) |
### Quickstart with NetBird Cloud
-- Download and install NetBird at [https://app.netbird.io/install](https://app.netbird.io/install)
-- Follow the steps to sign-up with Google, Microsoft, GitHub or your email address.
-- Check NetBird [admin UI](https://app.netbird.io/).
-- Add more machines.
+- Download and install NetBird at [https://app.netbird.io/install](https://app.netbird.io/install).
+- Follow the steps to sign up with Google, Microsoft, GitHub or your email address.
+- Check the NetBird [admin UI](https://app.netbird.io/).
### Quickstart with self-hosted NetBird
-> This is the quickest way to try self-hosted NetBird. It should take around 5 minutes to get started if you already have a public domain and a VM.
-Follow the [Advanced guide with a custom identity provider](https://docs.netbird.io/selfhosted/selfhosted-guide#advanced-guide-with-a-custom-identity-provider) for installations with different IDPs.
+This is the quickest way to try self-hosted NetBird. It should take around 5 minutes to get started if you already have a public domain and a VM. Follow the [Advanced guide with a custom identity provider](https://docs.netbird.io/selfhosted/selfhosted-guide#advanced-guide-with-a-custom-identity-provider) for installations with different IdPs.
**Infrastructure requirements:**
-- A Linux VM with at least **1CPU** and **2GB** of memory.
-- The VM should be publicly accessible on TCP ports **80** and **443** and UDP port: **3478**.
-- **Public domain** name pointing to the VM.
+- A Linux VM with at least **1 CPU** and **2 GB** of memory.
+- The VM should be publicly accessible on TCP ports **80** and **443** and UDP port **3478**.
+- A **public domain** name pointing to the VM.
**Software requirements:**
-- Docker installed on the VM with the docker-compose plugin ([Docker installation guide](https://docs.docker.com/engine/install/)) or docker with docker-compose in version 2 or higher.
-- [jq](https://jqlang.github.io/jq/) installed. In most distributions
- Usually available in the official repositories and can be installed with `sudo apt install jq` or `sudo yum install jq`
-- [curl](https://curl.se/) installed.
- Usually available in the official repositories and can be installed with `sudo apt install curl` or `sudo yum install curl`
+- Docker with the Compose plugin (Compose v2 or higher). See the [Docker installation guide](https://docs.docker.com/engine/install/).
**Steps**
- Download and run the installation script:
```bash
export NETBIRD_DOMAIN=netbird.example.com; curl -fsSL https://github.com/netbirdio/netbird/releases/latest/download/getting-started.sh | bash
```
-- Once finished, you can manage the resources via `docker-compose`
### A bit on NetBird internals
-- Every machine in the network runs [NetBird Agent (or Client)](client/) that manages WireGuard.
-- Every agent connects to [Management Service](management/) that holds network state, manages peer IPs, and distributes network updates to agents (peers).
-- NetBird agent uses WebRTC ICE implemented in [pion/ice library](https://github.com/pion/ice) to discover connection candidates when establishing a peer-to-peer connection between machines.
-- Connection candidates are discovered with the help of [STUN](https://en.wikipedia.org/wiki/STUN) servers.
-- Agents negotiate a connection through [Signal Service](signal/) passing p2p encrypted messages with candidates.
-- Sometimes the NAT traversal is unsuccessful due to strict NATs (e.g. mobile carrier-grade NAT) and a p2p connection isn't possible. When this occurs the system falls back to a relay server called [TURN](https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT), and a secure WireGuard tunnel is established via the TURN server.
-
-[Coturn](https://github.com/coturn/coturn) is the one that has been successfully used for STUN and TURN in NetBird setups.
+- Every machine in the network runs the [NetBird agent](client/), which manages WireGuard.
+- Every agent connects to the [Management Service](management/), which holds network state, manages peer IPs, and distributes updates to agents.
+- Agents use ICE (via [pion/ice](https://github.com/pion/ice)) to discover connection candidates for peer-to-peer connections.
+- Candidates are discovered with the help of [STUN](https://en.wikipedia.org/wiki/STUN) servers.
+- Agents negotiate a connection through the [Signal Service](signal/), exchanging end-to-end encrypted messages with candidates.
+- When NAT traversal fails (e.g. mobile carrier-grade NAT) and a direct p2p connection isn't possible, the system falls back to a [Relay Service](relay/) and a secure WireGuard tunnel is established through it.
-
+
See a complete [architecture overview](https://docs.netbird.io/about-netbird/how-netbird-works#architecture) for details.
### Community projects
-- [NetBird installer script](https://github.com/physk/netbird-installer)
-- [NetBird ansible collection by Dominion Solutions](https://galaxy.ansible.com/ui/repo/published/dominion_solutions/netbird/)
-- [netbird-tui](https://github.com/n0pashkov/netbird-tui) — terminal UI for managing NetBird peers, routes, and settings
+- [NetBird installer script](https://github.com/physk/netbird-installer)
+- [netbird-tui](https://github.com/n0pashkov/netbird-tui) - terminal UI for managing NetBird peers, routes, and settings
+- [caddy-netbird](https://github.com/lixmal/caddy-netbird) - Caddy plugin that embeds a NetBird client for proxying HTTP and TCP/UDP traffic through NetBird networks
**Note**: The `main` branch may be in an *unstable or even broken state* during development.
For stable versions, see [releases](https://github.com/netbirdio/netbird/releases).
### Support acknowledgement
-In November 2022, NetBird joined the [StartUpSecure program](https://www.forschung-it-sicherheit-kommunikationssysteme.de/foerderung/bekanntmachungen/startup-secure) sponsored by The Federal Ministry of Education and Research of The Federal Republic of Germany. Together with [CISPA Helmholtz Center for Information Security](https://cispa.de/en) NetBird brings the security best practices and simplicity to private networking.
+In November 2022, NetBird joined the [StartUpSecure program](https://www.forschung-it-sicherheit-kommunikationssysteme.de/foerderung/bekanntmachungen/startup-secure) sponsored by the Federal Ministry of Education and Research of the Federal Republic of Germany. Together with the [CISPA Helmholtz Center for Information Security](https://cispa.de/en), NetBird brings security best practices and simplicity to private networking.

-### Testimonials
-We use open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE (WebRTC)](https://github.com/pion/ice), [Coturn](https://github.com/coturn/coturn), and [Rosenpass](https://rosenpass.eu). We very much appreciate the work these guys are doing and we'd greatly appreciate if you could support them in any way (e.g., by giving a star or a contribution).
+### Acknowledgements
+We build on open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing).
### Legal
-This repository is licensed under BSD-3-Clause license that applies to all parts of the repository except for the directories management/, signal/ and relay/.
+This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/.
Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory.
_WireGuard_ and the _WireGuard_ logo are [registered trademarks](https://www.wireguard.com/trademark-policy/) of Jason A. Donenfeld.
diff --git a/client/cmd/debug.go b/client/cmd/debug.go
index 2a8cdc887..02a742b28 100644
--- a/client/cmd/debug.go
+++ b/client/cmd/debug.go
@@ -19,6 +19,7 @@ import (
"github.com/netbirdio/netbird/client/server"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/upload-server/types"
+ "github.com/netbirdio/netbird/version"
)
const errCloseConnection = "Failed to close connection: %v"
@@ -100,6 +101,7 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
Anonymize: anonymizeFlag,
SystemInfo: systemInfoFlag,
LogFileCount: logFileCount,
+ CliVersion: version.NetbirdVersion(),
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
@@ -298,6 +300,7 @@ func runForDuration(cmd *cobra.Command, args []string) error {
Anonymize: anonymizeFlag,
SystemInfo: systemInfoFlag,
LogFileCount: logFileCount,
+ CliVersion: version.NetbirdVersion(),
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
@@ -432,6 +435,7 @@ func generateDebugBundle(config *profilemanager.Config, recorder *peer.Status, c
SyncResponse: syncResponse,
LogPath: logFilePath,
CPUProfile: nil,
+ DaemonVersion: version.NetbirdVersion(), // acting as daemon
},
debug.BundleConfig{
IncludeSystemInfo: true,
diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go
index 88121c067..8de147946 100644
--- a/client/cmd/service_controller.go
+++ b/client/cmd/service_controller.go
@@ -102,7 +102,7 @@ func (p *program) Stop(srv service.Service) error {
}
// Common setup for service control commands
-func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) {
+func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc, consoleLog bool) (service.Service, error) {
// rootCmd env vars are already applied by PersistentPreRunE.
SetFlagsFromEnvVars(serviceCmd)
@@ -112,8 +112,14 @@ func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel
return nil, err
}
- if err := util.InitLog(logLevel, logFiles...); err != nil {
- return nil, fmt.Errorf("init log: %w", err)
+ if consoleLog {
+ if err := util.InitLog(logLevel, util.LogConsole); err != nil {
+ return nil, fmt.Errorf("init log: %w", err)
+ }
+ } else {
+ if err := util.InitLog(logLevel, logFiles...); err != nil {
+ return nil, fmt.Errorf("init log: %w", err)
+ }
}
cfg, err := newSVCConfig()
@@ -138,7 +144,7 @@ var runCmd = &cobra.Command{
SetupCloseHandler(ctx, cancel)
SetupDebugHandler(ctx, nil, nil, nil, util.FindFirstLogPath(logFiles))
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -152,7 +158,7 @@ var startCmd = &cobra.Command{
Short: "starts NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -170,7 +176,7 @@ var stopCmd = &cobra.Command{
Short: "stops NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -188,7 +194,7 @@ var restartCmd = &cobra.Command{
Short: "restarts NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -206,7 +212,7 @@ var svcStatusCmd = &cobra.Command{
Short: "shows NetBird service status",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, true)
if err != nil {
return err
}
diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go
index c24965e8d..205327ef5 100644
--- a/client/cmd/testutil_test.go
+++ b/client/cmd/testutil_test.go
@@ -11,7 +11,7 @@ import (
"go.opentelemetry.io/otel"
"google.golang.org/grpc"
- "github.com/netbirdio/management-integrations/integrations"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
nbcache "github.com/netbirdio/netbird/management/server/cache"
@@ -109,7 +109,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
t.Fatal(err)
}
- iv, _ := integrations.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
+ iv, _ := validator.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
require.NoError(t, err)
diff --git a/client/cmd/version.go b/client/cmd/version.go
index 249854444..5deeae1a0 100644
--- a/client/cmd/version.go
+++ b/client/cmd/version.go
@@ -12,7 +12,13 @@ var (
Short: "Print the NetBird's client application version",
Run: func(cmd *cobra.Command, args []string) {
cmd.SetOut(cmd.OutOrStdout())
- cmd.Println(version.NetbirdVersion())
+ out := version.NetbirdVersion()
+ if version.IsDevelopmentVersion(out) {
+ if commit := version.NetbirdCommit(); commit != "" {
+ out += "-" + commit
+ }
+ }
+ cmd.Println(out)
},
}
)
diff --git a/client/embed/embed.go b/client/embed/embed.go
index 8b669e547..04bc60fb8 100644
--- a/client/embed/embed.go
+++ b/client/embed/embed.go
@@ -12,6 +12,7 @@ import (
"sync"
"github.com/sirupsen/logrus"
+ wgdevice "golang.zx2c4.com/wireguard/device"
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
"github.com/netbirdio/netbird/client/iface"
@@ -84,6 +85,12 @@ type Options struct {
DisableIPv6 bool
// BlockInbound blocks all inbound connections from peers
BlockInbound bool
+ // BlockLANAccess blocks the embedded peer from reaching the host's
+ // LAN (RFC 1918, link-local, loopback) when it's used as a routing
+ // peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful
+ // when the embedded client must never act as a stepping stone into
+ // the host's local network (e.g. the proxy's overlay peer).
+ BlockLANAccess bool
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
WireguardPort *int
// MTU is the MTU for the tunnel interface.
@@ -94,6 +101,26 @@ type Options struct {
MTU *uint16
// DNSLabels defines additional DNS labels configured in the peer.
DNSLabels []string
+ // Performance configures the tunnel's buffer pool cap and batch size.
+ Performance Performance
+}
+
+// Performance configures the embedded client's tunnel memory/throughput knobs.
+//
+// These settings are process-global: any non-nil field also becomes the
+// default for Clients constructed by later embed.New calls in the same
+// process. Nil fields are ignored.
+type Performance struct {
+ // PreallocatedBuffersPerPool caps the per-tunnel buffer pool. Zero
+ // leaves the pool unbounded. Lower values trade throughput for a
+ // tighter memory ceiling. May also be changed on a running Client via
+ // Client.SetPerformance, provided this field was nonzero at construction.
+ PreallocatedBuffersPerPool *uint32
+ // MaxBatchSize overrides the number of packets the tunnel reads or
+ // writes per syscall, which also bounds eager buffer allocation per
+ // worker. Zero uses the platform default. Applied at construction
+ // only; ignored by Client.SetPerformance.
+ MaxBatchSize *uint32
}
// validateCredentials checks that exactly one credential type is provided
@@ -175,6 +202,7 @@ func New(opts Options) (*Client, error) {
DisableClientRoutes: &opts.DisableClientRoutes,
DisableIPv6: &opts.DisableIPv6,
BlockInbound: &opts.BlockInbound,
+ BlockLANAccess: &opts.BlockLANAccess,
WireguardPort: opts.WireguardPort,
MTU: opts.MTU,
DNSLabels: parsedLabels,
@@ -192,6 +220,13 @@ func New(opts Options) (*Client, error) {
config.PrivateKey = opts.PrivateKey
}
+ if opts.Performance.PreallocatedBuffersPerPool != nil {
+ wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
+ }
+ if opts.Performance.MaxBatchSize != nil {
+ wgdevice.SetMaxBatchSizeOverride(*opts.Performance.MaxBatchSize)
+ }
+
return &Client{
deviceName: opts.DeviceName,
setupKey: opts.SetupKey,
@@ -405,6 +440,21 @@ func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession,
}, nil
}
+// IdentityForIP looks up a remote peer by its tunnel IP using the
+// embedded client's status recorder. Returns the peer's WireGuard public
+// key and FQDN. ok=false means the IP isn't in this client's peer
+// roster — callers should treat that as "unknown peer".
+func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
+ if !ip.IsValid() || c.recorder == nil {
+ return "", "", false
+ }
+ state, found := c.recorder.PeerStateByIP(ip.String())
+ if !found {
+ return "", "", false
+ }
+ return state.PubKey, state.FQDN, true
+}
+
// Status returns the current status of the client.
func (c *Client) Status() (peer.FullStatus, error) {
c.mu.Lock()
@@ -473,6 +523,25 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
}
+// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool
+// takes effect, and only when it was nonzero at construction;
+// MaxBatchSize is construction-only and returns an error if set here.
+//
+// Returns ErrClientNotStarted / ErrEngineNotStarted if the Client is not
+// running yet.
+func (c *Client) SetPerformance(t Performance) error {
+ if t.MaxBatchSize != nil {
+ return errors.New("MaxBatchSize is construction-only and cannot be changed at runtime")
+ }
+ engine, err := c.getEngine()
+ if err != nil {
+ return err
+ }
+ return engine.SetPerformance(internal.Performance{
+ PreallocatedBuffersPerPool: t.PreallocatedBuffersPerPool,
+ })
+}
+
// StartCapture begins capturing packets on this client's tunnel device.
// Only one capture can be active at a time; starting a new one stops the previous.
// Call StopCapture (or CaptureSession.Stop) to end it.
diff --git a/client/firewall/iptables/acl_linux.go b/client/firewall/iptables/acl_linux.go
index e5e19cec9..4b4cebf9c 100644
--- a/client/firewall/iptables/acl_linux.go
+++ b/client/firewall/iptables/acl_linux.go
@@ -3,6 +3,7 @@ package iptables
import (
"errors"
"fmt"
+ "maps"
"net"
"slices"
@@ -421,12 +422,17 @@ func (m *aclManager) updateState() {
currentState.Lock()
defer currentState.Unlock()
+ // Clone the maps so the persisted state holds a private snapshot. The
+ // live maps keep being mutated by subsequent rule operations while the
+ // state manager marshals the state from its periodic-save goroutine.
+ // Sharing them by reference races the two and aborts the process with a
+ // concurrent map iteration and write.
if m.v6 {
- currentState.ACLEntries6 = m.entries
- currentState.ACLIPsetStore6 = m.ipsetStore
+ currentState.ACLEntries6 = maps.Clone(m.entries)
+ currentState.ACLIPsetStore6 = m.ipsetStore.clone()
} else {
- currentState.ACLEntries = m.entries
- currentState.ACLIPsetStore = m.ipsetStore
+ currentState.ACLEntries = maps.Clone(m.entries)
+ currentState.ACLIPsetStore = m.ipsetStore.clone()
}
if err := m.stateManager.UpdateState(currentState); err != nil {
diff --git a/client/firewall/iptables/router_linux.go b/client/firewall/iptables/router_linux.go
index 290e5da1e..42d305f5c 100644
--- a/client/firewall/iptables/router_linux.go
+++ b/client/firewall/iptables/router_linux.go
@@ -4,6 +4,7 @@ package iptables
import (
"fmt"
+ "maps"
"net/netip"
"strconv"
"strings"
@@ -749,11 +750,17 @@ func (r *router) updateState() {
currentState.Lock()
defer currentState.Unlock()
+ // Clone the rule map so the persisted state holds a private snapshot. The
+ // live map keeps being mutated by subsequent rule operations while the
+ // state manager marshals the state from its periodic-save goroutine.
+ // Sharing it by reference races the two and aborts the process with a
+ // concurrent map iteration and write. The ipset counter guards itself
+ // during marshaling, so it can be shared directly.
if r.v6 {
- currentState.RouteRules6 = r.rules
+ currentState.RouteRules6 = maps.Clone(r.rules)
currentState.RouteIPsetCounter6 = r.ipsetCounter
} else {
- currentState.RouteRules = r.rules
+ currentState.RouteRules = maps.Clone(r.rules)
currentState.RouteIPsetCounter = r.ipsetCounter
}
diff --git a/client/firewall/iptables/rulestore_linux.go b/client/firewall/iptables/rulestore_linux.go
index 004c512a4..a6d36540e 100644
--- a/client/firewall/iptables/rulestore_linux.go
+++ b/client/firewall/iptables/rulestore_linux.go
@@ -1,6 +1,9 @@
package iptables
-import "encoding/json"
+import (
+ "encoding/json"
+ "maps"
+)
type ipList struct {
ips map[string]struct{}
@@ -19,6 +22,14 @@ func (s *ipList) addIP(ip string) {
s.ips[ip] = struct{}{}
}
+// clone returns a deep copy of the ipList with its own ips map.
+func (s *ipList) clone() *ipList {
+ if s == nil {
+ return nil
+ }
+ return &ipList{ips: maps.Clone(s.ips)}
+}
+
// MarshalJSON implements json.Marshaler
func (s *ipList) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
@@ -55,6 +66,19 @@ func newIpsetStore() *ipsetStore {
}
}
+// clone returns a deep copy of the ipsetStore with its own ipsets map and
+// independent ipList entries.
+func (s *ipsetStore) clone() *ipsetStore {
+ if s == nil {
+ return nil
+ }
+ cloned := &ipsetStore{ipsets: make(map[string]*ipList, len(s.ipsets))}
+ for name, list := range s.ipsets {
+ cloned.ipsets[name] = list.clone()
+ }
+ return cloned
+}
+
func (s *ipsetStore) ipset(ipsetName string) (*ipList, bool) {
r, ok := s.ipsets[ipsetName]
return r, ok
diff --git a/client/firewall/nftables/external_chain_monitor_linux.go b/client/firewall/nftables/external_chain_monitor_linux.go
index 2a2e04c09..9c91c95cf 100644
--- a/client/firewall/nftables/external_chain_monitor_linux.go
+++ b/client/firewall/nftables/external_chain_monitor_linux.go
@@ -52,9 +52,10 @@ func (m *externalChainMonitor) start() {
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
- m.done = make(chan struct{})
+ done := make(chan struct{})
+ m.done = done
- go m.run(ctx)
+ go m.run(ctx, done)
}
func (m *externalChainMonitor) stop() {
@@ -72,8 +73,8 @@ func (m *externalChainMonitor) stop() {
<-done
}
-func (m *externalChainMonitor) run(ctx context.Context) {
- defer close(m.done)
+func (m *externalChainMonitor) run(ctx context.Context, done chan struct{}) {
+ defer close(done)
bo := &backoff.ExponentialBackOff{
InitialInterval: externalMonitorInitInterval,
diff --git a/client/firewall/uspfilter/forwarder/icmp.go b/client/firewall/uspfilter/forwarder/icmp.go
index d6d4e705e..94a50570f 100644
--- a/client/firewall/uspfilter/forwarder/icmp.go
+++ b/client/firewall/uspfilter/forwarder/icmp.go
@@ -362,6 +362,10 @@ func (f *Forwarder) injectICMPv6Reply(id stack.TransportEndpointID, icmpPayload
return 0
}
+ if pc := f.endpoint.capture.Load(); pc != nil {
+ (*pc).Offer(fullPacket, true)
+ }
+
return len(fullPacket)
}
diff --git a/client/installer.nsis b/client/installer.nsis
index 3e057df10..63bff1c5b 100644
--- a/client/installer.nsis
+++ b/client/installer.nsis
@@ -260,23 +260,15 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
-; Drop Run, App Paths and Uninstall entries left in the 32-bit registry view
-; or HKCU by legacy installers.
-DetailPrint "Cleaning legacy 32-bit / HKCU entries..."
-DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
-SetRegView 32
-DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
-DeleteRegKey HKLM "${REG_APP_PATH}"
-DeleteRegKey HKLM "${UI_REG_APP_PATH}"
-DeleteRegKey HKLM "${UNINSTALL_PATH}"
-SetRegView 64
-
+; Create autostart registry entry based on checkbox
DetailPrint "Autostart enabled: $AutostartEnabled"
${If} $AutostartEnabled == "1"
WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
${Else}
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
+ ; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
+ DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
DetailPrint "Autostart not enabled by user"
${EndIf}
@@ -307,16 +299,11 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall'
DetailPrint "Terminating Netbird UI process..."
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
-; Remove autostart entries from every view a previous installer may have used.
+; Remove autostart registry entry
DetailPrint "Removing autostart registry entry if exists..."
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
+; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
-SetRegView 32
-DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
-DeleteRegKey HKLM "${REG_APP_PATH}"
-DeleteRegKey HKLM "${UI_REG_APP_PATH}"
-DeleteRegKey HKLM "${UNINSTALL_PATH}"
-SetRegView 64
; Handle data deletion based on checkbox
DetailPrint "Checking if user requested data deletion..."
diff --git a/client/internal/auth/pkce_flow.go b/client/internal/auth/pkce_flow.go
index 2e16836d8..84fa8a214 100644
--- a/client/internal/auth/pkce_flow.go
+++ b/client/internal/auth/pkce_flow.go
@@ -360,7 +360,13 @@ func isRedirectURLPortUsed(redirectURL string, excludedRanges []excludedPortRang
return true
}
- addr := fmt.Sprintf(":%s", port)
+ // FreeBSD 15 disables connecting to INADDR_ANY (0.0.0.0) as a localhost
+ // alias by default, ensure explicit ip for localhost.
+ host := parsedURL.Hostname()
+ if host == "" {
+ host = "127.0.0.1"
+ }
+ addr := net.JoinHostPort(host, port)
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return false
diff --git a/client/internal/connect.go b/client/internal/connect.go
index ade43e9f4..1a7a6836f 100644
--- a/client/internal/connect.go
+++ b/client/internal/connect.go
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/netip"
+ "path/filepath"
"runtime"
"runtime/debug"
"strings"
@@ -346,6 +347,11 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
return wrapErr(err)
}
engineConfig.TempDir = mobileDependency.TempDir
+ // Leave StateDir empty when there is no state path so a disk-backed
+ // syncstore falls back to os.TempDir() instead of filepath.Dir("") == ".".
+ if path != "" {
+ engineConfig.StateDir = filepath.Dir(path)
+ }
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
c.statusRecorder.SetRelayMgr(relayManager)
diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go
index f259d4e1d..9a4e56dce 100644
--- a/client/internal/debug/debug.go
+++ b/client/internal/debug/debug.go
@@ -254,6 +254,8 @@ type BundleGenerator struct {
capturePath string
refreshStatus func() // Optional callback to refresh status before bundle generation
clientMetrics MetricsExporter
+ daemonVersion string
+ cliVersion string
anonymize bool
includeSystemInfo bool
@@ -278,6 +280,8 @@ type GeneratorDependencies struct {
CapturePath string
RefreshStatus func()
ClientMetrics MetricsExporter
+ DaemonVersion string
+ CliVersion string
}
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
@@ -299,6 +303,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
capturePath: deps.CapturePath,
refreshStatus: deps.RefreshStatus,
clientMetrics: deps.ClientMetrics,
+ daemonVersion: deps.DaemonVersion,
+ cliVersion: deps.CliVersion,
anonymize: cfg.Anonymize,
includeSystemInfo: cfg.IncludeSystemInfo,
@@ -459,9 +465,11 @@ func (g *BundleGenerator) addStatus() error {
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
- Anonymize: g.anonymize,
- ProfileName: profName,
+ Anonymize: g.anonymize,
+ ProfileName: profName,
+ DaemonVersion: g.daemonVersion,
})
+ overview.CliVersion = g.cliVersion
statusOutput := overview.FullDetailSummary()
statusReader := strings.NewReader(statusOutput)
@@ -804,6 +812,8 @@ func (g *BundleGenerator) addSyncResponse() error {
AllowPartial: true,
}
+ g.maskSecrets()
+
jsonBytes, err := options.Marshal(g.syncResponse)
if err != nil {
return fmt.Errorf("generate json: %w", err)
@@ -816,6 +826,27 @@ func (g *BundleGenerator) addSyncResponse() error {
return nil
}
+func (g *BundleGenerator) maskSecrets() {
+ if g.syncResponse == nil || g.syncResponse.NetbirdConfig == nil {
+ return
+ }
+
+ if g.syncResponse.NetbirdConfig.Flow != nil {
+ g.syncResponse.NetbirdConfig.Flow.TokenPayload = maskedValue
+
+ }
+
+ if g.syncResponse.NetbirdConfig.Relay != nil {
+ g.syncResponse.NetbirdConfig.Relay.TokenPayload = maskedValue
+ }
+
+ for i := range g.syncResponse.NetbirdConfig.Turns {
+ if g.syncResponse.NetbirdConfig.Turns[i] != nil {
+ g.syncResponse.NetbirdConfig.Turns[i].Password = maskedValue
+ }
+ }
+}
+
func (g *BundleGenerator) addStateFile() error {
sm := profilemanager.NewServiceManager("")
path := sm.GetStatePath()
@@ -1045,7 +1076,8 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
return
}
- pattern := filepath.Join(logDir, "client-*.log.gz")
+ // This regex will match both logs rotated by us and logrotate on linux
+ pattern := filepath.Join(logDir, "client*.log.*")
files, err := filepath.Glob(pattern)
if err != nil {
log.Warnf("failed to glob rotated logs: %v", err)
@@ -1078,7 +1110,12 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
for i := 0; i < maxFiles; i++ {
name := filepath.Base(files[i])
- if err := g.addSingleLogFileGz(files[i], name); err != nil {
+ if strings.HasSuffix(name, ".gz") {
+ err = g.addSingleLogFileGz(files[i], name)
+ } else {
+ err = g.addSingleLogfile(files[i], name)
+ }
+ if err != nil {
log.Warnf("failed to add rotated log %s: %v", name, err)
}
}
diff --git a/client/internal/debug/debug_logfiles_test.go b/client/internal/debug/debug_logfiles_test.go
new file mode 100644
index 000000000..f6473f979
--- /dev/null
+++ b/client/internal/debug/debug_logfiles_test.go
@@ -0,0 +1,103 @@
+package debug
+
+import (
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestAddRotatedLogFiles_PicksUpAllVariants asserts that the rotated-log
+// glob picks up logs rotated by timberjack (gzipped) and by logrotate (plain
+// and gzipped), and skips unrelated files.
+func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) {
+ dir := t.TempDir()
+
+ writeFile(t, filepath.Join(dir, "client.log"), "active log\n")
+ writeFile(t, filepath.Join(dir, "other.log"), "unrelated\n")
+
+ timberjackRotated := "client-2026-05-21T10-30-45.000.log.gz"
+ writeGzFile(t, filepath.Join(dir, timberjackRotated), "timberjack rotated content\n")
+
+ logrotatePlain := "client.log.1"
+ writeFile(t, filepath.Join(dir, logrotatePlain), "logrotate plain content\n")
+
+ logrotateGz := "client.log.2.gz"
+ writeGzFile(t, filepath.Join(dir, logrotateGz), "logrotate gz content\n")
+
+ names := runAddRotatedLogFiles(t, dir, 10)
+
+ require.Contains(t, names, timberjackRotated, "timberjack rotated file should be in bundle")
+ require.Contains(t, names, logrotatePlain, "logrotate plain rotated file should be in bundle")
+ require.Contains(t, names, logrotateGz, "logrotate gzipped rotated file should be in bundle")
+ require.NotContains(t, names, "client.log", "active log should not be added by addRotatedLogFiles")
+ require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
+}
+
+// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest
+// logFileCount rotated files are bundled, ordered by mtime.
+func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
+ dir := t.TempDir()
+
+ oldest := filepath.Join(dir, "client.log.3")
+ middle := filepath.Join(dir, "client.log.2")
+ newest := filepath.Join(dir, "client.log.1")
+ writeFile(t, oldest, "old\n")
+ writeFile(t, middle, "mid\n")
+ writeFile(t, newest, "new\n")
+
+ now := time.Now()
+ require.NoError(t, os.Chtimes(oldest, now.Add(-2*time.Hour), now.Add(-2*time.Hour)))
+ require.NoError(t, os.Chtimes(middle, now.Add(-1*time.Hour), now.Add(-1*time.Hour)))
+ require.NoError(t, os.Chtimes(newest, now, now))
+
+ names := runAddRotatedLogFiles(t, dir, 2)
+
+ require.Contains(t, names, "client.log.1")
+ require.Contains(t, names, "client.log.2")
+ require.NotContains(t, names, "client.log.3", "oldest file should be dropped when logFileCount=2")
+}
+
+// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
+// zip writer and returns the set of entry names that ended up in the archive.
+func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} {
+ t.Helper()
+
+ var buf bytes.Buffer
+ g := &BundleGenerator{
+ archive: zip.NewWriter(&buf),
+ logFileCount: logFileCount,
+ }
+ g.addRotatedLogFiles(dir)
+ require.NoError(t, g.archive.Close())
+
+ zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
+ require.NoError(t, err)
+
+ names := make(map[string]struct{}, len(zr.File))
+ for _, f := range zr.File {
+ names[f.Name] = struct{}{}
+ }
+ return names
+}
+
+func writeFile(t *testing.T, path, content string) {
+ t.Helper()
+ require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
+}
+
+func writeGzFile(t *testing.T, path, content string) {
+ t.Helper()
+ var buf bytes.Buffer
+ gw := gzip.NewWriter(&buf)
+ _, err := io.WriteString(gw, content)
+ require.NoError(t, err)
+ require.NoError(t, gw.Close())
+ require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644))
+}
diff --git a/client/internal/dns/handler_chain.go b/client/internal/dns/handler_chain.go
index 57e7722d4..dc20146eb 100644
--- a/client/internal/dns/handler_chain.go
+++ b/client/internal/dns/handler_chain.go
@@ -339,8 +339,7 @@ func (c *HandlerChain) isHandlerMatch(qname string, entry HandlerEntry) bool {
case entry.Pattern == ".":
return true
case entry.IsWildcard:
- parts := strings.Split(strings.TrimSuffix(qname, entry.Pattern), ".")
- return len(parts) >= 2 && strings.HasSuffix(qname, entry.Pattern)
+ return strings.HasSuffix(qname, "."+entry.Pattern)
default:
// For non-wildcard patterns:
// If handler wants subdomain matching, allow suffix match
diff --git a/client/internal/dns/handler_chain_test.go b/client/internal/dns/handler_chain_test.go
index 034a760dc..b3db97ba3 100644
--- a/client/internal/dns/handler_chain_test.go
+++ b/client/internal/dns/handler_chain_test.go
@@ -164,6 +164,54 @@ func TestHandlerChain_ServeDNS_DomainMatching(t *testing.T) {
matchSubdomains: true,
shouldMatch: true,
},
+ {
+ name: "wildcard label-boundary mismatch (suffix overlap)",
+ handlerDomain: "*.b.test.",
+ queryDomain: "x.ab.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: false,
+ },
+ {
+ name: "wildcard label-boundary match",
+ handlerDomain: "*.b.test.",
+ queryDomain: "x.b.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: true,
+ },
+ {
+ name: "wildcard multi-label match",
+ handlerDomain: "*.b.test.",
+ queryDomain: "x.y.b.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: true,
+ },
+ {
+ name: "wildcard no match on multi-label apex",
+ handlerDomain: "*.b.test.",
+ queryDomain: "b.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: false,
+ },
+ {
+ name: "wildcard no match on unrelated suffix containment",
+ handlerDomain: "*.example.com.",
+ queryDomain: "notexample.com.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: false,
+ },
+ {
+ name: "wildcard accepts pattern registered without trailing dot",
+ handlerDomain: "*.b.test",
+ queryDomain: "x.b.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: true,
+ },
}
for _, tt := range tests {
@@ -273,6 +321,19 @@ func TestHandlerChain_ServeDNS_OverlappingDomains(t *testing.T) {
expectedCalls: 1,
expectedHandler: 2, // highest priority matching handler should be called
},
+ {
+ name: "overlapping wildcard suffixes route to correct handler",
+ handlers: []struct {
+ pattern string
+ priority int
+ }{
+ {pattern: "*.b.test.", priority: nbdns.PriorityDNSRoute},
+ {pattern: "*.ab.test.", priority: nbdns.PriorityDNSRoute},
+ },
+ queryDomain: "app.ab.test.",
+ expectedCalls: 1,
+ expectedHandler: 1,
+ },
{
name: "root zone with specific domain",
handlers: []struct {
diff --git a/client/internal/dns/local/local.go b/client/internal/dns/local/local.go
index 4a75a76b6..d13aa672e 100644
--- a/client/internal/dns/local/local.go
+++ b/client/internal/dns/local/local.go
@@ -26,6 +26,19 @@ type resolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}
+// PeerConnectivity reports whether a tunnel IP belongs to a peer the
+// client knows about and whether that peer is currently connected. The
+// local resolver uses this to suppress A/AAAA answers whose RDATA points
+// at a disconnected peer (typical case: a synthesized private-service
+// record pointing at an embedded proxy peer that just went offline).
+//
+// known=false means the IP isn't in the local peerstore at all — the
+// record is left alone (it points at something outside our mesh, e.g.
+// a non-peer upstream).
+type PeerConnectivity interface {
+ IsConnectedByIP(ip string) (known, connected bool)
+}
+
type Resolver struct {
mu sync.RWMutex
records map[dns.Question][]dns.RR
@@ -33,6 +46,11 @@ type Resolver struct {
// zones maps zone domain -> NonAuthoritative (true = non-authoritative, user-created zone)
zones map[domain.Domain]bool
resolver resolver
+ // peerConn, when non-nil, is consulted on every A/AAAA answer to
+ // drop records pointing at disconnected peers. nil disables the
+ // filter and preserves the legacy "return whatever is registered"
+ // behaviour for callers that never wire a status source.
+ peerConn PeerConnectivity
ctx context.Context
cancel context.CancelFunc
@@ -49,6 +67,15 @@ func NewResolver() *Resolver {
}
}
+// SetPeerConnectivity wires the per-IP connectivity check used to filter
+// out A/AAAA answers pointing at disconnected peers. Pass nil to disable.
+// Safe to call multiple times; the latest value wins.
+func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ d.peerConn = p
+}
+
func (d *Resolver) MatchSubdomains() bool {
return true
}
@@ -95,6 +122,7 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
replyMessage.RecursionAvailable = true
result := d.lookupRecords(logger, question)
+ result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
replyMessage.Authoritative = !result.hasExternalData
replyMessage.Answer = result.records
replyMessage.Rcode = d.determineRcode(question, result)
@@ -436,6 +464,78 @@ func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16,
}
}
+// filterDisconnectedPeerAnswers drops A/AAAA records whose RDATA matches
+// a known but disconnected peer. The synthesized private-service zones
+// emit one A record per connected proxy peer in a cluster; when a peer
+// goes offline, the server-side refresh removes the record from the
+// next netmap, but the client may still hold the previous netmap for a
+// short window. This filter is the local belt to that braces — even on
+// the stale netmap, the resolver hides the offline target.
+//
+// Records pointing at unknown IPs (outside the local peerstore, e.g.
+// non-mesh upstreams) are never dropped. Non-A/AAAA records pass
+// through untouched.
+//
+// Escape hatch: if filtering would leave the answer empty AND at least
+// one record was filtered, the original list is returned. Better to
+// hand the client a record that may not respond than NXDOMAIN it
+// completely when every proxy peer is offline (the upstream may still
+// be reachable some other way, or the peerstore may be stale).
+func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns.Question, records []dns.RR) []dns.RR {
+ if len(records) == 0 {
+ return records
+ }
+ d.mu.RLock()
+ checker := d.peerConn
+ d.mu.RUnlock()
+ if checker == nil {
+ return records
+ }
+
+ kept := make([]dns.RR, 0, len(records))
+ var dropped int
+ for _, rr := range records {
+ ip := extractRecordIP(rr)
+ if ip == "" {
+ kept = append(kept, rr)
+ continue
+ }
+ known, connected := checker.IsConnectedByIP(ip)
+ if known && !connected {
+ dropped++
+ continue
+ }
+ kept = append(kept, rr)
+ }
+ if dropped == 0 {
+ return records
+ }
+ if len(kept) == 0 {
+ logger.Debugf("all %d answers for %s point at disconnected peers; returning the original list", dropped, question.Name)
+ return records
+ }
+ logger.Tracef("dropped %d disconnected-peer answer(s) for %s, returning %d", dropped, question.Name, len(kept))
+ return kept
+}
+
+// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
+// an A or AAAA record, or "" for any other record type.
+func extractRecordIP(rr dns.RR) string {
+ switch r := rr.(type) {
+ case *dns.A:
+ if r.A == nil {
+ return ""
+ }
+ return r.A.String()
+ case *dns.AAAA:
+ if r.AAAA == nil {
+ return ""
+ }
+ return r.AAAA.String()
+ }
+ return ""
+}
+
// Update replaces all zones and their records
func (d *Resolver) Update(customZones []nbdns.CustomZone) {
d.mu.Lock()
diff --git a/client/internal/dns/local/local_test.go b/client/internal/dns/local/local_test.go
index 2c6b7dbc3..fdf7f2659 100644
--- a/client/internal/dns/local/local_test.go
+++ b/client/internal/dns/local/local_test.go
@@ -30,6 +30,21 @@ func (m *mockResolver) LookupNetIP(ctx context.Context, network, host string) ([
return nil, nil
}
+// mockPeerConnectivity returns canned (known, connected) results per IP.
+// Used by the disconnected-peer filter tests below. IPs not in the map
+// are reported as unknown so the filter leaves them alone.
+type mockPeerConnectivity struct {
+ byIP map[string]struct{ known, connected bool }
+}
+
+func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
+ v, ok := m.byIP[ip]
+ if !ok {
+ return false, false
+ }
+ return v.known, v.connected
+}
+
func TestLocalResolver_ServeDNS(t *testing.T) {
recordA := nbdns.SimpleRecord{
Name: "peera.netbird.cloud.",
@@ -2652,3 +2667,114 @@ func BenchmarkIsInManagedZone_ManyZones(b *testing.B) {
resolver.isInManagedZone(qname)
}
}
+
+// TestLocalResolver_FilterDisconnectedPeerAnswers verifies the
+// connectivity-aware filtering layered on top of lookupRecords:
+// when an A record's IP belongs to a known peer that's disconnected,
+// the record is dropped from the answer. Records for unknown IPs pass
+// through. If filtering would empty the answer entirely and at least
+// one record was dropped, the original list is restored (escape hatch
+// for the "all proxies offline" case).
+func TestLocalResolver_FilterDisconnectedPeerAnswers(t *testing.T) {
+ zone := "svc.cluster.netbird."
+ connectedRec := nbdns.SimpleRecord{
+ Name: zone,
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 5,
+ RData: "100.64.0.10",
+ }
+ disconnectedRec := nbdns.SimpleRecord{
+ Name: zone,
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 5,
+ RData: "100.64.0.11",
+ }
+ unknownRec := nbdns.SimpleRecord{
+ Name: zone,
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 5,
+ RData: "203.0.113.5",
+ }
+
+ type ipState struct{ known, connected bool }
+ tests := []struct {
+ name string
+ records []nbdns.SimpleRecord
+ connByIP map[string]ipState
+ wantInOrder []string
+ }{
+ {
+ name: "drops disconnected peer, keeps connected",
+ records: []nbdns.SimpleRecord{connectedRec, disconnectedRec},
+ connByIP: map[string]ipState{
+ "100.64.0.10": {known: true, connected: true},
+ "100.64.0.11": {known: true, connected: false},
+ },
+ wantInOrder: []string{"100.64.0.10"},
+ },
+ {
+ name: "unknown IPs pass through untouched",
+ records: []nbdns.SimpleRecord{unknownRec, disconnectedRec},
+ connByIP: map[string]ipState{
+ "100.64.0.11": {known: true, connected: false},
+ },
+ wantInOrder: []string{"203.0.113.5"},
+ },
+ {
+ name: "all disconnected falls back to original list",
+ records: []nbdns.SimpleRecord{disconnectedRec, connectedRec},
+ connByIP: map[string]ipState{
+ "100.64.0.10": {known: true, connected: false},
+ "100.64.0.11": {known: true, connected: false},
+ },
+ wantInOrder: []string{"100.64.0.11", "100.64.0.10"},
+ },
+ {
+ name: "no checker wired returns all records",
+ records: []nbdns.SimpleRecord{connectedRec, disconnectedRec},
+ connByIP: nil,
+ wantInOrder: []string{"100.64.0.10", "100.64.0.11"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ resolver := NewResolver()
+ if tc.connByIP != nil {
+ cm := mockPeerConnectivity{byIP: make(map[string]struct{ known, connected bool }, len(tc.connByIP))}
+ for ip, st := range tc.connByIP {
+ cm.byIP[ip] = struct{ known, connected bool }{st.known, st.connected}
+ }
+ resolver.SetPeerConnectivity(cm)
+ }
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: strings.TrimSuffix(zone, "."),
+ Records: tc.records,
+ NonAuthoritative: true,
+ }})
+
+ var got *dns.Msg
+ writer := &test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ got = m
+ return nil
+ },
+ }
+ req := new(dns.Msg).SetQuestion(zone, dns.TypeA)
+ resolver.ServeDNS(writer, req)
+
+ require.NotNil(t, got, "resolver must produce a response")
+ require.Len(t, got.Answer, len(tc.wantInOrder),
+ "answer count must match expected: %v", tc.wantInOrder)
+ for i, want := range tc.wantInOrder {
+ a, ok := got.Answer[i].(*dns.A)
+ require.True(t, ok, "answer[%d] must be an A record", i)
+ assert.Equal(t, want, a.A.String(),
+ "answer[%d] expected %s got %s", i, want, a.A.String())
+ }
+ })
+ }
+}
diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go
index e689f3586..dcd4cb9d0 100644
--- a/client/internal/dns/server.go
+++ b/client/internal/dns/server.go
@@ -301,6 +301,11 @@ func newDefaultServer(
warningDelayBase: defaultWarningDelayBase,
healthRefresh: make(chan struct{}, 1),
}
+ // Wire the local resolver against the peer status recorder so it can
+ // suppress A/AAAA answers that point at disconnected peers (typical
+ // case: synthesised private-service records pointing at an embedded
+ // proxy peer that just went offline).
+ defaultServer.localResolver.SetPeerConnectivity(localPeerConnectivity{statusRecorder})
// register with root zone, handler chain takes care of the routing
dnsService.RegisterMux(".", handlerChain)
@@ -772,13 +777,24 @@ func (s *DefaultServer) applyHostConfig() {
// context is released rather than leaked until GC.
func (s *DefaultServer) registerFallback() {
originalNameservers := s.hostManager.getOriginalNameservers()
- if len(originalNameservers) == 0 {
+
+ serverIP := s.service.RuntimeIP()
+ var servers []netip.AddrPort
+ for _, ns := range originalNameservers {
+ if ns == serverIP {
+ log.Debugf("skipping original nameserver %s as it is the same as the server IP %s", ns, serverIP)
+ continue
+ }
+ servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
+ }
+
+ if len(servers) == 0 {
log.Debugf("no fallback upstreams to register; clearing PriorityFallback handler")
s.clearFallback()
return
}
- log.Infof("registering original nameservers %v as upstream handlers with priority %d", originalNameservers, PriorityFallback)
+ log.Infof("registering original nameservers %v as upstream handlers with priority %d", servers, PriorityFallback)
handler, err := newUpstreamResolver(
s.ctx,
@@ -792,11 +808,6 @@ func (s *DefaultServer) registerFallback() {
return
}
handler.selectedRoutes = s.selectedRoutes
-
- var servers []netip.AddrPort
- for _, ns := range originalNameservers {
- servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
- }
handler.addRace(servers)
prev := s.fallbackHandler
@@ -1386,3 +1397,25 @@ func (s *DefaultServer) PopulateManagementDomain(mgmtURL *url.URL) error {
}
return nil
}
+
+// localPeerConnectivity adapts *peer.Status to local.PeerConnectivity so
+// the local resolver can ask "is this IP a known peer and is it
+// connected?" without taking on the peer package as a dependency.
+// A nil status recorder always reports known=false so the resolver
+// short-circuits to the legacy "return everything" path.
+type localPeerConnectivity struct {
+ status *peer.Status
+}
+
+// IsConnectedByIP looks the IP up in the peerstore and surfaces both
+// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
+func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
+ if l.status == nil {
+ return false, false
+ }
+ state, ok := l.status.PeerStateByIP(ip)
+ if !ok {
+ return false, false
+ }
+ return true, state.ConnStatus == peer.StatusConnected
+}
diff --git a/client/internal/engine.go b/client/internal/engine.go
index 3b575a889..04d45ea98 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -22,7 +22,6 @@ import (
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
- "google.golang.org/protobuf/proto"
nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/client/firewall"
@@ -57,6 +56,7 @@ import (
"github.com/netbirdio/netbird/client/internal/routemanager"
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
"github.com/netbirdio/netbird/client/internal/statemanager"
+ "github.com/netbirdio/netbird/client/internal/syncstore"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/jobexec"
cProto "github.com/netbirdio/netbird/client/proto"
@@ -73,6 +73,7 @@ import (
sProto "github.com/netbirdio/netbird/shared/signal/proto"
"github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/util/capture"
+ "github.com/netbirdio/netbird/version"
)
// PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer.
@@ -151,6 +152,10 @@ type EngineConfig struct {
LogPath string
TempDir string
+
+ // StateDir is the directory holding the state file. The sync response
+ // (network map) is serialized here on platforms that persist it to disk.
+ StateDir string
}
// EngineServices holds the external service dependencies required by the Engine.
@@ -231,10 +236,15 @@ type Engine struct {
afpacketCapture *capture.AFPacketCapture
- // Sync response persistence (protected by syncRespMux)
- syncRespMux sync.RWMutex
- persistSyncResponse bool
- latestSyncResponse *mgmProto.SyncResponse
+ // Sync response persistence (protected by syncRespMux).
+ // syncStore is nil unless persistence has been enabled; its presence is
+ // what marks persistence as active. The backend (disk or memory) is
+ // selected per-platform; see the syncstore package. syncStoreDir is where
+ // a disk-backed store serializes to.
+ syncRespMux sync.RWMutex
+ syncStore syncstore.Store
+ syncStoreDir string
+
flowManager nftypes.FlowManager
// auto-update
@@ -298,6 +308,7 @@ func NewEngine(
jobExecutor: jobexec.NewExecutor(),
clientMetrics: services.ClientMetrics,
updateManager: services.UpdateManager,
+ syncStoreDir: config.StateDir,
}
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
@@ -923,19 +934,18 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
}
// Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
- // Read the storage-enabled flag under the syncRespMux too.
+ // A non-nil syncStore is what marks persistence as enabled. Hold the lock for
+ // the whole Set so the store cannot be cleared (disabled / engine close)
+ // mid-call and have this write resurrect a file that was just removed.
e.syncRespMux.RLock()
- enabled := e.persistSyncResponse
- e.syncRespMux.RUnlock()
-
- // Store sync response if persistence is enabled
- if enabled {
- e.syncRespMux.Lock()
- e.latestSyncResponse = update
- e.syncRespMux.Unlock()
-
- log.Debugf("sync response persisted with serial %d", nm.GetSerial())
+ if e.syncStore != nil {
+ if err := e.syncStore.Set(update); err != nil {
+ log.Errorf("failed to persist sync response: %v", err)
+ } else {
+ log.Debugf("sync response persisted with serial %d", nm.GetSerial())
+ }
}
+ e.syncRespMux.RUnlock()
// only apply new changes and ignore old ones
if err := e.updateNetworkMap(nm); err != nil {
@@ -1078,6 +1088,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
state.PubKey = e.config.WgPrivateKey.PublicKey().String()
state.KernelInterface = !e.wgInterface.IsUserspaceBind()
state.FQDN = conf.GetFqdn()
+ state.WgPort = e.config.WgPort
e.statusRecorder.UpdateLocalPeerState(state)
@@ -1156,6 +1167,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
LogPath: e.config.LogPath,
TempDir: e.config.TempDir,
ClientMetrics: e.clientMetrics,
+ DaemonVersion: version.NetbirdVersion(),
RefreshStatus: func() {
e.RunHealthProbes(true)
},
@@ -1834,6 +1846,18 @@ func (e *Engine) close() {
if err := e.portForwardManager.GracefullyStop(ctx); err != nil {
log.Warnf("failed to gracefully stop port forwarding manager: %s", err)
}
+
+ // Drop any persisted sync response so its network map does not linger on
+ // disk after the engine stops (and cannot leak into a later run).
+ e.syncRespMux.Lock()
+ store := e.syncStore
+ e.syncStore = nil
+ e.syncRespMux.Unlock()
+ if store != nil {
+ if err := store.Clear(); err != nil {
+ log.Warnf("failed to clear persisted sync response on close: %v", err)
+ }
+ }
}
func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
@@ -1989,6 +2013,29 @@ func (e *Engine) GetClientMetrics() *metrics.ClientMetrics {
return e.clientMetrics
}
+// Performance bundles runtime-adjustable tunnel pool knobs.
+// See Engine.SetPerformance. Nil fields are ignored.
+type Performance struct {
+ PreallocatedBuffersPerPool *uint32
+}
+
+// SetPerformance applies the given tuning to this engine's live Device.
+func (e *Engine) SetPerformance(t Performance) error {
+ e.syncMsgMux.Lock()
+ defer e.syncMsgMux.Unlock()
+ if e.wgInterface == nil {
+ return fmt.Errorf("wg interface not initialized")
+ }
+ dev := e.wgInterface.GetWGDevice()
+ if dev == nil {
+ return fmt.Errorf("wg device not initialized")
+ }
+ if t.PreallocatedBuffersPerPool != nil {
+ dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool)
+ }
+ return nil
+}
+
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
iface, err := net.InterfaceByName(ifaceName)
if err != nil {
@@ -2141,45 +2188,42 @@ func (e *Engine) stopDNSServer() {
e.statusRecorder.UpdateDNSStates(nsGroupStates)
}
-// SetSyncResponsePersistence enables or disables sync response persistence
+// SetSyncResponsePersistence enables or disables sync response persistence.
+// The store is only instantiated while persistence is enabled; construction
+// itself drops any stale data left over from an earlier run (see syncstore).
func (e *Engine) SetSyncResponsePersistence(enabled bool) {
e.syncRespMux.Lock()
defer e.syncRespMux.Unlock()
- if enabled == e.persistSyncResponse {
+ if enabled == (e.syncStore != nil) {
return
}
- e.persistSyncResponse = enabled
log.Debugf("Sync response persistence is set to %t", enabled)
if !enabled {
- e.latestSyncResponse = nil
+ if err := e.syncStore.Clear(); err != nil {
+ log.Warnf("failed to clear persisted sync response: %v", err)
+ }
+ e.syncStore = nil
+ return
}
+
+ e.syncStore = syncstore.New(e.syncStoreDir)
}
// GetLatestSyncResponse returns the stored sync response if persistence is enabled
func (e *Engine) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) {
+ // Hold the lock for the whole Get so the store cannot be cleared
+ // (disabled / engine close) mid-call.
e.syncRespMux.RLock()
- enabled := e.persistSyncResponse
- latest := e.latestSyncResponse
- e.syncRespMux.RUnlock()
+ defer e.syncRespMux.RUnlock()
- if !enabled {
+ if e.syncStore == nil {
return nil, errors.New("sync response persistence is disabled")
}
- if latest == nil {
- //nolint:nilnil
- return nil, nil
- }
-
- log.Debugf("Retrieving latest sync response with size %d bytes", proto.Size(latest))
- sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
- if !ok {
- return nil, fmt.Errorf("failed to clone sync response")
- }
-
- return sr, nil
+ //nolint:nilnil
+ return e.syncStore.Get()
}
// GetWgAddr returns the wireguard address
@@ -2215,7 +2259,7 @@ func (e *Engine) updateDNSForwarder(
enabled bool,
fwdEntries []*dnsfwd.ForwarderEntry,
) {
- if e.config.DisableServerRoutes {
+ if e.config.DisableServerRoutes || e.config.BlockInbound {
return
}
diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go
index 834a49a09..289f1906f 100644
--- a/client/internal/engine_test.go
+++ b/client/internal/engine_test.go
@@ -27,7 +27,7 @@ import (
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/management/server/job"
- "github.com/netbirdio/management-integrations/integrations"
+ "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"
@@ -66,8 +66,8 @@ import (
"github.com/netbirdio/netbird/route"
mgmt "github.com/netbirdio/netbird/shared/management/client"
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
- relayClient "github.com/netbirdio/netbird/shared/relay/client"
"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"
@@ -1641,7 +1641,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
return nil, "", err
}
- ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
+ ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
require.NoError(t, err)
diff --git a/client/internal/lazyconn/support.go b/client/internal/lazyconn/support.go
index 5e765c2d6..cc0e95e53 100644
--- a/client/internal/lazyconn/support.go
+++ b/client/internal/lazyconn/support.go
@@ -4,6 +4,8 @@ import (
"strings"
"github.com/hashicorp/go-version"
+
+ nbversion "github.com/netbirdio/netbird/version"
)
var (
@@ -11,7 +13,7 @@ var (
)
func IsSupported(agentVersion string) bool {
- if agentVersion == "development" {
+ if nbversion.IsDevelopmentVersion(agentVersion) {
return true
}
diff --git a/client/internal/networkmonitor/check_change_common.go b/client/internal/networkmonitor/check_change_common.go
index a4a4f76ac..f693081a6 100644
--- a/client/internal/networkmonitor/check_change_common.go
+++ b/client/internal/networkmonitor/check_change_common.go
@@ -50,7 +50,7 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
switch msg.Type {
// handle route changes
case unix.RTM_ADD, syscall.RTM_DELETE:
- route, err := parseRouteMessage(buf[:n])
+ route, flags, err := parseRouteMessage(buf[:n])
if err != nil {
log.Debugf("Network monitor: error parsing routing message: %v", err)
continue
@@ -66,6 +66,10 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
}
switch msg.Type {
case unix.RTM_ADD:
+ if systemops.IgnoreAddedDefaultRoute(flags) {
+ log.Debugf("Network monitor: ignoring added default route via %s, interface %s, flags %#x", route.Gw, intf, flags)
+ continue
+ }
log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf)
return nil
case unix.RTM_DELETE:
@@ -78,22 +82,26 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
}
}
-func parseRouteMessage(buf []byte) (*systemops.Route, error) {
+func parseRouteMessage(buf []byte) (*systemops.Route, int, error) {
msgs, err := route.ParseRIB(route.RIBTypeRoute, buf)
if err != nil {
- return nil, fmt.Errorf("parse RIB: %v", err)
+ return nil, 0, fmt.Errorf("parse RIB: %v", err)
}
if len(msgs) != 1 {
- return nil, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
+ return nil, 0, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
}
msg, ok := msgs[0].(*route.RouteMessage)
if !ok {
- return nil, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
+ return nil, 0, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
}
- return systemops.MsgToRoute(msg)
+ r, err := systemops.MsgToRoute(msg)
+ if err != nil {
+ return nil, 0, err
+ }
+ return r, msg.Flags, nil
}
// waitReadable blocks until fd has data to read, or ctx is cancelled.
diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go
index 1e416bfe7..79a513956 100644
--- a/client/internal/peer/conn.go
+++ b/client/internal/peer/conn.go
@@ -23,6 +23,7 @@ import (
"github.com/netbirdio/netbird/client/internal/peer/id"
"github.com/netbirdio/netbird/client/internal/peer/worker"
"github.com/netbirdio/netbird/client/internal/portforward"
+ "github.com/netbirdio/netbird/client/internal/rosenpass"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/route"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
@@ -899,7 +900,7 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
}
// Fallback to deterministic key if no NetBird PSK is configured
- determKey, err := conn.rosenpassDetermKey()
+ determKey, err := rosenpass.DeterministicSeedKey(conn.config.LocalKey, conn.config.Key)
if err != nil {
conn.Log.Errorf("failed to generate Rosenpass initial key: %v", err)
return nil
@@ -908,26 +909,6 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
return determKey
}
-// todo: move this logic into Rosenpass package
-func (conn *Conn) rosenpassDetermKey() (*wgtypes.Key, error) {
- lk := []byte(conn.config.LocalKey)
- rk := []byte(conn.config.Key) // remote key
- var keyInput []byte
- if string(lk) > string(rk) {
- //nolint:gocritic
- keyInput = append(lk[:16], rk[:16]...)
- } else {
- //nolint:gocritic
- keyInput = append(rk[:16], lk[:16]...)
- }
-
- key, err := wgtypes.NewKey(keyInput)
- if err != nil {
- return nil, err
- }
- return &key, nil
-}
-
func isController(config ConnConfig) bool {
return config.LocalKey > config.Key
}
diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go
index c74a3ed8c..81469578b 100644
--- a/client/internal/peer/status.go
+++ b/client/internal/peer/status.go
@@ -111,6 +111,7 @@ type LocalPeerState struct {
PubKey string
KernelInterface bool
FQDN string
+ WgPort int
Routes map[string]struct{}
}
@@ -185,9 +186,12 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
return s.eventsChan
}
-// Status holds a state of peers, signal, management connections and relays
+// Status holds a state of peers, signal, management connections and relays.
+// mux is an RWMutex so hot read paths (notably PeerStateByIP, called for
+// every private-service request) don't contend against each other.
+// Pure read methods take RLock; anything that mutates state takes Lock.
type Status struct {
- mux sync.Mutex
+ mux sync.RWMutex
peers map[string]State
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
signalState bool
@@ -283,8 +287,8 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string)
// GetPeer adds peer to Daemon status map
func (d *Status) GetPeer(peerPubKey string) (State, error) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
state, ok := d.peers[peerPubKey]
if !ok {
@@ -294,8 +298,8 @@ func (d *Status) GetPeer(peerPubKey string) (State, error) {
}
func (d *Status) PeerByIP(ip string) (string, bool) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
for _, state := range d.peers {
if state.IP == ip {
@@ -305,6 +309,34 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
return "", false
}
+// PeerStateByIP returns the full peer State for the given tunnel IP.
+// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
+// address so dual-stack peers are reachable on either family. Searches
+// both d.peers and d.offlinePeers — peers that have been moved into
+// the offline slice by ReplaceOfflinePeers are still part of the
+// account's roster and callers (DNS filter, embed.Client.IdentityForIP)
+// need to recognise them rather than treating them as unknown. Returns
+// the zero State and false when no peer matches or the input is empty.
+func (d *Status) PeerStateByIP(ip string) (State, bool) {
+ if ip == "" {
+ return State{}, false
+ }
+ d.mux.RLock()
+ defer d.mux.RUnlock()
+
+ for _, state := range d.peers {
+ if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
+ return state, true
+ }
+ }
+ for _, state := range d.offlinePeers {
+ if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
+ return state, true
+ }
+ }
+ return State{}, false
+}
+
// RemovePeer removes peer from Daemon status map
func (d *Status) RemovePeer(peerPubKey string) error {
d.mux.Lock()
@@ -702,8 +734,8 @@ func (d *Status) UnsubscribePeerStateChanges(subscription *StatusChangeSubscript
// GetLocalPeerState returns the local peer state
func (d *Status) GetLocalPeerState() LocalPeerState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return d.localPeer.Clone()
}
@@ -909,8 +941,8 @@ func (d *Status) DeleteResolvedDomainsStates(domain domain.Domain) {
}
func (d *Status) GetRosenpassState() RosenpassState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return RosenpassState{
d.rosenpassEnabled,
d.rosenpassPermissive,
@@ -918,14 +950,14 @@ func (d *Status) GetRosenpassState() RosenpassState {
}
func (d *Status) GetLazyConnection() bool {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return d.lazyConnectionEnabled
}
func (d *Status) GetManagementState() ManagementState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return ManagementState{
d.mgmAddress,
d.managementState,
@@ -951,8 +983,8 @@ func (d *Status) UpdateLatency(pubKey string, latency time.Duration) error {
// IsLoginRequired determines if a peer's login has expired.
func (d *Status) IsLoginRequired() bool {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
// if peer is connected to the management then login is not expired
if d.managementState {
@@ -967,8 +999,8 @@ func (d *Status) IsLoginRequired() bool {
}
func (d *Status) GetSignalState() SignalState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return SignalState{
d.signalAddress,
d.signalState,
@@ -978,8 +1010,8 @@ func (d *Status) GetSignalState() SignalState {
// GetRelayStates returns the stun/turn/permanent relay states
func (d *Status) GetRelayStates() []relay.ProbeResult {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
if d.relayMgr == nil {
return d.relayStates
}
@@ -1008,8 +1040,8 @@ func (d *Status) GetRelayStates() []relay.ProbeResult {
}
func (d *Status) ForwardingRules() []firewall.ForwardRule {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
if d.ingressGwMgr == nil {
return nil
}
@@ -1018,16 +1050,16 @@ func (d *Status) ForwardingRules() []firewall.ForwardRule {
}
func (d *Status) GetDNSStates() []NSGroupState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
// shallow copy is good enough, as slices fields are currently not updated
return slices.Clone(d.nsGroupStates)
}
func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return maps.Clone(d.resolvedDomainsStates)
}
@@ -1043,8 +1075,8 @@ func (d *Status) GetFullStatus() FullStatus {
LazyConnectionEnabled: d.GetLazyConnection(),
}
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
fullStatus.LocalPeerState = d.localPeer
@@ -1228,8 +1260,8 @@ func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
}
func (d *Status) PeersStatus() (*configurer.Stats, error) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
if d.wgIface == nil {
return nil, fmt.Errorf("wgInterface is nil, cannot retrieve peers status")
}
@@ -1335,6 +1367,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
+ pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort)
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)
diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go
index 9bafca55a..97fb32c03 100644
--- a/client/internal/peer/status_test.go
+++ b/client/internal/peer/status_test.go
@@ -63,6 +63,55 @@ func TestUpdatePeerState(t *testing.T) {
assert.Equal(t, ip, state.IP, "ip should be equal")
}
+func TestStatus_PeerStateByIP(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ req := require.New(t)
+
+ req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", ""))
+ req.NoError(status.AddPeer("pk-2", "peer-2.netbird", "100.64.0.11", ""))
+
+ state, ok := status.PeerStateByIP("100.64.0.10")
+ req.True(ok, "known tunnel IP should resolve to a peer state")
+ req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
+ req.Equal("peer-1.netbird", state.FQDN, "matching state must carry the right FQDN")
+
+ _, ok = status.PeerStateByIP("100.64.0.99")
+ req.False(ok, "unknown IP must report ok=false")
+}
+
+func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ req := require.New(t)
+
+ req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1"))
+
+ state, ok := status.PeerStateByIP("fd00::1")
+ req.True(ok, "IPv6-only match must resolve to the peer state")
+ req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
+}
+
+// TestStatus_PeerStateByIP_MatchesOfflinePeers covers peers that have
+// been moved into the offline slice via ReplaceOfflinePeers. Callers
+// (DNS filter, embed.Client.IdentityForIP) need to treat them as known
+// rather than unknown — otherwise authentication / DNS filtering treats
+// known-but-offline peers as foreign IPs.
+func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ req := require.New(t)
+
+ status.ReplaceOfflinePeers([]State{
+ {PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"},
+ })
+
+ state, ok := status.PeerStateByIP("100.64.0.20")
+ req.True(ok, "offline peer must resolve by IPv4 tunnel address")
+ req.Equal("pk-offline", state.PubKey, "matching state must carry the offline peer's pub key")
+
+ state, ok = status.PeerStateByIP("fd00::20")
+ req.True(ok, "offline peer must resolve by IPv6 tunnel address")
+ req.Equal("pk-offline", state.PubKey, "IPv6 match must carry the offline peer's pub key")
+}
+
func TestStatus_UpdatePeerFQDN(t *testing.T) {
key := "abc"
fqdn := "peer-a.netbird.local"
diff --git a/client/internal/portforward/pcp/nat.go b/client/internal/portforward/pcp/nat.go
index 6491e7367..0e635b6c8 100644
--- a/client/internal/portforward/pcp/nat.go
+++ b/client/internal/portforward/pcp/nat.go
@@ -179,8 +179,10 @@ func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) {
}
dst := net.IPv4zero
- if runtime.GOOS == "linux" {
- // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux.
+ if runtime.GOOS == "linux" || runtime.GOOS == "android" {
+ // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android.
+ // TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties /
+ // NWPathMonitor) when netlink-based lookup is restricted or unavailable.
dst = net.IPv4(0, 0, 0, 1)
}
_, gateway, localIP, err = router.Route(dst)
@@ -203,7 +205,7 @@ func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) {
}
dst := net.IPv6zero
- if runtime.GOOS == "linux" {
+ if runtime.GOOS == "linux" || runtime.GOOS == "android" {
// ::2
dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}
}
diff --git a/client/internal/rosenpass/manager.go b/client/internal/rosenpass/manager.go
index 11cda8dbc..903753753 100644
--- a/client/internal/rosenpass/manager.go
+++ b/client/internal/rosenpass/manager.go
@@ -28,6 +28,15 @@ func hashRosenpassKey(key []byte) string {
return hex.EncodeToString(hasher.Sum(nil))
}
+// rpServer is the subset of rp.Server used by Manager. Defined as an interface
+// so tests can substitute a mock without spinning up a real UDP server.
+type rpServer interface {
+ AddPeer(rp.PeerConfig) (rp.PeerID, error)
+ RemovePeer(rp.PeerID) error
+ Run() error
+ Close() error
+}
+
type Manager struct {
ifaceName string
spk []byte
@@ -36,7 +45,7 @@ type Manager struct {
preSharedKey *[32]byte
rpPeerIDs map[string]*rp.PeerID
rpWgHandler *NetbirdHandler
- server *rp.Server
+ server rpServer
lock sync.Mutex
port int
wgIface PresharedKeySetter
@@ -51,7 +60,22 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
rpKeyHash := hashRosenpassKey(public)
log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash)
- return &Manager{ifaceName: wgIfaceName, rpKeyHash: rpKeyHash, spk: public, ssk: secret, preSharedKey: (*[32]byte)(preSharedKey), rpPeerIDs: make(map[string]*rp.PeerID), lock: sync.Mutex{}}, nil
+ return &Manager{
+ ifaceName: wgIfaceName,
+ rpKeyHash: rpKeyHash,
+ spk: public,
+ ssk: secret,
+ preSharedKey: (*[32]byte)(preSharedKey),
+ rpPeerIDs: make(map[string]*rp.PeerID),
+ // rpWgHandler is created here (instead of only in generateConfig) so it
+ // is never nil between NewManager and Run(). Otherwise an early
+ // OnConnected call (race observed on Android, issue #4341) panics on
+ // nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will
+ // replace it with a fresh handler on each Run() to clear stale peer
+ // state from previous engine sessions.
+ rpWgHandler: NewNetbirdHandler(),
+ lock: sync.Mutex{},
+ }, nil
}
func (m *Manager) GetPubKey() []byte {
@@ -65,6 +89,16 @@ func (m *Manager) GetAddress() *net.UDPAddr {
// addPeer adds a new peer to the Rosenpass server
func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuardIP string, wireGuardPubKey string) error {
+ // Defense in depth against issue #4341 (Android crash): if Run() has not
+ // completed yet, m.server / m.rpWgHandler may be nil. Return an explicit
+ // error instead of panicking on nil-receiver dereference.
+ if m.server == nil {
+ return fmt.Errorf("rosenpass server not initialized")
+ }
+ if m.rpWgHandler == nil {
+ return fmt.Errorf("rosenpass wg handler not initialized")
+ }
+
var err error
pcfg := rp.PeerConfig{PublicKey: rosenpassPubKey}
if m.preSharedKey != nil {
@@ -79,6 +113,16 @@ func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuar
if pcfg.Endpoint, err = net.ResolveUDPAddr("udp", peerAddr); err != nil {
return fmt.Errorf("failed to resolve peer endpoint address: %w", err)
}
+ // Our local Rosenpass UDP server binds on the IPv6 wildcard ([::]) — see
+ // GetAddress(). The remote peer's endpoint (pcfg.Endpoint) is the destination
+ // our server will sendto when initiating handshakes. ResolveUDPAddr returns a
+ // 4-byte IPv4 for IPv4 hosts, which the kernel rejects (EDESTADDRREQ) when
+ // sent from an AF_INET6 socket. Normalize the remote endpoint to IPv4-mapped
+ // IPv6 so its address family matches our listening socket.
+ // TODO: maybe bind the Rosenpass UDP server to the peer wg IP addr
+ if v4 := pcfg.Endpoint.IP.To4(); v4 != nil {
+ pcfg.Endpoint.IP = v4.To16()
+ }
}
peerID, err := m.server.AddPeer(pcfg)
if err != nil {
@@ -182,24 +226,31 @@ func (m *Manager) Run() error {
return err
}
- m.server, err = rp.NewUDPServer(conf)
+ server, err := rp.NewUDPServer(conf)
if err != nil {
return err
}
+ m.lock.Lock()
+ m.server = server
+ m.lock.Unlock()
+
log.Infof("starting rosenpass server on port %d", m.port)
- return m.server.Run()
+ return server.Run()
}
// Close closes the Rosenpass server
func (m *Manager) Close() error {
- if m.server != nil {
- err := m.server.Close()
- if err != nil {
- log.Errorf("failed closing local rosenpass server")
- }
- m.server = nil
+ m.lock.Lock()
+ server := m.server
+ m.server = nil
+ m.lock.Unlock()
+ if server == nil {
+ return nil
+ }
+ if err := server.Close(); err != nil {
+ log.Errorf("failed closing local rosenpass server: %v", err)
}
return nil
}
diff --git a/client/internal/rosenpass/manager_test.go b/client/internal/rosenpass/manager_test.go
index 90bbdda59..ace6f88da 100644
--- a/client/internal/rosenpass/manager_test.go
+++ b/client/internal/rosenpass/manager_test.go
@@ -1,14 +1,412 @@
package rosenpass
import (
+ "errors"
+ "os"
+ "sync"
"testing"
+ rp "cunicu.li/go-rosenpass"
"github.com/stretchr/testify/require"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
+// --- test doubles -----------------------------------------------------------
+
+type addPeerCall struct {
+ cfg rp.PeerConfig
+}
+
+type removePeerCall struct {
+ id rp.PeerID
+}
+
+type mockServer struct {
+ mu sync.Mutex
+ addCalls []addPeerCall
+ removed []removePeerCall
+ nextID rp.PeerID
+ addErr error
+ removeErr error
+ closed bool
+ ran bool
+}
+
+func (m *mockServer) AddPeer(cfg rp.PeerConfig) (rp.PeerID, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.addCalls = append(m.addCalls, addPeerCall{cfg: cfg})
+ if m.addErr != nil {
+ return rp.PeerID{}, m.addErr
+ }
+ // Increment a byte in nextID so distinct peers get distinct IDs.
+ m.nextID[0]++
+ return m.nextID, nil
+}
+
+func (m *mockServer) RemovePeer(id rp.PeerID) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.removed = append(m.removed, removePeerCall{id: id})
+ return m.removeErr
+}
+
+func (m *mockServer) Run() error { m.ran = true; return nil }
+func (m *mockServer) Close() error { m.closed = true; return nil }
+
+type setPSKCall struct {
+ peerKey string
+ psk wgtypes.Key
+ updateOnly bool
+}
+
+type mockIface struct {
+ mu sync.Mutex
+ calls []setPSKCall
+ err error
+}
+
+func (m *mockIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.calls = append(m.calls, setPSKCall{peerKey: peerKey, psk: psk, updateOnly: updateOnly})
+ return m.err
+}
+
+// newTestManager builds a Manager with deterministic spk so tie-break
+// against a peer pubkey is controllable from tests. The provided spk byte
+// becomes the first byte; remaining bytes are zero.
+func newTestManager(spkFirstByte byte, mock *mockServer) *Manager {
+ spk := make([]byte, 32)
+ spk[0] = spkFirstByte
+ return &Manager{
+ ifaceName: "wt0",
+ spk: spk,
+ ssk: make([]byte, 32),
+ rpKeyHash: "test-hash",
+ rpPeerIDs: make(map[string]*rp.PeerID),
+ rpWgHandler: NewNetbirdHandler(),
+ server: mock,
+ }
+}
+
+// validWGKey returns a deterministic 32-byte wireguard public key (base64).
+func validWGKey(t *testing.T, lastByte byte) string {
+ t.Helper()
+ var k wgtypes.Key
+ k[31] = lastByte
+ return k.String()
+}
+
+// --- pure helpers ----------------------------------------------------------
+
+func TestHashRosenpassKey_Deterministic(t *testing.T) {
+ key := []byte("hello-rosenpass")
+ require.Equal(t, hashRosenpassKey(key), hashRosenpassKey(key))
+ require.Len(t, hashRosenpassKey(key), 64) // sha256 hex
+}
+
+func TestHashRosenpassKey_DifferentInputsDifferOutputs(t *testing.T) {
+ require.NotEqual(t, hashRosenpassKey([]byte("a")), hashRosenpassKey([]byte("b")))
+}
+
+func TestGetLogLevel_DefaultWhenUnset(t *testing.T) {
+ // Snapshot + unset to exercise the LookupEnv ok=false branch. t.Setenv
+ // can only set, not delete, so do it manually with restore via t.Cleanup.
+ prev, hadPrev := os.LookupEnv(defaultLogLevelVar)
+ require.NoError(t, os.Unsetenv(defaultLogLevelVar))
+ t.Cleanup(func() {
+ if hadPrev {
+ _ = os.Setenv(defaultLogLevelVar, prev)
+ } else {
+ _ = os.Unsetenv(defaultLogLevelVar)
+ }
+ })
+ require.Equal(t, defaultLog.String(), getLogLevel().String())
+}
+
+func TestGetLogLevel_Cases(t *testing.T) {
+ cases := map[string]string{
+ "debug": "DEBUG",
+ "info": "INFO",
+ "warn": "WARN",
+ "error": "ERROR",
+ "unknown": "INFO", // default fallback
+ }
+ for input, wantStr := range cases {
+ input, wantStr := input, wantStr
+ t.Run(input, func(t *testing.T) {
+ t.Setenv(defaultLogLevelVar, input)
+ require.Equal(t, wantStr, getLogLevel().String())
+ })
+ }
+}
+
func TestFindRandomAvailableUDPPort(t *testing.T) {
port, err := findRandomAvailableUDPPort()
require.NoError(t, err)
require.Greater(t, port, 0)
require.LessOrEqual(t, port, 65535)
}
+
+// --- addPeer ---------------------------------------------------------------
+
+func TestAddPeer_HigherLocalPubkey_SetsEndpoint(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv) // local spk lexicographically larger
+
+ remotePubKey := make([]byte, 32) // remote spk = all zeros (smaller)
+ err := m.addPeer(remotePubKey, "rosenpass-host:7000", "100.1.1.1", validWGKey(t, 1))
+ require.NoError(t, err)
+ require.Len(t, srv.addCalls, 1)
+
+ ep := srv.addCalls[0].cfg.Endpoint
+ require.NotNil(t, ep, "initiator side must set Endpoint")
+ require.Equal(t, 7000, ep.Port)
+ require.Equal(t, "100.1.1.1", ep.IP.String())
+}
+
+func TestAddPeer_HigherLocalPubkey_EndpointIPIsIPv4Mapped(t *testing.T) {
+ // Regression guard for the EDESTADDRREQ fix: Endpoint.IP must be 16-byte
+ // (IPv4-mapped IPv6) so it matches the AF_INET6 listening socket family.
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
+ require.NoError(t, err)
+
+ ep := srv.addCalls[0].cfg.Endpoint
+ require.NotNil(t, ep)
+ require.Len(t, ep.IP, 16, "IPv4 endpoint must be normalized to 16-byte v4-mapped form")
+ require.True(t, ep.IP.To4() != nil, "Endpoint must still be detected as IPv4")
+}
+
+func TestAddPeer_LowerLocalPubkey_LeavesEndpointNil(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0x00, srv) // local spk smaller
+
+ remotePubKey := make([]byte, 32)
+ remotePubKey[0] = 0xFF
+ err := m.addPeer(remotePubKey, "rp:5000", "100.1.1.1", validWGKey(t, 2))
+ require.NoError(t, err)
+
+ require.Nil(t, srv.addCalls[0].cfg.Endpoint, "responder side must NOT set Endpoint")
+}
+
+func TestAddPeer_PresharedKeyPropagated(t *testing.T) {
+ srv := &mockServer{}
+ psk := &wgtypes.Key{0x42}
+ m := newTestManager(0xFF, srv)
+ m.preSharedKey = (*[32]byte)(psk)
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 3))
+ require.NoError(t, err)
+ require.Equal(t, [32]byte(*psk), [32]byte(srv.addCalls[0].cfg.PresharedKey))
+}
+
+func TestAddPeer_InvalidRosenpassAddr_ReturnsError(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv) // initiator path → parses rosenpassAddr
+
+ err := m.addPeer(make([]byte, 32), "not-a-host-port", "100.1.1.1", validWGKey(t, 1))
+ require.Error(t, err)
+ require.Empty(t, srv.addCalls, "server.AddPeer must not run when address parse fails")
+}
+
+func TestAddPeer_InvalidWireGuardPubKey_ReturnsError(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", "not-a-valid-key")
+ require.Error(t, err)
+}
+
+func TestAddPeer_ServerError_Propagates(t *testing.T) {
+ srv := &mockServer{addErr: errors.New("boom")}
+ m := newTestManager(0xFF, srv)
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
+ require.Error(t, err)
+}
+
+// Regression guard for issue #4341 (Android crash). If Run() has not completed
+// before OnConnected fires, m.rpWgHandler or m.server may be nil. Without the
+// nil guards, m.rpWgHandler.AddPeer panics on nil receiver.
+func TestAddPeer_NilHandler_ReturnsErrorNoCrash(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+ m.rpWgHandler = nil // simulate Run() not yet completed
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "wg handler not initialized")
+}
+
+func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) {
+ m := newTestManager(0xFF, nil)
+ m.server = nil // simulate Run() not yet completed
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "server not initialized")
+}
+
+// NewManager must pre-initialize rpWgHandler so the nil-receiver crash from
+// issue #4341 cannot occur in the window between NewManager and Run().
+func TestNewManager_PreInitializesHandler(t *testing.T) {
+ psk := wgtypes.Key{}
+ m, err := NewManager(&psk, "wt0")
+ require.NoError(t, err)
+ require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager")
+}
+
+func TestAddPeer_RecordsPeerID(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ wgKey := validWGKey(t, 5)
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey)
+ require.NoError(t, err)
+ require.Contains(t, m.rpPeerIDs, wgKey)
+}
+
+// --- OnConnected / OnDisconnected ------------------------------------------
+
+func TestOnConnected_NilRemotePubKey_NoAddPeer(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ m.OnConnected(validWGKey(t, 1), nil, "100.1.1.1", "rp:5000")
+ require.Empty(t, srv.addCalls, "nil remote rosenpass pubkey must skip AddPeer")
+ require.Empty(t, m.rpPeerIDs)
+}
+
+func TestOnConnected_ValidPubKey_CallsAddPeer(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ wgKey := validWGKey(t, 1)
+ m.OnConnected(wgKey, make([]byte, 32), "100.1.1.1", "rp:5000")
+ require.Len(t, srv.addCalls, 1)
+ require.Contains(t, m.rpPeerIDs, wgKey)
+}
+
+func TestOnDisconnected_UnknownPeer_NoOp(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ m.OnDisconnected(validWGKey(t, 99))
+ require.Empty(t, srv.removed, "unknown peer key must not call RemovePeer")
+}
+
+func TestOnDisconnected_KnownPeer_CallsRemoveAndForgets(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ wgKey := validWGKey(t, 1)
+ require.NoError(t, m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey))
+ require.Contains(t, m.rpPeerIDs, wgKey)
+
+ m.OnDisconnected(wgKey)
+ require.Len(t, srv.removed, 1)
+ require.NotContains(t, m.rpPeerIDs, wgKey, "peer must be forgotten after disconnect")
+}
+
+// --- IsPresharedKeyInitialized ---------------------------------------------
+
+func TestIsPresharedKeyInitialized_UnknownPeer_ReturnsFalse(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+ require.False(t, m.IsPresharedKeyInitialized(validWGKey(t, 1)))
+}
+
+func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ wgKey := validWGKey(t, 2)
+ require.NoError(t, m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey))
+ require.False(t, m.IsPresharedKeyInitialized(wgKey))
+}
+
+// --- NetbirdHandler.outputKey ----------------------------------------------
+
+func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
+ h := NewNetbirdHandler()
+ iface := &mockIface{}
+ h.SetInterface(iface)
+
+ pid := rp.PeerID{0x01}
+ wgKey := wgtypes.Key{0xAA}
+ h.AddPeer(pid, "wt0", rp.Key(wgKey))
+
+ psk := rp.Key{0xBB}
+ h.HandshakeCompleted(pid, psk)
+
+ require.Len(t, iface.calls, 1)
+ require.False(t, iface.calls[0].updateOnly, "first PSK rotation must use updateOnly=false")
+ require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
+}
+
+func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
+ h := NewNetbirdHandler()
+ iface := &mockIface{}
+ h.SetInterface(iface)
+
+ pid := rp.PeerID{0x02}
+ h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{0xCC}))
+
+ h.HandshakeCompleted(pid, rp.Key{0x01}) // first
+ h.HandshakeCompleted(pid, rp.Key{0x02}) // second
+
+ require.Len(t, iface.calls, 2)
+ require.False(t, iface.calls[0].updateOnly)
+ require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true")
+}
+
+func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
+ h := NewNetbirdHandler()
+ // no SetInterface — iface remains nil
+ pid := rp.PeerID{0x03}
+ h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{}))
+
+ // Must not panic.
+ h.HandshakeCompleted(pid, rp.Key{})
+}
+
+func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
+ h := NewNetbirdHandler()
+ iface := &mockIface{}
+ h.SetInterface(iface)
+
+ h.HandshakeCompleted(rp.PeerID{0xFF}, rp.Key{})
+ require.Empty(t, iface.calls, "unknown peer id must not trigger SetPresharedKey")
+}
+
+func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
+ h := NewNetbirdHandler()
+ iface := &mockIface{}
+ h.SetInterface(iface)
+
+ pid := rp.PeerID{0x04}
+ h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{0xDD}))
+ h.HandshakeCompleted(pid, rp.Key{0x01})
+ require.True(t, h.IsPeerInitialized(pid))
+
+ h.RemovePeer(pid)
+ require.False(t, h.IsPeerInitialized(pid), "RemovePeer must clear initialized flag")
+}
+
+func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) {
+ h := NewNetbirdHandler()
+ pid := rp.PeerID{0x05}
+ wgKey := wgtypes.Key{0xEE}
+ h.AddPeer(pid, "wt0", rp.Key(wgKey))
+
+ iface := &mockIface{}
+ h.SetInterface(iface) // set after AddPeer
+
+ h.HandshakeCompleted(pid, rp.Key{0x42})
+ require.Len(t, iface.calls, 1)
+ require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
+}
diff --git a/client/internal/rosenpass/seed.go b/client/internal/rosenpass/seed.go
new file mode 100644
index 000000000..83aba1e0e
--- /dev/null
+++ b/client/internal/rosenpass/seed.go
@@ -0,0 +1,42 @@
+package rosenpass
+
+import (
+ "fmt"
+
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+)
+
+// DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair
+// of peer public keys. Both peers, given the same key pair, produce the same
+// output regardless of which side runs the function: the inputs are ordered
+// lexicographically before concatenation.
+//
+// NetBird uses this value as the initial Rosenpass-side preshared key when no
+// explicit account-level PSK is configured, so both peers converge on the same
+// PSK before the first post-quantum handshake completes.
+//
+// The resulting key MUST NOT be treated as quantum-safe: it is deterministic
+// from public keys and exists only to seed WireGuard until Rosenpass rotates
+// in a real post-quantum PSK.
+func DeterministicSeedKey(localKey, remoteKey string) (*wgtypes.Key, error) {
+ lk := []byte(localKey)
+ rk := []byte(remoteKey)
+ if len(lk) < 16 || len(rk) < 16 {
+ return nil, fmt.Errorf("rosenpass: peer keys must be at least 16 bytes (got local=%d, remote=%d)", len(lk), len(rk))
+ }
+
+ var keyInput []byte
+ if localKey > remoteKey {
+ keyInput = append(keyInput, lk[:16]...)
+ keyInput = append(keyInput, rk[:16]...)
+ } else {
+ keyInput = append(keyInput, rk[:16]...)
+ keyInput = append(keyInput, lk[:16]...)
+ }
+
+ key, err := wgtypes.NewKey(keyInput)
+ if err != nil {
+ return nil, fmt.Errorf("rosenpass: deterministic seed key: %w", err)
+ }
+ return &key, nil
+}
diff --git a/client/internal/rosenpass/seed_test.go b/client/internal/rosenpass/seed_test.go
new file mode 100644
index 000000000..0dfa478c7
--- /dev/null
+++ b/client/internal/rosenpass/seed_test.go
@@ -0,0 +1,44 @@
+package rosenpass
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDeterministicSeedKey_SameForBothSides(t *testing.T) {
+ // Peer A and peer B must derive the same PSK regardless of which side
+ // computes it: the function orders inputs internally.
+ a := strings.Repeat("a", 32)
+ b := strings.Repeat("b", 32)
+
+ keyAB, err := DeterministicSeedKey(a, b)
+ require.NoError(t, err)
+ keyBA, err := DeterministicSeedKey(b, a)
+ require.NoError(t, err)
+ require.Equal(t, keyAB.String(), keyBA.String(), "swapping arguments must yield identical key")
+}
+
+func TestDeterministicSeedKey_ChangesWithKeys(t *testing.T) {
+ a := strings.Repeat("a", 32)
+ b := strings.Repeat("b", 32)
+ c := strings.Repeat("c", 32)
+
+ keyAB, err := DeterministicSeedKey(a, b)
+ require.NoError(t, err)
+ keyAC, err := DeterministicSeedKey(a, c)
+ require.NoError(t, err)
+ require.NotEqual(t, keyAB.String(), keyAC.String(), "different peer pair must yield different key")
+}
+
+func TestDeterministicSeedKey_TooShortKey_ReturnsError(t *testing.T) {
+ short := "short" // < 16 bytes
+ long := strings.Repeat("x", 32)
+
+ _, err := DeterministicSeedKey(short, long)
+ require.Error(t, err)
+ _, err = DeterministicSeedKey(long, short)
+ require.Error(t, err)
+}
+
diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go
index 839ec14c0..f10a2b5e0 100644
--- a/client/internal/routemanager/manager.go
+++ b/client/internal/routemanager/manager.go
@@ -700,6 +700,13 @@ func resolveURLsToIPs(urls []string) []net.IP {
// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
+ // An explicit user "deselect all" must not be overridden by management auto-apply.
+ // Auto-applying an exit node here would call SelectRoutes, which clears the
+ // deselect-all flag and re-enables every route the user turned off.
+ if m.routeSelector.IsDeselectAll() {
+ return
+ }
+
exitNodeInfo := m.collectExitNodeInfo(clientRoutes)
if len(exitNodeInfo.allIDs) == 0 {
return
diff --git a/client/internal/routemanager/selector_management_test.go b/client/internal/routemanager/selector_management_test.go
new file mode 100644
index 000000000..04659db65
--- /dev/null
+++ b/client/internal/routemanager/selector_management_test.go
@@ -0,0 +1,71 @@
+package routemanager
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal/routeselector"
+ "github.com/netbirdio/netbird/route"
+)
+
+func exitNodeRoutes(netID route.NetID, skipAutoApply bool) route.HAMap {
+ haID := route.HAUniqueID(string(netID) + "|0.0.0.0/0")
+ return route.HAMap{
+ haID: []*route.Route{
+ {
+ ID: "r-" + route.ID(netID),
+ NetID: netID,
+ Network: netip.MustParsePrefix("0.0.0.0/0"),
+ NetworkType: route.IPv4Network,
+ Enabled: true,
+ SkipAutoApply: skipAutoApply,
+ },
+ },
+ }
+}
+
+func TestUpdateRouteSelectorFromManagement(t *testing.T) {
+ t.Run("management auto-apply selects exit node without user selection", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ routes := exitNodeRoutes("exit1", false)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsSelected("exit1"), "auto-apply exit node should be selected")
+ require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "selected exit node should pass the filter")
+ })
+
+ t.Run("management SkipAutoApply leaves exit node deselected", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ routes := exitNodeRoutes("exit1", true)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.False(t, m.routeSelector.IsSelected("exit1"), "SkipAutoApply exit node should not be selected")
+ require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "deselected exit node should be filtered out")
+ })
+
+ t.Run("user selection is not overridden by management", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exit1"}, true, []route.NetID{"exit1"}))
+ routes := exitNodeRoutes("exit1", true)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsSelected("exit1"), "explicit user selection must survive a management sync that wants to skip auto-apply")
+ require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "user-selected exit node should pass the filter")
+ })
+
+ t.Run("deselect-all is preserved across a management sync", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ m.routeSelector.DeselectAllRoutes()
+ routes := exitNodeRoutes("exit1", false)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsDeselectAll(), "an explicit deselect-all must not be cleared by management auto-apply")
+ require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "no routes should be selected while deselect-all is set")
+ })
+}
diff --git a/client/internal/routemanager/systemops/routeflags_addfilter_bsd.go b/client/internal/routemanager/systemops/routeflags_addfilter_bsd.go
new file mode 100644
index 000000000..45a1bfceb
--- /dev/null
+++ b/client/internal/routemanager/systemops/routeflags_addfilter_bsd.go
@@ -0,0 +1,9 @@
+//go:build dragonfly || freebsd || netbsd || openbsd
+
+package systemops
+
+// IgnoreAddedDefaultRoute reports whether an RTM_ADD default route with the
+// given flags should be ignored by the network monitor.
+func IgnoreAddedDefaultRoute(flags int) bool {
+ return filterRoutesByFlags(flags)
+}
diff --git a/client/internal/routemanager/systemops/routeflags_addfilter_darwin.go b/client/internal/routemanager/systemops/routeflags_addfilter_darwin.go
new file mode 100644
index 000000000..e8f655387
--- /dev/null
+++ b/client/internal/routemanager/systemops/routeflags_addfilter_darwin.go
@@ -0,0 +1,21 @@
+//go:build darwin
+
+package systemops
+
+import "golang.org/x/sys/unix"
+
+// IgnoreAddedDefaultRoute reports whether an RTM_ADD default route with the
+// given flags should be ignored by the network monitor. Scoped routes
+// (RTF_IFSCOPE) are tied to a specific interface index and cannot replace the
+// unscoped default the kernel uses for general egress, so flapping ones (e.g.
+// Wi-Fi calling IMS tunnels on ipsec0, Docker bridges, scoped utun defaults)
+// must not trigger an engine restart.
+func IgnoreAddedDefaultRoute(flags int) bool {
+ if filterRoutesByFlags(flags) {
+ return true
+ }
+ if flags&unix.RTF_IFSCOPE != 0 {
+ return true
+ }
+ return false
+}
diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go
index 2ddc24bf2..b9991cd37 100644
--- a/client/internal/routeselector/routeselector.go
+++ b/client/internal/routeselector/routeselector.go
@@ -116,6 +116,14 @@ func (rs *RouteSelector) DeselectAllRoutes() {
clear(rs.selectedRoutes)
}
+// IsDeselectAll reports whether the user has explicitly deselected all routes.
+func (rs *RouteSelector) IsDeselectAll() bool {
+ rs.mu.RLock()
+ defer rs.mu.RUnlock()
+
+ return rs.deselectAll
+}
+
// IsSelected checks if a specific route is selected.
func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
rs.mu.RLock()
diff --git a/client/internal/statemanager/manager.go b/client/internal/statemanager/manager.go
index 7d4cf3deb..5145c1cbe 100644
--- a/client/internal/statemanager/manager.go
+++ b/client/internal/statemanager/manager.go
@@ -104,17 +104,19 @@ func (m *Manager) Stop(ctx context.Context) error {
}
m.mu.Lock()
- defer m.mu.Unlock()
+ cancel := m.cancel
+ done := m.done
+ m.mu.Unlock()
- if m.cancel == nil {
+ if cancel == nil {
return nil
}
- m.cancel()
+ cancel()
select {
case <-ctx.Done():
return ctx.Err()
- case <-m.done:
+ case <-done:
}
return nil
diff --git a/client/internal/syncstore/disk.go b/client/internal/syncstore/disk.go
new file mode 100644
index 000000000..eb24e87a7
--- /dev/null
+++ b/client/internal/syncstore/disk.go
@@ -0,0 +1,99 @@
+package syncstore
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/proto"
+
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+ "github.com/netbirdio/netbird/util"
+)
+
+// syncResponseFileName is the name of the file the sync response is serialized
+// to, placed inside the configured directory (the state directory).
+const syncResponseFileName = "networkmap.pb"
+
+// diskStore serializes the latest sync response to a file on disk instead of
+// keeping it in memory. This trades disk I/O for a much smaller memory
+// footprint, which matters on memory-constrained platforms (iOS).
+type diskStore struct {
+ mu sync.Mutex
+ path string
+}
+
+// NewDiskStore returns a Store that serializes the sync response to a file in
+// the given directory. If dir is empty it falls back to the OS temp directory.
+//
+// Any file left over from a previous run is removed on construction so a fresh
+// store never reads stale data (e.g. another profile's network map).
+func NewDiskStore(dir string) Store {
+ if dir == "" {
+ dir = os.TempDir()
+ }
+ s := &diskStore{
+ path: filepath.Join(dir, syncResponseFileName),
+ }
+ if err := s.Clear(); err != nil {
+ log.Warnf("failed to clear stale sync response file: %v", err)
+ }
+ return s
+}
+
+func (s *diskStore) Set(resp *mgmProto.SyncResponse) error {
+ if resp == nil {
+ return s.Clear()
+ }
+
+ bs, err := proto.Marshal(resp)
+ if err != nil {
+ return fmt.Errorf("marshal sync response: %w", err)
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := util.WriteBytesWithRestrictedPermission(context.Background(), s.path, bs); err != nil {
+ return fmt.Errorf("write sync response to %s: %w", s.path, err)
+ }
+
+ log.Debugf("sync response persisted to %s (%d bytes)", s.path, len(bs))
+ return nil
+}
+
+func (s *diskStore) Get() (*mgmProto.SyncResponse, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ bs, err := os.ReadFile(s.path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ //nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
+ return nil, nil
+ }
+ return nil, fmt.Errorf("read sync response from %s: %w", s.path, err)
+ }
+
+ resp := &mgmProto.SyncResponse{}
+ if err := proto.Unmarshal(bs, resp); err != nil {
+ return nil, fmt.Errorf("unmarshal sync response: %w", err)
+ }
+
+ log.Debugf("retrieving latest sync response from %s (%d bytes)", s.path, len(bs))
+ return resp, nil
+}
+
+func (s *diskStore) Clear() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("remove sync response file %s: %w", s.path, err)
+ }
+ return nil
+}
diff --git a/client/internal/syncstore/factory_ios.go b/client/internal/syncstore/factory_ios.go
new file mode 100644
index 000000000..f19ab5e5c
--- /dev/null
+++ b/client/internal/syncstore/factory_ios.go
@@ -0,0 +1,9 @@
+//go:build ios
+
+package syncstore
+
+// New returns the platform default store. On iOS the sync response is
+// serialized to disk (in dir) to keep it out of the constrained process memory.
+func New(dir string) Store {
+ return NewDiskStore(dir)
+}
diff --git a/client/internal/syncstore/factory_other.go b/client/internal/syncstore/factory_other.go
new file mode 100644
index 000000000..79ea46116
--- /dev/null
+++ b/client/internal/syncstore/factory_other.go
@@ -0,0 +1,9 @@
+//go:build !ios
+
+package syncstore
+
+// New returns the platform default store. On all non-iOS platforms the sync
+// response is kept in memory; dir is unused.
+func New(_ string) Store {
+ return NewMemoryStore()
+}
diff --git a/client/internal/syncstore/memory.go b/client/internal/syncstore/memory.go
new file mode 100644
index 000000000..8fc069069
--- /dev/null
+++ b/client/internal/syncstore/memory.go
@@ -0,0 +1,56 @@
+package syncstore
+
+import (
+ "fmt"
+ "sync"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/proto"
+
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// memoryStore keeps the latest sync response in memory.
+type memoryStore struct {
+ mu sync.RWMutex
+ latest *mgmProto.SyncResponse
+}
+
+// NewMemoryStore returns a Store that keeps the sync response in memory.
+func NewMemoryStore() Store {
+ return &memoryStore{}
+}
+
+func (s *memoryStore) Set(resp *mgmProto.SyncResponse) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.latest = resp
+ return nil
+}
+
+func (s *memoryStore) Get() (*mgmProto.SyncResponse, error) {
+ s.mu.RLock()
+ latest := s.latest
+ s.mu.RUnlock()
+
+ if latest == nil {
+ //nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
+ return nil, nil
+ }
+
+ log.Debugf("retrieving latest sync response with size %d bytes", proto.Size(latest))
+ sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
+ if !ok {
+ return nil, fmt.Errorf("clone sync response")
+ }
+ return sr, nil
+}
+
+func (s *memoryStore) Clear() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.latest = nil
+ return nil
+}
diff --git a/client/internal/syncstore/syncstore.go b/client/internal/syncstore/syncstore.go
new file mode 100644
index 000000000..ba24b9c57
--- /dev/null
+++ b/client/internal/syncstore/syncstore.go
@@ -0,0 +1,29 @@
+// Package syncstore stores the latest Management sync response (which carries
+// the network map) for debug bundle generation.
+//
+// The storage backend is selected at build time per operating system: on iOS
+// the response is serialized to disk to keep it out of the (tightly
+// constrained) process memory, while on all other platforms it is kept in
+// memory. The backend is chosen by the New constructor; see factory_ios.go and
+// factory_other.go.
+package syncstore
+
+import (
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// Store persists the latest sync response and returns it on demand.
+//
+// Implementations must be safe for concurrent use.
+type Store interface {
+ // Set stores the given sync response, replacing any previously stored one.
+ Set(resp *mgmProto.SyncResponse) error
+
+ // Get returns the stored sync response, or nil if none is stored.
+ // The returned value is an independent copy that the caller may retain.
+ Get() (*mgmProto.SyncResponse, error)
+
+ // Clear removes any stored sync response. It is safe to call when nothing
+ // is stored.
+ Clear() error
+}
diff --git a/client/internal/updater/manager.go b/client/internal/updater/manager.go
index dfcb93177..7fc300739 100644
--- a/client/internal/updater/manager.go
+++ b/client/internal/updater/manager.go
@@ -19,8 +19,6 @@ import (
const (
latestVersion = "latest"
- // this version will be ignored
- developmentVersion = "development"
)
var errNoUpdateState = errors.New("no update state found")
@@ -483,7 +481,7 @@ func (m *Manager) loadAndDeleteUpdateState(ctx context.Context) (*UpdateState, e
}
func (m *Manager) shouldUpdate(updateVersion *v.Version, forceUpdate bool) bool {
- if m.currentVersion == developmentVersion {
+ if version.IsDevelopmentVersion(m.currentVersion) {
log.Debugf("skipping auto-update, running development version")
return false
}
diff --git a/client/netbird.wxs b/client/netbird.wxs
index 96814ce52..6f18b63b5 100644
--- a/client/netbird.wxs
+++ b/client/netbird.wxs
@@ -64,13 +64,6 @@
-
-
-
-
-
@@ -83,28 +76,10 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go
index 08b73a25e..153a82412 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -1649,6 +1649,7 @@ type LocalPeerState struct {
RosenpassPermissive bool `protobuf:"varint,6,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"`
Networks []string `protobuf:"bytes,7,rep,name=networks,proto3" json:"networks,omitempty"`
Ipv6 string `protobuf:"bytes,8,opt,name=ipv6,proto3" json:"ipv6,omitempty"`
+ WgPort int32 `protobuf:"varint,9,opt,name=wgPort,proto3" json:"wgPort,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1739,6 +1740,13 @@ func (x *LocalPeerState) GetIpv6() string {
return ""
}
+func (x *LocalPeerState) GetWgPort() int32 {
+ if x != nil {
+ return x.WgPort
+ }
+ return 0
+}
+
// SignalState contains the latest state of a signal connection
type SignalState struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -2886,6 +2894,7 @@ type DebugBundleRequest struct {
SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"`
UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"`
LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"`
+ CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -2948,6 +2957,13 @@ func (x *DebugBundleRequest) GetLogFileCount() uint32 {
return 0
}
+func (x *DebugBundleRequest) GetCliVersion() string {
+ if x != nil {
+ return x.CliVersion
+ }
+ return ""
+}
+
type DebugBundleResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
@@ -6691,7 +6707,7 @@ const file_daemon_proto_rawDesc = "" +
"\n" +
"sshHostKey\x18\x13 \x01(\fR\n" +
"sshHostKey\x12\x12\n" +
- "\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x84\x02\n" +
+ "\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x9c\x02\n" +
"\x0eLocalPeerState\x12\x0e\n" +
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12(\n" +
@@ -6700,7 +6716,8 @@ const file_daemon_proto_rawDesc = "" +
"\x10rosenpassEnabled\x18\x05 \x01(\bR\x10rosenpassEnabled\x120\n" +
"\x13rosenpassPermissive\x18\x06 \x01(\bR\x13rosenpassPermissive\x12\x1a\n" +
"\bnetworks\x18\a \x03(\tR\bnetworks\x12\x12\n" +
- "\x04ipv6\x18\b \x01(\tR\x04ipv6\"S\n" +
+ "\x04ipv6\x18\b \x01(\tR\x04ipv6\x12\x16\n" +
+ "\x06wgPort\x18\t \x01(\x05R\x06wgPort\"S\n" +
"\vSignalState\x12\x10\n" +
"\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" +
"\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" +
@@ -6787,14 +6804,17 @@ const file_daemon_proto_rawDesc = "" +
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
"\x17ForwardingRulesResponse\x12,\n" +
- "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x94\x01\n" +
+ "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xb4\x01\n" +
"\x12DebugBundleRequest\x12\x1c\n" +
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
"\n" +
"systemInfo\x18\x03 \x01(\bR\n" +
"systemInfo\x12\x1c\n" +
"\tuploadURL\x18\x04 \x01(\tR\tuploadURL\x12\"\n" +
- "\flogFileCount\x18\x05 \x01(\rR\flogFileCount\"}\n" +
+ "\flogFileCount\x18\x05 \x01(\rR\flogFileCount\x12\x1e\n" +
+ "\n" +
+ "cliVersion\x18\x06 \x01(\tR\n" +
+ "cliVersion\"}\n" +
"\x13DebugBundleResponse\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index c253de2ed..6cfe0a7fe 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -365,6 +365,7 @@ message LocalPeerState {
bool rosenpassPermissive = 6;
repeated string networks = 7;
string ipv6 = 8;
+ int32 wgPort = 9;
}
// SignalState contains the latest state of a signal connection
@@ -507,6 +508,7 @@ message DebugBundleRequest {
bool systemInfo = 3;
string uploadURL = 4;
uint32 logFileCount = 5;
+ string cliVersion = 6;
}
message DebugBundleResponse {
diff --git a/client/proto/generate.sh b/client/proto/generate.sh
index e659cef90..1ae55e380 100755
--- a/client/proto/generate.sh
+++ b/client/proto/generate.sh
@@ -1,17 +1,16 @@
#!/bin/bash
set -e
-if ! which realpath > /dev/null 2>&1
-then
- echo realpath is not installed
- echo run: brew install coreutils
- exit 1
+if ! which realpath >/dev/null 2>&1; then
+ echo realpath is not installed
+ echo run: brew install coreutils
+ exit 1
fi
old_pwd=$(pwd)
-script_path=$(dirname $(realpath "$0"))
+script_path=$(dirname "$(realpath "$0")")
cd "$script_path"
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6
-go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
+go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1
protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional
cd "$old_pwd"
diff --git a/client/server/debug.go b/client/server/debug.go
index 33247db5f..14dcaba33 100644
--- a/client/server/debug.go
+++ b/client/server/debug.go
@@ -14,6 +14,7 @@ import (
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+ "github.com/netbirdio/netbird/version"
)
// DebugBundle creates a debug bundle and returns the location.
@@ -67,6 +68,8 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
CapturePath: capturePath,
RefreshStatus: refreshStatus,
ClientMetrics: clientMetrics,
+ DaemonVersion: version.NetbirdVersion(),
+ CliVersion: req.CliVersion,
},
debug.BundleConfig{
Anonymize: req.GetAnonymize(),
diff --git a/client/server/server_test.go b/client/server/server_test.go
index 641cd85fe..66e0fcc4c 100644
--- a/client/server/server_test.go
+++ b/client/server/server_test.go
@@ -13,7 +13,7 @@ import (
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
- "github.com/netbirdio/management-integrations/integrations"
+ "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"
@@ -315,7 +315,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve
return nil, "", err
}
- ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
+ ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
require.NoError(t, err)
diff --git a/client/status/status.go b/client/status/status.go
index 6b88e891a..2e2a7dde2 100644
--- a/client/status/status.go
+++ b/client/status/status.go
@@ -156,6 +156,7 @@ type OutputOverview struct {
IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"`
PubKey string `json:"publicKey" yaml:"publicKey"`
KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"`
+ WgPort int `json:"wireguardPort" yaml:"wireguardPort"`
FQDN string `json:"fqdn" yaml:"fqdn"`
RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"`
RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"`
@@ -202,6 +203,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(),
PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(),
KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(),
+ WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()),
FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(),
RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(),
RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(),
@@ -603,6 +605,21 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
goarm = fmt.Sprintf(" (ARMv%s)", os.Getenv("GOARM"))
}
+ daemonVersion := "N/A"
+ if o.DaemonVersion != "" {
+ daemonVersion = o.DaemonVersion
+ }
+
+ cliVersion := version.NetbirdVersion()
+ if o.CliVersion != "" {
+ cliVersion = o.CliVersion
+ }
+
+ wgPortString := "N/A"
+ if o.WgPort > 0 {
+ wgPortString = fmt.Sprintf("%d", o.WgPort)
+ }
+
summary := fmt.Sprintf(
"OS: %s\n"+
"Daemon version: %s\n"+
@@ -616,6 +633,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
"NetBird IP: %s\n"+
"%s"+
"Interface type: %s\n"+
+ "Wireguard port: %s\n"+
"Quantum resistance: %s\n"+
"Lazy connection: %s\n"+
"SSH Server: %s\n"+
@@ -624,8 +642,8 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
"%s"+
"Peers count: %s\n",
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
- o.DaemonVersion,
- version.NetbirdVersion(),
+ daemonVersion,
+ cliVersion,
o.ProfileName,
managementConnString,
signalConnString,
@@ -635,6 +653,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
interfaceIP,
ipv6Line,
interfaceTypeString,
+ wgPortString,
rosenpassEnabledStatus,
lazyConnectionEnabledStatus,
sshServerStatus,
diff --git a/client/status/status_test.go b/client/status/status_test.go
index 52118d014..aead76bff 100644
--- a/client/status/status_test.go
+++ b/client/status/status_test.go
@@ -94,6 +94,7 @@ var resp = &proto.StatusResponse{
Ipv6: "fd00::100",
PubKey: "Some-Pub-Key",
KernelInterface: true,
+ WgPort: 51820,
Fqdn: "some-localhost.awesome-domain.com",
Networks: []string{
"10.10.0.0/24",
@@ -210,6 +211,7 @@ var overview = OutputOverview{
IPv6: "fd00::100",
PubKey: "Some-Pub-Key",
KernelInterface: true,
+ WgPort: 51820,
FQDN: "some-localhost.awesome-domain.com",
NSServerGroups: []NsServerGroupStateOutput{
{
@@ -373,6 +375,7 @@ func TestParsingToJSON(t *testing.T) {
"netbirdIpv6": "fd00::100",
"publicKey": "Some-Pub-Key",
"usesKernelInterface": true,
+ "wireguardPort": 51820,
"fqdn": "some-localhost.awesome-domain.com",
"quantumResistance": false,
"quantumResistancePermissive": false,
@@ -495,6 +498,7 @@ netbirdIp: 192.168.178.100/16
netbirdIpv6: fd00::100
publicKey: Some-Pub-Key
usesKernelInterface: true
+wireguardPort: 51820
fqdn: some-localhost.awesome-domain.com
quantumResistance: false
quantumResistancePermissive: false
@@ -590,13 +594,14 @@ FQDN: some-localhost.awesome-domain.com
NetBird IP: 192.168.178.100/16
NetBird IPv6: fd00::100
Interface type: Kernel
+Wireguard port: %d
Quantum resistance: false
Lazy connection: false
SSH Server: Disabled
VNC Server: Disabled
Networks: 10.10.0.0/24
Peers count: 2/2 Connected
-`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion)
+`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion, overview.WgPort)
assert.Equal(t, expectedDetail, detail)
}
@@ -616,6 +621,7 @@ FQDN: some-localhost.awesome-domain.com
NetBird IP: 192.168.178.100/16
NetBird IPv6: fd00::100
Interface type: Kernel
+Wireguard port: 51820
Quantum resistance: false
Lazy connection: false
SSH Server: Disabled
diff --git a/client/system/info_linux.go b/client/system/info_linux.go
index 6c7a23b95..de37a9f5b 100644
--- a/client/system/info_linux.go
+++ b/client/system/info_linux.go
@@ -3,15 +3,14 @@
package system
import (
- "bytes"
"context"
"os"
- "os/exec"
"regexp"
"runtime"
- "strings"
"time"
+ "golang.org/x/sys/unix"
+
log "github.com/sirupsen/logrus"
"github.com/zcalusic/sysinfo"
@@ -29,19 +28,11 @@ func UpdateStaticInfoAsync() {
// GetInfo retrieves and parses the system information
func GetInfo(ctx context.Context) *Info {
- info := _getInfo()
- for strings.Contains(info, "broken pipe") {
- info = _getInfo()
- time.Sleep(500 * time.Millisecond)
- }
-
- osStr := strings.ReplaceAll(info, "\n", "")
- osStr = strings.ReplaceAll(osStr, "\r\n", "")
- osInfo := strings.Split(osStr, " ")
+ kernelName, kernelVersion, kernelPlatform := kernelInfo()
osName, osVersion := readOsReleaseFile()
if osName == "" {
- osName = osInfo[3]
+ osName = kernelName
}
systemHostname, _ := os.Hostname()
@@ -58,8 +49,8 @@ func GetInfo(ctx context.Context) *Info {
}
gio := &Info{
- Kernel: osInfo[0],
- Platform: osInfo[2],
+ Kernel: kernelName,
+ Platform: kernelPlatform,
OS: osName,
OSVersion: osVersion,
Hostname: extractDeviceName(ctx, systemHostname),
@@ -67,7 +58,7 @@ func GetInfo(ctx context.Context) *Info {
CPUs: runtime.NumCPU(),
NetbirdVersion: version.NetbirdVersion(),
UIVersion: extractUserAgent(ctx),
- KernelVersion: osInfo[1],
+ KernelVersion: kernelVersion,
NetworkAddresses: addrs,
SystemSerialNumber: si.SystemSerialNumber,
SystemProductName: si.SystemProductName,
@@ -78,18 +69,12 @@ func GetInfo(ctx context.Context) *Info {
return gio
}
-func _getInfo() string {
- cmd := exec.Command("uname", "-srio")
- cmd.Stdin = strings.NewReader("some")
- var out bytes.Buffer
- var stderr bytes.Buffer
- cmd.Stdout = &out
- cmd.Stderr = &stderr
- err := cmd.Run()
- if err != nil {
- log.Warnf("getInfo: %s", err)
+func kernelInfo() (string, string, string) {
+ var uts unix.Utsname
+ if err := unix.Uname(&uts); err != nil {
+ return "", "", ""
}
- return out.String()
+ return unix.ByteSliceToString(uts.Sysname[:]), unix.ByteSliceToString(uts.Release[:]), unix.ByteSliceToString(uts.Machine[:])
}
func sysInfo() (string, string, string) {
diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go
index 18ca9d08b..d030937a6 100644
--- a/client/ui/client_ui.go
+++ b/client/ui/client_ui.go
@@ -546,7 +546,7 @@ func (s *serviceClient) getConnectionForm() *widget.Form {
{Text: "Pre-shared Key", Widget: s.iPreSharedKey},
{Text: "Quantum-Resistance", Widget: s.sRosenpassPermissive},
{Text: "Interface Name", Widget: s.iInterfaceName},
- {Text: "Interface Port", Widget: s.iInterfacePort},
+ {Text: "Interface Port", Widget: s.iInterfacePort, HintText: "If set to 0, a random free port will be used"},
{Text: "MTU", Widget: s.iMTU},
{Text: "Log File", Widget: s.iLogFile},
},
@@ -602,8 +602,8 @@ func (s *serviceClient) parseNumericSettings() (int64, int64, error) {
if err != nil {
return 0, 0, errors.New("invalid interface port")
}
- if port < 1 || port > 65535 {
- return 0, 0, errors.New("invalid interface port: out of range 1-65535")
+ if port < 0 || port > 65535 {
+ return 0, 0, errors.New("invalid interface port: out of range 0-65535")
}
var mtu int64
@@ -1515,7 +1515,7 @@ func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config {
}
config.WgIface = cfg.InterfaceName
- if cfg.WireguardPort != 0 {
+ if cfg.WireguardPort >= 0 && cfg.WireguardPort <= 65535 {
config.WgPort = int(cfg.WireguardPort)
} else {
config.WgPort = iface.DefaultWgPort
diff --git a/client/ui/debug.go b/client/ui/debug.go
index cf5ac1a75..d3d4fa4f8 100644
--- a/client/ui/debug.go
+++ b/client/ui/debug.go
@@ -21,6 +21,7 @@ import (
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
uptypes "github.com/netbirdio/netbird/upload-server/types"
+ "github.com/netbirdio/netbird/version"
)
// Initial state for the debug collection
@@ -462,6 +463,7 @@ func (s *serviceClient) createDebugBundleFromCollection(
request := &proto.DebugBundleRequest{
Anonymize: params.anonymize,
SystemInfo: params.systemInfo,
+ CliVersion: version.NetbirdVersion(),
}
if params.upload {
@@ -593,6 +595,7 @@ func (s *serviceClient) createDebugBundle(anonymize bool, systemInfo bool, uploa
request := &proto.DebugBundleRequest{
Anonymize: anonymize,
SystemInfo: systemInfo,
+ CliVersion: version.NetbirdVersion(),
}
if uploadURL != "" {
diff --git a/client/wasm/internal/rdp/cert_validation.go b/client/wasm/internal/rdp/cert_validation.go
index 1678c3996..47e30b6e6 100644
--- a/client/wasm/internal/rdp/cert_validation.go
+++ b/client/wasm/internal/rdp/cert_validation.go
@@ -6,6 +6,7 @@ import (
"crypto/tls"
"crypto/x509"
"fmt"
+ "sync"
"syscall/js"
"time"
@@ -13,7 +14,7 @@ import (
)
const (
- certValidationTimeout = 60 * time.Second
+ certValidationTimeout = 5 * time.Minute
)
func (p *RDCleanPathProxy) validateCertificateWithJS(conn *proxyConnection, certChain [][]byte) (bool, error) {
@@ -46,17 +47,31 @@ func (p *RDCleanPathProxy) validateCertificateWithJS(conn *proxyConnection, cert
promise := conn.wsHandlers.Call("onCertificateRequest", certInfo)
- resultChan := make(chan bool)
- errorChan := make(chan error)
+ resultChan := make(chan bool, 1)
+ errorChan := make(chan error, 1)
- promise.Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
- result := args[0].Bool()
- resultChan <- result
+ // Release from inside the callbacks so a post-timeout promise resolution
+ // does not invoke an already-released func.
+ var thenFn, catchFn js.Func
+ var releaseOnce sync.Once
+ release := func() {
+ releaseOnce.Do(func() {
+ thenFn.Release()
+ catchFn.Release()
+ })
+ }
+ thenFn = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
+ defer release()
+ resultChan <- args[0].Bool()
return nil
- })).Call("catch", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
+ })
+ catchFn = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
+ defer release()
errorChan <- fmt.Errorf("certificate validation failed")
return nil
- }))
+ })
+
+ promise.Call("then", thenFn).Call("catch", catchFn)
select {
case result := <-resultChan:
diff --git a/client/wasm/internal/rdp/rdcleanpath.go b/client/wasm/internal/rdp/rdcleanpath.go
index 6c36fdec6..ee420dca4 100644
--- a/client/wasm/internal/rdp/rdcleanpath.go
+++ b/client/wasm/internal/rdp/rdcleanpath.go
@@ -11,6 +11,7 @@ import (
"io"
"net"
"sync"
+ "sync/atomic"
"syscall/js"
"time"
@@ -57,6 +58,8 @@ type RDCleanPathProxy struct {
}
activeConnections map[string]*proxyConnection
destinations map[string]string
+ pendingHandlers map[string]js.Func
+ nextID atomic.Uint64
mu sync.Mutex
}
@@ -66,8 +69,15 @@ type proxyConnection struct {
rdpConn net.Conn
tlsConn *tls.Conn
wsHandlers js.Value
- ctx context.Context
- cancel context.CancelFunc
+ // Go-side callbacks exposed to JS. js.FuncOf pins the Go closure in a
+ // global handle map and MUST be released, otherwise every connection
+ // leaks the Go memory the closure captures.
+ wsHandlerFn js.Func
+ onMessageFn js.Func
+ onCloseFn js.Func
+ cleanupOnce sync.Once
+ ctx context.Context
+ cancel context.CancelFunc
}
// NewRDCleanPathProxy creates a new RDCleanPath proxy
@@ -80,7 +90,11 @@ func NewRDCleanPathProxy(client interface {
}
}
-// CreateProxy creates a new proxy endpoint for the given destination
+// CreateProxy creates a new proxy endpoint for the given destination.
+// The registered handler fn and its destinations/pendingHandlers entries are
+// only released once a connection is established and cleanupConnection runs.
+// If a caller invokes CreateProxy but never connects to the returned URL,
+// those entries stay pinned for the lifetime of the page.
func (p *RDCleanPathProxy) CreateProxy(hostname, port string) js.Value {
destination := net.JoinHostPort(hostname, port)
@@ -88,7 +102,7 @@ func (p *RDCleanPathProxy) CreateProxy(hostname, port string) js.Value {
resolve := args[0]
go func() {
- proxyID := fmt.Sprintf("proxy_%d", len(p.activeConnections))
+ proxyID := fmt.Sprintf("proxy_%d", p.nextID.Add(1))
p.mu.Lock()
if p.destinations == nil {
@@ -100,7 +114,7 @@ func (p *RDCleanPathProxy) CreateProxy(hostname, port string) js.Value {
proxyURL := fmt.Sprintf("%s://%s/%s", RDCleanPathProxyScheme, RDCleanPathProxyHost, proxyID)
// Register the WebSocket handler for this specific proxy
- js.Global().Set(fmt.Sprintf("handleRDCleanPathWebSocket_%s", proxyID), js.FuncOf(func(_ js.Value, args []js.Value) any {
+ handlerFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return js.ValueOf("error: requires WebSocket argument")
}
@@ -108,7 +122,14 @@ func (p *RDCleanPathProxy) CreateProxy(hostname, port string) js.Value {
ws := args[0]
p.HandleWebSocketConnection(ws, proxyID)
return nil
- }))
+ })
+ p.mu.Lock()
+ if p.pendingHandlers == nil {
+ p.pendingHandlers = make(map[string]js.Func)
+ }
+ p.pendingHandlers[proxyID] = handlerFn
+ p.mu.Unlock()
+ js.Global().Set(fmt.Sprintf("handleRDCleanPathWebSocket_%s", proxyID), handlerFn)
log.Infof("Created RDCleanPath proxy endpoint: %s for destination: %s", proxyURL, destination)
resolve.Invoke(proxyURL)
@@ -142,6 +163,10 @@ func (p *RDCleanPathProxy) HandleWebSocketConnection(ws js.Value, proxyID string
p.mu.Lock()
p.activeConnections[proxyID] = conn
+ if fn, ok := p.pendingHandlers[proxyID]; ok {
+ conn.wsHandlerFn = fn
+ delete(p.pendingHandlers, proxyID)
+ }
p.mu.Unlock()
p.setupWebSocketHandlers(ws, conn)
@@ -150,7 +175,7 @@ func (p *RDCleanPathProxy) HandleWebSocketConnection(ws js.Value, proxyID string
}
func (p *RDCleanPathProxy) setupWebSocketHandlers(ws js.Value, conn *proxyConnection) {
- ws.Set("onGoMessage", js.FuncOf(func(this js.Value, args []js.Value) any {
+ conn.onMessageFn = js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 1 {
return nil
}
@@ -158,13 +183,15 @@ func (p *RDCleanPathProxy) setupWebSocketHandlers(ws js.Value, conn *proxyConnec
data := args[0]
go p.handleWebSocketMessage(conn, data)
return nil
- }))
+ })
+ ws.Set("onGoMessage", conn.onMessageFn)
- ws.Set("onGoClose", js.FuncOf(func(_ js.Value, args []js.Value) any {
+ conn.onCloseFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
log.Debug("WebSocket closed by JavaScript")
conn.cancel()
return nil
- }))
+ })
+ ws.Set("onGoClose", conn.onCloseFn)
}
func (p *RDCleanPathProxy) handleWebSocketMessage(conn *proxyConnection, data js.Value) {
@@ -261,25 +288,49 @@ func (p *RDCleanPathProxy) handleDirectRDP(conn *proxyConnection, firstPacket []
}
func (p *RDCleanPathProxy) cleanupConnection(conn *proxyConnection) {
- log.Debugf("Cleaning up connection %s", conn.id)
- conn.cancel()
- if conn.tlsConn != nil {
- log.Debug("Closing TLS connection")
- if err := conn.tlsConn.Close(); err != nil {
- log.Debugf("Error closing TLS connection: %v", err)
+ conn.cleanupOnce.Do(func() {
+ log.Debugf("Cleaning up connection %s", conn.id)
+ conn.cancel()
+ if conn.tlsConn != nil {
+ log.Debug("Closing TLS connection")
+ if err := conn.tlsConn.Close(); err != nil {
+ log.Debugf("Error closing TLS connection: %v", err)
+ }
+ conn.tlsConn = nil
}
- conn.tlsConn = nil
- }
- if conn.rdpConn != nil {
- log.Debug("Closing TCP connection")
- if err := conn.rdpConn.Close(); err != nil {
- log.Debugf("Error closing TCP connection: %v", err)
+ if conn.rdpConn != nil {
+ log.Debug("Closing TCP connection")
+ if err := conn.rdpConn.Close(); err != nil {
+ log.Debugf("Error closing TCP connection: %v", err)
+ }
+ conn.rdpConn = nil
}
- conn.rdpConn = nil
- }
- p.mu.Lock()
- delete(p.activeConnections, conn.id)
- p.mu.Unlock()
+ js.Global().Delete(fmt.Sprintf("handleRDCleanPathWebSocket_%s", conn.id))
+
+ // Detach before releasing so late JS calls surface as TypeError instead
+ // of silent "call to released function".
+ if conn.wsHandlers.Truthy() {
+ conn.wsHandlers.Set("onGoMessage", js.Undefined())
+ conn.wsHandlers.Set("onGoClose", js.Undefined())
+ }
+
+ // wsHandlerFn may be zero-value if the pending handler lookup missed.
+ if conn.wsHandlerFn.Truthy() {
+ conn.wsHandlerFn.Release()
+ }
+ if conn.onMessageFn.Truthy() {
+ conn.onMessageFn.Release()
+ }
+ if conn.onCloseFn.Truthy() {
+ conn.onCloseFn.Release()
+ }
+
+ p.mu.Lock()
+ delete(p.activeConnections, conn.id)
+ delete(p.destinations, conn.id)
+ delete(p.pendingHandlers, conn.id)
+ p.mu.Unlock()
+ })
}
func (p *RDCleanPathProxy) sendToWebSocket(conn *proxyConnection, data []byte) {
diff --git a/client/wasm/internal/ssh/handlers.go b/client/wasm/internal/ssh/handlers.go
index ea64eb0aa..6d33916a5 100644
--- a/client/wasm/internal/ssh/handlers.go
+++ b/client/wasm/internal/ssh/handlers.go
@@ -13,7 +13,7 @@ import (
func CreateJSInterface(client *Client) js.Value {
jsInterface := js.Global().Get("Object").Call("create", js.Null())
- jsInterface.Set("write", js.FuncOf(func(this js.Value, args []js.Value) any {
+ writeFunc := js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 1 {
return js.ValueOf(false)
}
@@ -32,9 +32,10 @@ func CreateJSInterface(client *Client) js.Value {
_, err := client.Write(bytes)
return js.ValueOf(err == nil)
- }))
+ })
+ jsInterface.Set("write", writeFunc)
- jsInterface.Set("resize", js.FuncOf(func(this js.Value, args []js.Value) any {
+ resizeFunc := js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 2 {
return js.ValueOf(false)
}
@@ -42,14 +43,26 @@ func CreateJSInterface(client *Client) js.Value {
rows := args[1].Int()
err := client.Resize(cols, rows)
return js.ValueOf(err == nil)
- }))
+ })
+ jsInterface.Set("resize", resizeFunc)
- jsInterface.Set("close", js.FuncOf(func(this js.Value, args []js.Value) any {
+ closeFunc := js.FuncOf(func(this js.Value, args []js.Value) any {
client.Close()
return js.Undefined()
- }))
+ })
+ jsInterface.Set("close", closeFunc)
- go readLoop(client, jsInterface)
+ go func() {
+ readLoop(client, jsInterface)
+ // Detach before releasing so late JS calls surface as TypeError instead
+ // of silent "call to released function".
+ jsInterface.Set("write", js.Undefined())
+ jsInterface.Set("resize", js.Undefined())
+ jsInterface.Set("close", js.Undefined())
+ writeFunc.Release()
+ resizeFunc.Release()
+ closeFunc.Release()
+ }()
return jsInterface
}
diff --git a/combined/cmd/root.go b/combined/cmd/root.go
index db986b4d4..31e0580fb 100644
--- a/combined/cmd/root.go
+++ b/combined/cmd/root.go
@@ -67,6 +67,10 @@ func init() {
rootCmd.AddCommand(newTokenCommands())
}
+func RootCmd() *cobra.Command {
+ return rootCmd
+}
+
func Execute() error {
return rootCmd.Execute()
}
@@ -168,7 +172,7 @@ func initializeConfig() error {
// serverInstances holds all server instances created during startup.
type serverInstances struct {
relaySrv *relayServer.Server
- mgmtSrv *mgmtServer.BaseServer
+ mgmtSrv mgmtServer.Server
signalSrv *signalServer.Server
healthcheck *healthcheck.Server
stunServer *stun.Server
@@ -324,19 +328,24 @@ func setupServerHooks(servers *serverInstances, cfg *CombinedConfig) {
return
}
- servers.mgmtSrv.AfterInit(func(s *mgmtServer.BaseServer) {
- grpcSrv := s.GRPCServer()
+ if s, ok := servers.mgmtSrv.GetContainer(mgmtServer.ContainerKeyBaseServer); ok {
+ if baseServer, ok := s.(*mgmtServer.BaseServer); ok {
+ baseServer.AfterInit(func(s *mgmtServer.BaseServer) {
+ grpcSrv := s.GRPCServer()
- if servers.signalSrv != nil {
- proto.RegisterSignalExchangeServer(grpcSrv, servers.signalSrv)
- log.Infof("Signal server registered on port %s", cfg.Server.ListenAddress)
- }
+ if servers.signalSrv != nil {
+ proto.RegisterSignalExchangeServer(grpcSrv, servers.signalSrv)
+ log.Infof("Signal server registered on port %s", cfg.Server.ListenAddress)
+ }
- s.SetHandlerFunc(createCombinedHandler(grpcSrv, s.APIHandler(), servers.relaySrv, servers.metricsServer.Meter, cfg))
- if servers.relaySrv != nil {
- log.Infof("Relay WebSocket handler added (path: /relay)")
+ s.SetHandlerFunc(createCombinedHandler(grpcSrv, s.APIHandler(), s.IDPHandler(), servers.relaySrv, servers.metricsServer.Meter, cfg))
+ if servers.relaySrv != nil {
+ log.Infof("Relay WebSocket handler added (path: /relay)")
+ }
+ })
}
- })
+ }
+
}
func startServers(wg *sync.WaitGroup, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, metricsServer *sharedMetrics.Metrics) {
@@ -346,38 +355,32 @@ func startServers(wg *sync.WaitGroup, srv *relayServer.Server, httpHealthcheck *
log.Infof("Relay WebSocket multiplexed on management port (no separate relay listener)")
}
- wg.Add(1)
- go func() {
- defer wg.Done()
+ wg.Go(func() {
log.Infof("running metrics server: %s%s", metricsServer.Addr, metricsServer.Endpoint)
if err := metricsServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("failed to start metrics server: %v", err)
}
- }()
+ })
- wg.Add(1)
- go func() {
- defer wg.Done()
+ wg.Go(func() {
if err := httpHealthcheck.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("failed to start healthcheck server: %v", err)
}
- }()
+ })
if stunServer != nil {
- wg.Add(1)
- go func() {
- defer wg.Done()
+ wg.Go(func() {
if err := stunServer.Listen(); err != nil {
if errors.Is(err, stun.ErrServerClosed) {
return
}
log.Errorf("STUN server error: %v", err)
}
- }()
+ })
}
}
-func shutdownServers(ctx context.Context, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, mgmtSrv *mgmtServer.BaseServer, metricsServer *sharedMetrics.Metrics) error {
+func shutdownServers(ctx context.Context, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, mgmtSrv mgmtServer.Server, metricsServer *sharedMetrics.Metrics) error {
var errs error
if err := httpHealthcheck.Shutdown(ctx); err != nil {
@@ -491,7 +494,7 @@ func handleTLSConfig(cfg *CombinedConfig) (*tls.Config, bool, error) {
return nil, false, nil
}
-func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (*mgmtServer.BaseServer, error) {
+func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (mgmtServer.Server, error) {
mgmt := cfg.Management
// Extract port from listen address
@@ -502,7 +505,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (*
}
mgmtPort, _ := strconv.Atoi(portStr)
- mgmtSrv := mgmtServer.NewServer(
+ mgmtSrv := newServer(
&mgmtServer.Config{
NbConfig: mgmtConfig,
DNSDomain: "",
@@ -521,7 +524,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (*
}
// createCombinedHandler creates an HTTP handler that multiplexes Management, Signal (via wsproxy), and Relay WebSocket traffic
-func createCombinedHandler(grpcServer *grpc.Server, httpHandler http.Handler, relaySrv *relayServer.Server, meter metric.Meter, cfg *CombinedConfig) http.Handler {
+func createCombinedHandler(grpcServer *grpc.Server, httpHandler http.Handler, idpHandler http.Handler, relaySrv *relayServer.Server, meter metric.Meter, cfg *CombinedConfig) http.Handler {
wsProxy := wsproxyserver.New(grpcServer, wsproxyserver.WithOTelMeter(meter))
var relayAcceptFn func(conn listener.Conn)
@@ -556,6 +559,10 @@ func createCombinedHandler(grpcServer *grpc.Server, httpHandler http.Handler, re
http.Error(w, "Relay service not enabled", http.StatusNotFound)
}
+ // Embedded IdP (Dex)
+ case idpHandler != nil && strings.HasPrefix(r.URL.Path, "/oauth2"):
+ idpHandler.ServeHTTP(w, r)
+
// Management HTTP API (default)
default:
httpHandler.ServeHTTP(w, r)
diff --git a/combined/cmd/server.go b/combined/cmd/server.go
new file mode 100644
index 000000000..f9384dfb1
--- /dev/null
+++ b/combined/cmd/server.go
@@ -0,0 +1,13 @@
+package cmd
+
+import (
+ mgmtServer "github.com/netbirdio/netbird/management/internals/server"
+)
+
+var newServer = func(cfg *mgmtServer.Config) mgmtServer.Server {
+ return mgmtServer.NewServer(cfg)
+}
+
+func SetNewServer(fn func(*mgmtServer.Config) mgmtServer.Server) {
+ newServer = fn
+}
diff --git a/formatter/hook/hook.go b/formatter/hook/hook.go
index f0ee509f8..69758566d 100644
--- a/formatter/hook/hook.go
+++ b/formatter/hook/hook.go
@@ -99,6 +99,9 @@ func addFields(entry *logrus.Entry) {
if ctxAccountID, ok := entry.Context.Value(context.AccountIDKey).(string); ok {
entry.Data[context.AccountIDKey] = ctxAccountID
}
+ if ctxUserAgent, ok := entry.Context.Value(context.UserAgentKey).(string); ok {
+ entry.Data[context.UserAgentKey] = ctxUserAgent
+ }
if ctxInitiatorID, ok := entry.Context.Value(context.UserIDKey).(string); ok {
entry.Data[context.UserIDKey] = ctxInitiatorID
}
diff --git a/go.mod b/go.mod
index dab8623d9..b6bd7c598 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/netbirdio/netbird
go 1.25.5
require (
- cunicu.li/go-rosenpass v0.4.0
+ cunicu.li/go-rosenpass v0.5.42
github.com/cenkalti/backoff/v4 v4.3.0
github.com/cloudflare/circl v1.3.3 // indirect
github.com/golang/protobuf v1.5.4
@@ -19,18 +19,18 @@ require (
github.com/vishvananda/netlink v1.3.1
golang.org/x/crypto v0.50.0
golang.org/x/sys v0.43.0
- golang.zx2c4.com/wireguard v0.0.0-20230704135630-469159ecf7d1
- golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
+ golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
+ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
golang.zx2c4.com/wireguard/windows v0.5.3
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
- gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
fyne.io/fyne/v2 v2.7.0
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3
+ github.com/DeRuina/timberjack v1.4.2
github.com/awnumar/memguard v0.23.0
github.com/aws/aws-sdk-go-v2 v1.38.3
github.com/aws/aws-sdk-go-v2/config v1.31.6
@@ -38,7 +38,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3
github.com/c-robinson/iplib v1.0.3
github.com/caddyserver/certmagic v0.21.3
- github.com/cilium/ebpf v0.15.0
+ github.com/cilium/ebpf v0.19.0
github.com/coder/websocket v1.8.14
github.com/coreos/go-iptables v0.7.0
github.com/coreos/go-oidc/v3 v3.18.0
@@ -61,7 +61,7 @@ require (
github.com/google/go-cmp v0.7.0
github.com/google/gopacket v1.1.19
github.com/google/nftables v0.3.0
- github.com/gopacket/gopacket v1.1.1
+ github.com/gopacket/gopacket v1.4.0
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2
@@ -338,7 +338,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-20260107100953-33b7c9d03db0
+replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f
replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6
diff --git a/go.sum b/go.sum
index 49894c22f..7e05c56c1 100644
--- a/go.sum
+++ b/go.sum
@@ -7,8 +7,8 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 h1:b8xUw3004wk+3ipBhu0VU4RtUJsegMIiqjxSK4++lzA=
codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6/go.mod h1:+CauBF6R70Jqcyl8N2hC8pAXYbWkGIezuSbuGLtRhnw=
-cunicu.li/go-rosenpass v0.4.0 h1:LtPtBgFWY/9emfgC4glKLEqS0MJTylzV6+ChRhiZERw=
-cunicu.li/go-rosenpass v0.4.0/go.mod h1:MPbjH9nxV4l3vEagKVdFNwHOketqgS5/To1VYJplf/M=
+cunicu.li/go-rosenpass v0.5.42 h1:fRDsGwCxd7DhDgZI1Pxeo8GtNyq8BESZJ7w2/BGGJtU=
+cunicu.li/go-rosenpass v0.5.42/go.mod h1:YRBeyKOe/gWpSX2kpDUec5p9t0XOLsshTguId5gTGVg=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
@@ -29,6 +29,8 @@ github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/DeRuina/timberjack v1.4.2 h1:4bKlzhKdsR+2oNkgef9mqb4n11ICow8VK88RfzJPzN8=
+github.com/DeRuina/timberjack v1.4.2/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
@@ -111,8 +113,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
-github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
+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/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
@@ -227,8 +229,8 @@ github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3Bum
github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
-github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
-github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
+github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
+github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
@@ -309,8 +311,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
-github.com/gopacket/gopacket v1.1.1 h1:zbx9F9d6A7sWNkFKrvMBZTfGgxFoY4NgUudFVVHMfcw=
-github.com/gopacket/gopacket v1.1.1/go.mod h1:HavMeONEl7W9036of9LbSWoonqhH7HA1+ZRO+rMIvFs=
+github.com/gopacket/gopacket v1.4.0 h1:cr1OlFpzksCkZHNO0eLjaSSOrMQnpPXg0j6qHIY3y2U=
+github.com/gopacket/gopacket v1.4.0/go.mod h1:EpvsxINeehp5qj4YMKMLf2/dekdhKn2IIAO/ZOifS7o=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@@ -394,6 +396,8 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM=
+github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
@@ -506,8 +510,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-20260107100953-33b7c9d03db0 h1:h/QnNzm7xzHPm+gajcblYUOclrW2FeNeDlUNj6tTWKQ=
-github.com/netbirdio/wireguard-go v0.0.0-20260107100953-33b7c9d03db0/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
+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/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=
@@ -908,8 +912,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
-golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvYQH2OU3/TnxLx97WDSUDRABfT18pCOYwc2GE=
-golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80=
+golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
+golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
@@ -946,8 +950,6 @@ gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
-gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh
index 9d1b57258..770cecc44 100755
--- a/infrastructure_files/getting-started.sh
+++ b/infrastructure_files/getting-started.sh
@@ -19,6 +19,46 @@ readonly MSG_SEPARATOR="=========================================="
# Utility Functions
############################################
+check_docker_sock_perms() {
+ local sock="${DOCKER_HOST:-unix:///var/run/docker.sock}"
+ sock="${sock#unix://}"
+
+ if [[ ! -S "$sock" ]]; then
+ return 0
+ fi
+
+ if [[ ! -r "$sock" ]] || [[ ! -w "$sock" ]]; then
+ local group
+ if [[ "${OSTYPE}" == "darwin"* ]]; then
+ group="$(stat -f '%Sg' "$sock")"
+ else
+ group="$(stat -c '%G' "$sock")"
+ fi
+
+ echo "Cannot access Docker socket: $sock" > /dev/stderr
+ echo "" > /dev/stderr
+ echo "Socket permissions:" > /dev/stderr
+ ls -l "$sock" > /dev/stderr
+ echo "" > /dev/stderr
+
+ if [[ "$group" == "docker" ]]; then
+ echo "Your user may need to be added to the '$group' group:" > /dev/stderr
+ echo " sudo usermod -aG $group \"$USER\"" > /dev/stderr
+ echo "Then log out and back in, or run this for the current shell:" > /dev/stderr
+ echo " newgrp $group" > /dev/stderr
+ echo "Note: newgrp is temporary; usermod is the permanent group change." > /dev/stderr
+ else
+ echo "The Docker socket is owned by the '$group' group, which is not the standard 'docker' group." > /dev/stderr
+ echo "For safety, this script will not suggest adding your user to '$group'." > /dev/stderr
+ echo "Instead, either run this script with appropriate privileges (for example, via sudo) or follow Docker's post-install steps to configure access via the 'docker' group:" > /dev/stderr
+ echo " https://docs.docker.com/engine/install/linux-postinstall/" > /dev/stderr
+ fi
+
+ exit 1
+ fi
+ return 0
+}
+
check_docker_compose() {
if command -v docker-compose &> /dev/null
then
@@ -311,11 +351,12 @@ initialize_default_values() {
NETBIRD_STUN_PORT=3478
# Docker images
- DASHBOARD_IMAGE="netbirdio/dashboard:latest"
+ DASHBOARD_IMAGE=${DASHBOARD_IMAGE:-"netbirdio/dashboard:latest"}
# Combined server replaces separate signal, relay, and management containers
- NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:latest"
- NETBIRD_PROXY_IMAGE="netbirdio/reverse-proxy:latest"
-
+ NETBIRD_SERVER_IMAGE=${NETBIRD_SERVER_IMAGE:-"netbirdio/netbird-server:latest"}
+ NETBIRD_PROXY_IMAGE=${NETBIRD_PROXY_IMAGE:-"netbirdio/reverse-proxy:latest"}
+ TRAEFIK_IMAGE=${TRAEFIK_IMAGE:-"traefik:v3.6"}
+ CROWDSEC_IMAGE=${CROWDSEC_IMAGE:-"crowdsecurity/crowdsec:v1.7.7"}
# Reverse proxy configuration
REVERSE_PROXY_TYPE="0"
TRAEFIK_EXTERNAL_NETWORK=""
@@ -580,12 +621,15 @@ start_services_and_show_instructions() {
}
init_environment() {
+ # Check if docker compose is installed using check_docker_compose function
+ DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
+ check_docker_sock_perms
+
initialize_default_values
configure_domain
configure_reverse_proxy
check_jq
- DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
check_existing_installation
generate_configuration_files
@@ -656,7 +700,7 @@ render_docker_compose_traefik_builtin() {
if [[ "$ENABLE_CROWDSEC" == "true" ]]; then
crowdsec_service="
crowdsec:
- image: crowdsecurity/crowdsec:v1.7.7
+ image: $CROWDSEC_IMAGE
container_name: netbird-crowdsec
restart: unless-stopped
networks: [netbird]
@@ -687,7 +731,7 @@ render_docker_compose_traefik_builtin() {
services:
# Traefik reverse proxy (automatic TLS via Let's Encrypt)
traefik:
- image: traefik:v3.6
+ image: $TRAEFIK_IMAGE
container_name: netbird-traefik
restart: unless-stopped
networks:
@@ -771,7 +815,7 @@ $traefik_dynamic_volume
labels:
- traefik.enable=true
# gRPC router (needs h2c backend for HTTP/2 cleartext)
- - traefik.http.routers.netbird-grpc.rule=Host(\`$NETBIRD_DOMAIN\`) && (PathPrefix(\`/signalexchange.SignalExchange/\`) || PathPrefix(\`/management.ManagementService/\`))
+ - traefik.http.routers.netbird-grpc.rule=Host(\`$NETBIRD_DOMAIN\`) && (PathPrefix(\`/signalexchange.SignalExchange/\`) || PathPrefix(\`/management.ManagementService/\`) || PathPrefix(\`/management.ProxyService/\`))
- traefik.http.routers.netbird-grpc.entrypoints=websecure
- traefik.http.routers.netbird-grpc.tls=true
- traefik.http.routers.netbird-grpc.tls.certresolver=letsencrypt
diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
index 590773dda..2b81cd6e5 100644
--- a/management/internals/controllers/network_map/controller/controller.go
+++ b/management/internals/controllers/network_map/controller/controller.go
@@ -32,6 +32,7 @@ import (
"github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/management/status"
"github.com/netbirdio/netbird/util"
+ "github.com/netbirdio/netbird/version"
)
type Controller struct {
@@ -112,7 +113,7 @@ func (c *Controller) CountStreams() int {
return c.peersUpdateManager.CountStreams()
}
-func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID string) error {
+func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
log.WithContext(ctx).Tracef("updating peers for account %s from %s", accountID, util.GetCallerName())
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
if err != nil {
@@ -175,6 +176,10 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
continue
}
+ if c.accountManagerMetrics != nil {
+ c.accountManagerMetrics.CountNmapTriggered(string(reason.Resource), string(reason.Operation))
+ }
+
wg.Add(1)
semaphore <- struct{}{}
go func(p *nbpeer.Peer) {
@@ -242,14 +247,14 @@ func (c *Controller) bufferSendUpdateAccountPeers(ctx context.Context, accountID
go func() {
defer b.mu.Unlock()
- _ = c.sendUpdateAccountPeers(ctx, accountID)
+ _ = c.sendUpdateAccountPeers(ctx, accountID, reason)
if !b.update.Load() {
return
}
b.update.Store(false)
if b.next == nil {
b.next = time.AfterFunc(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() {
- _ = c.sendUpdateAccountPeers(ctx, accountID)
+ _ = c.sendUpdateAccountPeers(ctx, accountID, reason)
})
return
}
@@ -265,7 +270,7 @@ func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string, r
if c.accountManagerMetrics != nil {
c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation))
}
- return c.sendUpdateAccountPeers(ctx, accountID)
+ return c.sendUpdateAccountPeers(ctx, accountID, reason)
}
func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error {
@@ -359,14 +364,14 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str
go func() {
defer b.mu.Unlock()
- _ = c.sendUpdateAccountPeers(ctx, accountID)
+ _ = c.sendUpdateAccountPeers(ctx, accountID, reason)
if !b.update.Load() {
return
}
b.update.Store(false)
if b.next == nil {
b.next = time.AfterFunc(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() {
- _ = c.sendUpdateAccountPeers(ctx, accountID)
+ _ = c.sendUpdateAccountPeers(ctx, accountID, reason)
})
return
}
@@ -510,7 +515,7 @@ func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 {
for _, peer := range peers {
// Development version is always supported
- if peer.Meta.WtVersion == "development" {
+ if version.IsDevelopmentVersion(peer.Meta.WtVersion) {
continue
}
peerVersion := semver.Canonical("v" + peer.Meta.WtVersion)
diff --git a/management/internals/controllers/network_map/update_channel/updatechannel.go b/management/internals/controllers/network_map/update_channel/updatechannel.go
index 5f7db5300..91627bf15 100644
--- a/management/internals/controllers/network_map/update_channel/updatechannel.go
+++ b/management/internals/controllers/network_map/update_channel/updatechannel.go
@@ -51,7 +51,7 @@ func (p *PeersUpdateManager) SendUpdate(ctx context.Context, peerID string, upda
found = true
select {
case channel <- update:
- log.WithContext(ctx).Debugf("update was sent to channel for peer %s", peerID)
+ log.WithContext(ctx).Tracef("update was sent to channel for peer %s", peerID)
default:
dropped = true
log.WithContext(ctx).Warnf("channel for peer %s is %d full or closed", peerID, len(channel))
diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go
index c913efb92..8f3253063 100644
--- a/management/internals/modules/peers/manager.go
+++ b/management/internals/modules/peers/manager.go
@@ -5,6 +5,7 @@ package peers
import (
"context"
"fmt"
+ "net"
"time"
"github.com/rs/xid"
@@ -35,6 +36,14 @@ type Manager interface {
SetAccountManager(accountManager account.Manager)
GetPeerID(ctx context.Context, peerKey string) (string, error)
CreateProxyPeer(ctx context.Context, accountID string, peerKey string, cluster string) error
+ // GetPeerByTunnelIP looks up a peer in accountID by its WireGuard tunnel IP.
+ // Returns nil with an error when no match exists. No permission check;
+ // callers (the proxy's ValidateTunnelPeer RPC) are trusted server components.
+ GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error)
+ // GetPeerWithGroups returns the peer and the list of *types.Group it belongs
+ // to. Used by the proxy's auth path to authorise a request by the calling
+ // peer's group memberships.
+ GetPeerWithGroups(ctx context.Context, accountID, peerID string) (*peer.Peer, []*types.Group, error)
}
type managerImpl struct {
@@ -66,7 +75,7 @@ func (m *managerImpl) SetAccountManager(accountManager account.Manager) {
}
func (m *managerImpl) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) {
- allowed, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
}
@@ -79,7 +88,7 @@ func (m *managerImpl) GetPeer(ctx context.Context, accountID, userID, peerID str
}
func (m *managerImpl) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) {
- allowed, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
}
@@ -99,6 +108,26 @@ func (m *managerImpl) GetPeersByGroupIDs(ctx context.Context, accountID string,
return m.store.GetPeersByGroupIDs(ctx, accountID, groupsIDs)
}
+// GetPeerByTunnelIP delegates to the store's indexed lookup.
+func (m *managerImpl) GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error) {
+ return m.store.GetPeerByIP(ctx, store.LockingStrengthNone, accountID, ip)
+}
+
+// GetPeerWithGroups returns the peer plus its group memberships. Any store
+// error returns (nil, nil, err) so callers never receive a valid peer
+// alongside a non-nil error.
+func (m *managerImpl) GetPeerWithGroups(ctx context.Context, accountID, peerID string) (*peer.Peer, []*types.Group, error) {
+ p, err := m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
+ if err != nil {
+ return nil, nil, err
+ }
+ groups, err := m.store.GetPeerGroups(ctx, store.LockingStrengthNone, accountID, peerID)
+ if err != nil {
+ return nil, nil, err
+ }
+ return p, groups, nil
+}
+
func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error {
settings, err := m.store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
diff --git a/management/internals/modules/peers/manager_mock.go b/management/internals/modules/peers/manager_mock.go
index d6c9ebacc..3836ac909 100644
--- a/management/internals/modules/peers/manager_mock.go
+++ b/management/internals/modules/peers/manager_mock.go
@@ -6,6 +6,7 @@ package peers
import (
context "context"
+ net "net"
reflect "reflect"
gomock "github.com/golang/mock/gomock"
@@ -13,6 +14,7 @@ import (
account "github.com/netbirdio/netbird/management/server/account"
integrated_validator "github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
peer "github.com/netbirdio/netbird/management/server/peer"
+ types "github.com/netbirdio/netbird/management/server/types"
)
// MockManager is a mock of Manager interface.
@@ -38,6 +40,20 @@ func (m *MockManager) EXPECT() *MockManagerMockRecorder {
return m.recorder
}
+// CreateProxyPeer mocks base method.
+func (m *MockManager) CreateProxyPeer(ctx context.Context, accountID, peerKey, cluster string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "CreateProxyPeer", ctx, accountID, peerKey, cluster)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// CreateProxyPeer indicates an expected call of CreateProxyPeer.
+func (mr *MockManagerMockRecorder) CreateProxyPeer(ctx, accountID, peerKey, cluster interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateProxyPeer", reflect.TypeOf((*MockManager)(nil).CreateProxyPeer), ctx, accountID, peerKey, cluster)
+}
+
// DeletePeers mocks base method.
func (m *MockManager) DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error {
m.ctrl.T.Helper()
@@ -97,6 +113,21 @@ func (mr *MockManagerMockRecorder) GetPeerAccountID(ctx, peerID interface{}) *go
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerAccountID", reflect.TypeOf((*MockManager)(nil).GetPeerAccountID), ctx, peerID)
}
+// GetPeerByTunnelIP mocks base method.
+func (m *MockManager) GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetPeerByTunnelIP", ctx, accountID, ip)
+ ret0, _ := ret[0].(*peer.Peer)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetPeerByTunnelIP indicates an expected call of GetPeerByTunnelIP.
+func (mr *MockManagerMockRecorder) GetPeerByTunnelIP(ctx, accountID, ip interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByTunnelIP", reflect.TypeOf((*MockManager)(nil).GetPeerByTunnelIP), ctx, accountID, ip)
+}
+
// GetPeerID mocks base method.
func (m *MockManager) GetPeerID(ctx context.Context, peerKey string) (string, error) {
m.ctrl.T.Helper()
@@ -112,6 +143,22 @@ func (mr *MockManagerMockRecorder) GetPeerID(ctx, peerKey interface{}) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerID", reflect.TypeOf((*MockManager)(nil).GetPeerID), ctx, peerKey)
}
+// GetPeerWithGroups mocks base method.
+func (m *MockManager) GetPeerWithGroups(ctx context.Context, accountID, peerID string) (*peer.Peer, []*types.Group, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetPeerWithGroups", ctx, accountID, peerID)
+ ret0, _ := ret[0].(*peer.Peer)
+ ret1, _ := ret[1].([]*types.Group)
+ ret2, _ := ret[2].(error)
+ return ret0, ret1, ret2
+}
+
+// GetPeerWithGroups indicates an expected call of GetPeerWithGroups.
+func (mr *MockManagerMockRecorder) GetPeerWithGroups(ctx, accountID, peerID interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerWithGroups", reflect.TypeOf((*MockManager)(nil).GetPeerWithGroups), ctx, accountID, peerID)
+}
+
// GetPeersByGroupIDs mocks base method.
func (m *MockManager) GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error) {
m.ctrl.T.Helper()
@@ -162,17 +209,3 @@ func (mr *MockManagerMockRecorder) SetNetworkMapController(networkMapController
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetNetworkMapController", reflect.TypeOf((*MockManager)(nil).SetNetworkMapController), networkMapController)
}
-
-// CreateProxyPeer mocks base method.
-func (m *MockManager) CreateProxyPeer(ctx context.Context, accountID string, peerKey string, cluster string) error {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "CreateProxyPeer", ctx, accountID, peerKey, cluster)
- ret0, _ := ret[0].(error)
- return ret0
-}
-
-// CreateProxyPeer indicates an expected call of CreateProxyPeer.
-func (mr *MockManagerMockRecorder) CreateProxyPeer(ctx, accountID, peerKey, cluster interface{}) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateProxyPeer", reflect.TypeOf((*MockManager)(nil).CreateProxyPeer), ctx, accountID, peerKey, cluster)
-}
diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go
index 59d7704eb..ced2ec4d1 100644
--- a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go
+++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go
@@ -63,7 +63,7 @@ func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.Ac
// GetAllAccessLogs retrieves access logs for an account with pagination and filtering
func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID string, filter *accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, 0, status.NewPermissionValidationError(err)
}
diff --git a/management/internals/modules/reverseproxy/domain/domain.go b/management/internals/modules/reverseproxy/domain/domain.go
index f65e31a07..08d7ad19b 100644
--- a/management/internals/modules/reverseproxy/domain/domain.go
+++ b/management/internals/modules/reverseproxy/domain/domain.go
@@ -23,6 +23,8 @@ type Domain struct {
// SupportsCrowdSec is populated at query time from proxy cluster capabilities.
// Not persisted.
SupportsCrowdSec *bool `gorm:"-"`
+ // SupportsPrivate is populated at query time from proxy cluster capabilities. Not persisted.
+ SupportsPrivate *bool `gorm:"-"`
}
// EventMeta returns activity event metadata for a domain
diff --git a/management/internals/modules/reverseproxy/domain/manager/api.go b/management/internals/modules/reverseproxy/domain/manager/api.go
index 4493ef0ad..f01329010 100644
--- a/management/internals/modules/reverseproxy/domain/manager/api.go
+++ b/management/internals/modules/reverseproxy/domain/manager/api.go
@@ -49,6 +49,7 @@ func domainToApi(d *domain.Domain) api.ReverseProxyDomain {
SupportsCustomPorts: d.SupportsCustomPorts,
RequireSubdomain: d.RequireSubdomain,
SupportsCrowdsec: d.SupportsCrowdSec,
+ SupportsPrivate: d.SupportsPrivate,
}
if d.TargetCluster != "" {
resp.TargetCluster = &d.TargetCluster
diff --git a/management/internals/modules/reverseproxy/domain/manager/manager.go b/management/internals/modules/reverseproxy/domain/manager/manager.go
index 2790b5f20..3c0f0d73b 100644
--- a/management/internals/modules/reverseproxy/domain/manager/manager.go
+++ b/management/internals/modules/reverseproxy/domain/manager/manager.go
@@ -35,6 +35,7 @@ type proxyManager interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
+ ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
}
type Manager struct {
@@ -56,7 +57,7 @@ func NewManager(store store, proxyMgr proxyManager, permissionsManager permissio
}
func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*domain.Domain, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -93,6 +94,7 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
d.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, cluster)
d.RequireSubdomain = m.proxyManager.ClusterRequireSubdomain(ctx, cluster)
d.SupportsCrowdSec = m.proxyManager.ClusterSupportsCrowdSec(ctx, cluster)
+ d.SupportsPrivate = m.proxyManager.ClusterSupportsPrivate(ctx, cluster)
ret = append(ret, d)
}
@@ -109,6 +111,7 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
if d.TargetCluster != "" {
cd.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, d.TargetCluster)
cd.SupportsCrowdSec = m.proxyManager.ClusterSupportsCrowdSec(ctx, d.TargetCluster)
+ cd.SupportsPrivate = m.proxyManager.ClusterSupportsPrivate(ctx, d.TargetCluster)
}
// Custom domains never require a subdomain by default since
// the account owns them and should be able to use the bare domain.
@@ -119,7 +122,7 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
}
func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*domain.Domain, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -160,7 +163,7 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName
}
func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -184,7 +187,7 @@ func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID s
}
func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
+ ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
if err != nil {
log.WithFields(log.Fields{
"accountID": accountID,
diff --git a/management/internals/modules/reverseproxy/domain/manager/manager_test.go b/management/internals/modules/reverseproxy/domain/manager/manager_test.go
index 5e7bbfc36..53a8dedae 100644
--- a/management/internals/modules/reverseproxy/domain/manager/manager_test.go
+++ b/management/internals/modules/reverseproxy/domain/manager/manager_test.go
@@ -10,7 +10,7 @@ import (
)
type mockProxyManager struct {
- getActiveClusterAddressesFunc func(ctx context.Context) ([]string, error)
+ getActiveClusterAddressesFunc func(ctx context.Context) ([]string, error)
getActiveClusterAddressesForAccountFunc func(ctx context.Context, accountID string) ([]string, error)
}
@@ -40,6 +40,10 @@ func (m *mockProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string)
return nil
}
+func (m *mockProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
+ return nil
+}
+
func TestGetClusterAllowList_BYOPMergedWithPublic(t *testing.T) {
pm := &mockProxyManager{
getActiveClusterAddressesForAccountFunc: func(_ context.Context, accID string) ([]string, error) {
@@ -151,4 +155,3 @@ func TestGetClusterAllowList_PublicEmpty_BYOPOnly(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, []string{"byop.example.com"}, result)
}
-
diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go
index 07ea6f0ab..22f1007ec 100644
--- a/management/internals/modules/reverseproxy/proxy/manager.go
+++ b/management/internals/modules/reverseproxy/proxy/manager.go
@@ -19,6 +19,7 @@ type Manager interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
+ ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStale(ctx context.Context, inactivityDuration time.Duration) error
GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error)
CountAccountProxies(ctx context.Context, accountID string) (int64, error)
diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager.go b/management/internals/modules/reverseproxy/proxy/manager/manager.go
index 510500e0c..943766004 100644
--- a/management/internals/modules/reverseproxy/proxy/manager/manager.go
+++ b/management/internals/modules/reverseproxy/proxy/manager/manager.go
@@ -21,6 +21,7 @@ type store interface {
GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
+ GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error
GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error)
CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error)
@@ -137,6 +138,11 @@ func (m Manager) ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string
return m.store.GetClusterSupportsCrowdSec(ctx, clusterAddr)
}
+// ClusterSupportsPrivate reports whether any active proxy claims the private capability (nil = unreported).
+func (m Manager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
+ return m.store.GetClusterSupportsPrivate(ctx, clusterAddr)
+}
+
// CleanupStale removes proxies that haven't sent heartbeat in the specified duration
func (m *Manager) CleanupStale(ctx context.Context, inactivityDuration time.Duration) error {
if err := m.store.CleanupStaleProxies(ctx, inactivityDuration); err != nil {
@@ -178,4 +184,3 @@ func (m *Manager) DeleteAccountCluster(ctx context.Context, clusterAddress, acco
}
return nil
}
-
diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go
index 3436216b4..5c44470a3 100644
--- a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go
+++ b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go
@@ -15,16 +15,16 @@ import (
)
type mockStore struct {
- saveProxyFunc func(ctx context.Context, p *proxy.Proxy) error
- disconnectProxyFunc func(ctx context.Context, proxyID, sessionID string) error
- updateProxyHeartbeatFunc func(ctx context.Context, p *proxy.Proxy) error
- getActiveProxyClusterAddressesFunc func(ctx context.Context) ([]string, error)
- getActiveProxyClusterAddressesForAccFunc func(ctx context.Context, accountID string) ([]string, error)
- cleanupStaleProxiesFunc func(ctx context.Context, d time.Duration) error
- getProxyByAccountIDFunc func(ctx context.Context, accountID string) (*proxy.Proxy, error)
- countProxiesByAccountIDFunc func(ctx context.Context, accountID string) (int64, error)
- isClusterAddressConflictingFunc func(ctx context.Context, clusterAddress, accountID string) (bool, error)
- deleteAccountClusterFunc func(ctx context.Context, clusterAddress, accountID string) error
+ saveProxyFunc func(ctx context.Context, p *proxy.Proxy) error
+ disconnectProxyFunc func(ctx context.Context, proxyID, sessionID string) error
+ updateProxyHeartbeatFunc func(ctx context.Context, p *proxy.Proxy) error
+ getActiveProxyClusterAddressesFunc func(ctx context.Context) ([]string, error)
+ getActiveProxyClusterAddressesForAccFunc func(ctx context.Context, accountID string) ([]string, error)
+ cleanupStaleProxiesFunc func(ctx context.Context, d time.Duration) error
+ getProxyByAccountIDFunc func(ctx context.Context, accountID string) (*proxy.Proxy, error)
+ countProxiesByAccountIDFunc func(ctx context.Context, accountID string) (int64, error)
+ isClusterAddressConflictingFunc func(ctx context.Context, clusterAddress, accountID string) (bool, error)
+ deleteAccountClusterFunc func(ctx context.Context, clusterAddress, accountID string) error
}
func (m *mockStore) SaveProxy(ctx context.Context, p *proxy.Proxy) error {
@@ -99,6 +99,9 @@ func (m *mockStore) GetClusterRequireSubdomain(_ context.Context, _ string) *boo
func (m *mockStore) GetClusterSupportsCrowdSec(_ context.Context, _ string) *bool {
return nil
}
+func (m *mockStore) GetClusterSupportsPrivate(_ context.Context, _ string) *bool {
+ return nil
+}
func newTestManager(s store) *Manager {
meter := noop.NewMeterProvider().Meter("test")
diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go
index a0e360a1b..d2be46c9f 100644
--- a/management/internals/modules/reverseproxy/proxy/manager_mock.go
+++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go
@@ -92,6 +92,20 @@ func (mr *MockManagerMockRecorder) ClusterSupportsCrowdSec(ctx, clusterAddr inte
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCrowdSec", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCrowdSec), ctx, clusterAddr)
}
+// ClusterSupportsPrivate mocks base method.
+func (m *MockManager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ClusterSupportsPrivate", ctx, clusterAddr)
+ ret0, _ := ret[0].(*bool)
+ return ret0
+}
+
+// ClusterSupportsPrivate indicates an expected call of ClusterSupportsPrivate.
+func (mr *MockManagerMockRecorder) ClusterSupportsPrivate(ctx, clusterAddr interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsPrivate", reflect.TypeOf((*MockManager)(nil).ClusterSupportsPrivate), ctx, clusterAddr)
+}
+
// Connect mocks base method.
func (m *MockManager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, accountID *string, capabilities *Capabilities) (*Proxy, error) {
m.ctrl.T.Helper()
diff --git a/management/internals/modules/reverseproxy/proxy/proxy.go b/management/internals/modules/reverseproxy/proxy/proxy.go
index 9da7910df..4404b0d24 100644
--- a/management/internals/modules/reverseproxy/proxy/proxy.go
+++ b/management/internals/modules/reverseproxy/proxy/proxy.go
@@ -20,6 +20,9 @@ type Capabilities struct {
RequireSubdomain *bool
// SupportsCrowdsec indicates whether this proxy has CrowdSec configured.
SupportsCrowdsec *bool
+ // Private indicates whether this proxy supports inbound access via Wireguard
+ // tunnel and netbird-only authentication policies
+ Private *bool
}
// Proxy represents a reverse proxy instance
@@ -67,10 +70,9 @@ type Cluster struct {
Type ClusterType
Online bool
ConnectedProxies int
- // Capability flags. *bool because nil means "no proxy reported a
- // capability for this cluster" — the dashboard renders these as
- // unknown rather than false.
+ // *bool: nil = no proxy reported the capability; the dashboard renders that as unknown.
SupportsCustomPorts *bool
RequireSubdomain *bool
SupportsCrowdSec *bool
+ Private *bool
}
diff --git a/management/internals/modules/reverseproxy/proxytoken/handler.go b/management/internals/modules/reverseproxy/proxytoken/handler.go
index 728cdf723..ed098a6dd 100644
--- a/management/internals/modules/reverseproxy/proxytoken/handler.go
+++ b/management/internals/modules/reverseproxy/proxytoken/handler.go
@@ -37,7 +37,7 @@ func (h *handler) createToken(w http.ResponseWriter, r *http.Request) {
return
}
- ok, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Create)
+ ok, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Create)
if err != nil {
util.WriteErrorResponse("failed to validate permissions", http.StatusInternalServerError, w)
return
@@ -76,13 +76,13 @@ func (h *handler) createToken(w http.ResponseWriter, r *http.Request) {
return
}
- if err := h.store.SaveProxyAccessToken(r.Context(), &generated.ProxyAccessToken); err != nil {
+ if err := h.store.SaveProxyAccessToken(ctx, &generated.ProxyAccessToken); err != nil {
util.WriteErrorResponse("failed to save token", http.StatusInternalServerError, w)
return
}
resp := toProxyTokenCreatedResponse(generated)
- util.WriteJSONObject(r.Context(), w, resp)
+ util.WriteJSONObject(ctx, w, resp)
}
func (h *handler) listTokens(w http.ResponseWriter, r *http.Request) {
@@ -92,7 +92,7 @@ func (h *handler) listTokens(w http.ResponseWriter, r *http.Request) {
return
}
- ok, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Read)
+ ok, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Read)
if err != nil {
util.WriteErrorResponse("failed to validate permissions", http.StatusInternalServerError, w)
return
@@ -102,7 +102,7 @@ func (h *handler) listTokens(w http.ResponseWriter, r *http.Request) {
return
}
- tokens, err := h.store.GetProxyAccessTokensByAccountID(r.Context(), store.LockingStrengthNone, userAuth.AccountId)
+ tokens, err := h.store.GetProxyAccessTokensByAccountID(ctx, store.LockingStrengthNone, userAuth.AccountId)
if err != nil {
util.WriteErrorResponse("failed to list tokens", http.StatusInternalServerError, w)
return
@@ -113,7 +113,7 @@ func (h *handler) listTokens(w http.ResponseWriter, r *http.Request) {
resp = append(resp, toProxyTokenResponse(token))
}
- util.WriteJSONObject(r.Context(), w, resp)
+ util.WriteJSONObject(ctx, w, resp)
}
func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) {
@@ -123,7 +123,7 @@ func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) {
return
}
- ok, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Delete)
+ ok, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Delete)
if err != nil {
util.WriteErrorResponse("failed to validate permissions", http.StatusInternalServerError, w)
return
@@ -139,7 +139,7 @@ func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) {
return
}
- token, err := h.store.GetProxyAccessTokenByID(r.Context(), store.LockingStrengthNone, tokenID)
+ token, err := h.store.GetProxyAccessTokenByID(ctx, store.LockingStrengthNone, tokenID)
if err != nil {
if s, ok := status.FromError(err); ok && s.ErrorType == status.NotFound {
util.WriteErrorResponse("token not found", http.StatusNotFound, w)
@@ -154,12 +154,12 @@ func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) {
return
}
- if err := h.store.RevokeProxyAccessToken(r.Context(), tokenID); err != nil {
+ if err := h.store.RevokeProxyAccessToken(ctx, tokenID); err != nil {
util.WriteErrorResponse("failed to revoke token", http.StatusInternalServerError, w)
return
}
- util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
+ util.WriteJSONObject(ctx, w, util.EmptyObject{})
}
func toProxyTokenResponse(token *types.ProxyAccessToken) api.ProxyToken {
diff --git a/management/internals/modules/reverseproxy/proxytoken/handler_test.go b/management/internals/modules/reverseproxy/proxytoken/handler_test.go
index a28752909..a5b5713c6 100644
--- a/management/internals/modules/reverseproxy/proxytoken/handler_test.go
+++ b/management/internals/modules/reverseproxy/proxytoken/handler_test.go
@@ -47,7 +47,7 @@ func TestCreateToken_AccountScoped(t *testing.T) {
)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Create).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Create).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -90,7 +90,7 @@ func TestCreateToken_WithExpiration(t *testing.T) {
)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -115,7 +115,7 @@ func TestCreateToken_EmptyName(t *testing.T) {
defer ctrl.Finish()
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(true, context.Background(), nil)
h := &handler{
permissionsManager: permsMgr,
@@ -135,7 +135,7 @@ func TestCreateToken_PermissionDenied(t *testing.T) {
defer ctrl.Finish()
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(false, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(false, context.Background(), nil)
h := &handler{
permissionsManager: permsMgr,
@@ -164,7 +164,7 @@ func TestListTokens(t *testing.T) {
}, nil)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Read).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Read).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -202,7 +202,7 @@ func TestRevokeToken_Success(t *testing.T) {
mockStore.EXPECT().RevokeProxyAccessToken(gomock.Any(), "tok-1").Return(nil)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -231,7 +231,7 @@ func TestRevokeToken_WrongAccount(t *testing.T) {
}, nil)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
@@ -258,7 +258,7 @@ func TestRevokeToken_ManagementWideToken(t *testing.T) {
}, nil)
permsMgr := permissions.NewMockManager(ctrl)
- permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, nil)
+ permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil)
h := &handler{
store: mockStore,
diff --git a/management/internals/modules/reverseproxy/service/manager/api.go b/management/internals/modules/reverseproxy/service/manager/api.go
index 9d93d52ee..7298b4261 100644
--- a/management/internals/modules/reverseproxy/service/manager/api.go
+++ b/management/internals/modules/reverseproxy/service/manager/api.go
@@ -204,6 +204,7 @@ func (h *handler) getClusters(w http.ResponseWriter, r *http.Request) {
SupportsCustomPorts: c.SupportsCustomPorts,
RequireSubdomain: c.RequireSubdomain,
SupportsCrowdsec: c.SupportsCrowdSec,
+ Private: c.Private,
})
}
diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go
index ca0c5540f..c8ab4f955 100644
--- a/management/internals/modules/reverseproxy/service/manager/manager.go
+++ b/management/internals/modules/reverseproxy/service/manager/manager.go
@@ -82,6 +82,7 @@ type CapabilityProvider interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
+ ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
}
type Manager struct {
@@ -119,7 +120,7 @@ func (m *Manager) StartExposeReaper(ctx context.Context) {
// capability flags reported by its active proxies so the dashboard can
// render feature support without a second round-trip.
func (m *Manager) GetClusters(ctx context.Context, accountID, userID string) ([]proxy.Cluster, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -136,6 +137,7 @@ func (m *Manager) GetClusters(ctx context.Context, accountID, userID string) ([]
clusters[i].SupportsCustomPorts = m.capabilities.ClusterSupportsCustomPorts(ctx, clusters[i].Address)
clusters[i].RequireSubdomain = m.capabilities.ClusterRequireSubdomain(ctx, clusters[i].Address)
clusters[i].SupportsCrowdSec = m.capabilities.ClusterSupportsCrowdSec(ctx, clusters[i].Address)
+ clusters[i].Private = m.capabilities.ClusterSupportsPrivate(ctx, clusters[i].Address)
}
return clusters, nil
@@ -144,7 +146,7 @@ func (m *Manager) GetClusters(ctx context.Context, accountID, userID string) ([]
// DeleteAccountCluster removes all proxy registrations for the given cluster address
// owned by the account.
func (m *Manager) DeleteAccountCluster(ctx context.Context, accountID, userID, clusterAddress string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -156,7 +158,7 @@ func (m *Manager) DeleteAccountCluster(ctx context.Context, accountID, userID, c
}
func (m *Manager) GetAllServices(ctx context.Context, accountID, userID string) ([]*service.Service, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -208,6 +210,9 @@ func (m *Manager) replaceHostByLookup(ctx context.Context, accountID string, s *
target.Host = resource.Domain
case service.TargetTypeSubnet:
// For subnets we do not do any lookups on the resource
+ case service.TargetTypeCluster:
+ // Cluster targets carry the upstream address on target_id; the
+ // proxy resolves the destination at request time.
default:
return fmt.Errorf("unknown target type: %s", target.TargetType)
}
@@ -217,7 +222,7 @@ func (m *Manager) replaceHostByLookup(ctx context.Context, accountID string, s *
}
func (m *Manager) GetService(ctx context.Context, accountID, userID, serviceID string) (*service.Service, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -238,7 +243,7 @@ func (m *Manager) GetService(ctx context.Context, accountID, userID, serviceID s
}
func (m *Manager) CreateService(ctx context.Context, accountID, userID string, s *service.Service) (*service.Service, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -523,7 +528,7 @@ func (m *Manager) checkDomainAvailable(ctx context.Context, transaction store.St
}
func (m *Manager) UpdateService(ctx context.Context, accountID, userID string, service *service.Service) (*service.Service, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -779,6 +784,10 @@ func validateTargetReferences(ctx context.Context, transaction store.Store, acco
if err := validateResourceTarget(ctx, transaction, accountID, target); err != nil {
return err
}
+ case service.TargetTypeCluster:
+ if err := validateClusterTarget(target); err != nil {
+ return err
+ }
default:
return status.Errorf(status.InvalidArgument, "unknown target type %q for target %q", target.TargetType, target.TargetId)
}
@@ -786,6 +795,13 @@ func validateTargetReferences(ctx context.Context, transaction store.Store, acco
return nil
}
+func validateClusterTarget(target *service.Target) error {
+ if !target.Options.DirectUpstream {
+ return status.Errorf(status.InvalidArgument, "cluster target %s has direct upstream disabled", target.Host)
+ }
+ return nil
+}
+
func validatePeerTarget(ctx context.Context, transaction store.Store, accountID string, target *service.Target) error {
if _, err := transaction.GetPeerByID(ctx, store.LockingStrengthShare, accountID, target.TargetId); err != nil {
if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound {
@@ -820,7 +836,7 @@ func validateResourceTargetType(target *service.Target, resource *resourcetypes.
}
func (m *Manager) DeleteService(ctx context.Context, accountID, userID, serviceID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -860,7 +876,7 @@ func (m *Manager) DeleteService(ctx context.Context, accountID, userID, serviceI
}
func (m *Manager) DeleteAllServices(ctx context.Context, accountID, userID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -962,12 +978,14 @@ func (m *Manager) ReloadAllServicesForAccount(ctx context.Context, accountID str
return fmt.Errorf("failed to get services: %w", err)
}
+ oidcCfg := m.proxyController.GetOIDCValidationConfig()
+
for _, s := range services {
err = m.replaceHostByLookup(ctx, accountID, s)
if err != nil {
return fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err)
}
- m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster)
+ m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", oidcCfg), s.ProxyCluster)
}
return nil
diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go
index 47b8b3865..0497415b7 100644
--- a/management/internals/modules/reverseproxy/service/manager/manager_test.go
+++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go
@@ -1172,7 +1172,7 @@ func TestDeleteService_DeletesTargets(t *testing.T) {
mockPerms.EXPECT().
ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAcct.EXPECT().
StoreEvent(ctx, userID, service.ID, accountID, activity.ServiceDeleted, gomock.Any())
mockAcct.EXPECT().
@@ -1344,3 +1344,66 @@ func TestValidateSubdomainRequirement(t *testing.T) {
})
}
}
+
+func TestValidateTargetReferences_ClusterTargetSkipsLookup(t *testing.T) {
+ ctx := context.Background()
+ ctrl := gomock.NewController(t)
+ mockStore := store.NewMockStore(ctrl)
+ accountID := "test-account"
+
+ // No peer or resource lookups must be issued for cluster targets.
+ targets := []*rpservice.Target{
+ {
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: rpservice.TargetTypeCluster,
+ Options: rpservice.TargetOptions{DirectUpstream: true},
+ },
+ }
+ require.NoError(t, validateTargetReferences(ctx, mockStore, accountID, targets), "cluster target must validate without store lookups")
+}
+
+// TestValidateTargetReferences_ClusterTargetRequiresDirectUpstream pins the
+// store-side check that cluster targets must opt into the host-stack dial
+// path. Without DirectUpstream the proxy would route this target through
+// the embedded NetBird client and fail on every request.
+func TestValidateTargetReferences_ClusterTargetRequiresDirectUpstream(t *testing.T) {
+ ctx := context.Background()
+ ctrl := gomock.NewController(t)
+ mockStore := store.NewMockStore(ctrl)
+ accountID := "test-account"
+
+ targets := []*rpservice.Target{
+ {
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: rpservice.TargetTypeCluster,
+ Host: "backend.lan",
+ },
+ }
+ err := validateTargetReferences(ctx, mockStore, accountID, targets)
+ require.Error(t, err, "cluster target without direct_upstream must be rejected")
+ assert.ErrorContains(t, err, "direct upstream disabled")
+}
+
+func TestReplaceHostByLookup_SkipsClusterTarget(t *testing.T) {
+ ctx := context.Background()
+ ctrl := gomock.NewController(t)
+ mockStore := store.NewMockStore(ctrl)
+ accountID := "test-account"
+
+ mgr := &Manager{store: mockStore}
+
+ svc := &rpservice.Service{
+ ID: "svc-1",
+ AccountID: accountID,
+ Targets: []*rpservice.Target{
+ {
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: rpservice.TargetTypeCluster,
+ Host: "127.0.0.1",
+ },
+ },
+ }
+
+ require.NoError(t, mgr.replaceHostByLookup(ctx, accountID, svc), "cluster target must not trigger peer/resource lookup")
+ assert.Equal(t, "127.0.0.1", svc.Targets[0].Host, "operator-supplied host must be preserved for cluster target")
+}
diff --git a/management/internals/modules/reverseproxy/service/service.go b/management/internals/modules/reverseproxy/service/service.go
index 166a66a5f..ee1e3c8b2 100644
--- a/management/internals/modules/reverseproxy/service/service.go
+++ b/management/internals/modules/reverseproxy/service/service.go
@@ -45,10 +45,11 @@ const (
StatusCertificateFailed Status = "certificate_failed"
StatusError Status = "error"
- TargetTypePeer TargetType = "peer"
- TargetTypeHost TargetType = "host"
- TargetTypeDomain TargetType = "domain"
- TargetTypeSubnet TargetType = "subnet"
+ TargetTypePeer TargetType = "peer"
+ TargetTypeHost TargetType = "host"
+ TargetTypeDomain TargetType = "domain"
+ TargetTypeSubnet TargetType = "subnet"
+ TargetTypeCluster TargetType = "cluster"
SourcePermanent = "permanent"
SourceEphemeral = "ephemeral"
@@ -60,6 +61,11 @@ type TargetOptions struct {
SessionIdleTimeout time.Duration `json:"session_idle_timeout,omitempty"`
PathRewrite PathRewriteMode `json:"path_rewrite,omitempty"`
CustomHeaders map[string]string `gorm:"serializer:json" json:"custom_headers,omitempty"`
+ // DirectUpstream bypasses the proxy's embedded NetBird client and dials
+ // the target via the proxy host's network stack. Useful for upstreams
+ // reachable without WireGuard (public APIs, LAN services, localhost
+ // sidecars). Default false.
+ DirectUpstream bool `json:"direct_upstream,omitempty"`
}
type Target struct {
@@ -67,7 +73,7 @@ type Target struct {
AccountID string `gorm:"index:idx_target_account;not null" json:"-"`
ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"`
Path *string `json:"path,omitempty"`
- Host string `json:"host"` // the Host field is only used for subnet targets, otherwise ignored
+ Host string `json:"host"`
Port uint16 `gorm:"index:idx_target_port" json:"port"`
Protocol string `gorm:"index:idx_target_protocol" json:"protocol"`
TargetId string `gorm:"index:idx_target_id" json:"target_id"`
@@ -200,6 +206,10 @@ type Service struct {
Mode string `gorm:"default:'http'"`
ListenPort uint16
PortAutoAssigned bool
+ // Private marks the service as NetBird-only: auth via ValidateTunnelPeer against AccessGroups instead of SSO. HTTP-only.
+ Private bool
+ // AccessGroups is the group ID allowlist for inbound peers on private services. Mutually exclusive with bearer SSO.
+ AccessGroups []string `json:"access_groups,omitempty" gorm:"serializer:json"`
}
// InitNewRecord generates a new unique ID and resets metadata for a newly created
@@ -299,6 +309,12 @@ func (s *Service) ToAPIResponse() *api.Service {
Mode: &mode,
ListenPort: &listenPort,
PortAutoAssigned: &s.PortAutoAssigned,
+ Private: &s.Private,
+ }
+
+ if len(s.AccessGroups) > 0 {
+ groups := append([]string(nil), s.AccessGroups...)
+ resp.AccessGroups = &groups
}
if s.ProxyCluster != "" {
@@ -308,6 +324,7 @@ func (s *Service) ToAPIResponse() *api.Service {
return resp
}
+// ToProtoMapping converts the service into the wire format the proxy consumes.
func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConfig proxy.OIDCValidationConfig) *proto.ProxyMapping {
pathMappings := s.buildPathMappings()
@@ -349,6 +366,7 @@ func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConf
RewriteRedirects: s.RewriteRedirects,
Mode: s.Mode,
ListenPort: int32(s.ListenPort), //nolint:gosec
+ Private: s.Private,
}
if r := restrictionsToProto(s.Restrictions); r != nil {
@@ -455,7 +473,8 @@ func pathRewriteToProto(mode PathRewriteMode) proto.PathRewriteMode {
}
func targetOptionsToAPI(opts TargetOptions) *api.ServiceTargetOptions {
- if !opts.SkipTLSVerify && opts.RequestTimeout == 0 && opts.SessionIdleTimeout == 0 && opts.PathRewrite == "" && len(opts.CustomHeaders) == 0 {
+ if !opts.SkipTLSVerify && opts.RequestTimeout == 0 && opts.SessionIdleTimeout == 0 &&
+ opts.PathRewrite == "" && len(opts.CustomHeaders) == 0 && !opts.DirectUpstream {
return nil
}
apiOpts := &api.ServiceTargetOptions{}
@@ -477,17 +496,22 @@ func targetOptionsToAPI(opts TargetOptions) *api.ServiceTargetOptions {
if len(opts.CustomHeaders) > 0 {
apiOpts.CustomHeaders = &opts.CustomHeaders
}
+ if opts.DirectUpstream {
+ apiOpts.DirectUpstream = &opts.DirectUpstream
+ }
return apiOpts
}
func targetOptionsToProto(opts TargetOptions) *proto.PathTargetOptions {
- if !opts.SkipTLSVerify && opts.PathRewrite == "" && opts.RequestTimeout == 0 && len(opts.CustomHeaders) == 0 {
+ if !opts.SkipTLSVerify && opts.PathRewrite == "" && opts.RequestTimeout == 0 &&
+ len(opts.CustomHeaders) == 0 && !opts.DirectUpstream {
return nil
}
popts := &proto.PathTargetOptions{
- SkipTlsVerify: opts.SkipTLSVerify,
- PathRewrite: pathRewriteToProto(opts.PathRewrite),
- CustomHeaders: opts.CustomHeaders,
+ SkipTlsVerify: opts.SkipTLSVerify,
+ PathRewrite: pathRewriteToProto(opts.PathRewrite),
+ CustomHeaders: opts.CustomHeaders,
+ DirectUpstream: opts.DirectUpstream,
}
if opts.RequestTimeout != 0 {
popts.RequestTimeout = durationpb.New(opts.RequestTimeout)
@@ -537,6 +561,9 @@ func targetOptionsFromAPI(idx int, o *api.ServiceTargetOptions) (TargetOptions,
if o.CustomHeaders != nil {
opts.CustomHeaders = *o.CustomHeaders
}
+ if o.DirectUpstream != nil {
+ opts.DirectUpstream = *o.DirectUpstream
+ }
return opts, nil
}
@@ -551,6 +578,14 @@ func (s *Service) FromAPIRequest(req *api.ServiceRequest, accountID string) erro
if req.ListenPort != nil {
s.ListenPort = uint16(*req.ListenPort) //nolint:gosec
}
+ if req.Private != nil {
+ s.Private = *req.Private
+ }
+ if req.AccessGroups != nil {
+ s.AccessGroups = append([]string(nil), *req.AccessGroups...)
+ } else {
+ s.AccessGroups = nil
+ }
targets, err := targetsFromAPI(accountID, req.Targets)
if err != nil {
@@ -740,6 +775,9 @@ func (s *Service) Validate() error {
if err := validateAccessRestrictions(&s.Restrictions); err != nil {
return err
}
+ if err := s.validatePrivateRequirements(); err != nil {
+ return err
+ }
switch s.Mode {
case ModeHTTP:
@@ -753,6 +791,23 @@ func (s *Service) Validate() error {
}
}
+// validatePrivateRequirements enforces the private-service contract: HTTP mode, ≥1 access group, no bearer auth.
+func (s *Service) validatePrivateRequirements() error {
+ if !s.Private {
+ return nil
+ }
+ if s.Mode != "" && s.Mode != ModeHTTP {
+ return fmt.Errorf("private services only support HTTP mode, got %q", s.Mode)
+ }
+ if len(s.AccessGroups) == 0 {
+ return errors.New("private services require at least one access group")
+ }
+ if s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled {
+ return errors.New("private services cannot enable bearer auth (SSO): NetBird-only access and SSO are mutually exclusive")
+ }
+ return nil
+}
+
func (s *Service) validateHTTPMode() error {
if s.Domain == "" {
return errors.New("service domain is required")
@@ -799,11 +854,21 @@ func (s *Service) validateHTTPTargets() error {
for i, target := range s.Targets {
switch target.TargetType {
case TargetTypePeer, TargetTypeHost, TargetTypeDomain:
- // host field will be ignored
+ // Host is normally overwritten by replaceHostByLookup with the
+ // resolved peer IP / resource address; operator-supplied values
+ // are honored only when DirectUpstream is set. Validate the
+ // override here so misconfigured hosts fail fast at API time.
+ if err := validateDirectUpstreamHost(i, target); err != nil {
+ return err
+ }
case TargetTypeSubnet:
if target.Host == "" {
return fmt.Errorf("target %d has empty host but target_type is %q", i, target.TargetType)
}
+ case TargetTypeCluster:
+ if err := validateClusterTarget(i, target); err != nil {
+ return err
+ }
default:
return fmt.Errorf("target %d has invalid target_type %q", i, target.TargetType)
}
@@ -821,25 +886,71 @@ func (s *Service) validateHTTPTargets() error {
return nil
}
+// validateClusterTarget cluster targets should not have empty hosts and should have direct upstream enabled.
+func validateClusterTarget(idx int, target *Target) error {
+ host := strings.TrimSpace(target.Host)
+ if host == "" {
+ return fmt.Errorf("target %d: has empty host", idx)
+ }
+ if !target.Options.DirectUpstream {
+ return fmt.Errorf("target %d: %s has direct upstream disabled", idx, target.Host)
+ }
+ return validateDirectUpstreamHost(idx, target)
+}
+
+// validateDirectUpstreamHost validates the operator-supplied Host on a
+// peer/host/domain target when DirectUpstream is set. Empty Host is
+// allowed — the lookup fills in the default peer IP / resource address.
+// Without DirectUpstream the Host value is silently overwritten by
+// replaceHostByLookup, so we don't validate it (preserves the historical
+// behaviour where APIs accepted any value and dropped it). Non-empty
+// Host with DirectUpstream must look like a hostname or IP and must
+// not carry a port (port lives on Target.Port).
+func validateDirectUpstreamHost(idx int, target *Target) error {
+ if !target.Options.DirectUpstream {
+ return nil
+ }
+ host := strings.TrimSpace(target.Host)
+ if host == "" {
+ return nil
+ }
+ if strings.ContainsAny(host, " \t/") {
+ return fmt.Errorf("target %d: host %q contains invalid characters", idx, host)
+ }
+ if _, _, err := net.SplitHostPort(host); err == nil {
+ return fmt.Errorf("target %d: host %q must not include a port (set target.port instead)", idx, host)
+ }
+ return nil
+}
+
func (s *Service) validateL4Target(target *Target) error {
// L4 services have a single target; per-target disable is meaningless
// (use the service-level Enabled flag instead). Force it on so that
// buildPathMappings always includes the target in the proto.
target.Enabled = true
- if target.Port == 0 {
- return errors.New("target port is required for L4 services")
- }
if target.TargetId == "" {
return errors.New("target_id is required for L4 services")
}
+ // Cluster targets resolve their upstream host:port from the target's
+ // own Host/Port fields just like the other L4 types — buildPathMappings
+ // emits net.JoinHostPort(target.Host, target.Port) for every L4
+ // target, so allowing port=0 here would let ":0" reach the proxy.
+ if target.Port == 0 {
+ return errors.New("target port is required for L4 services")
+ }
switch target.TargetType {
case TargetTypePeer, TargetTypeHost, TargetTypeDomain:
- // OK
+ if err := validateDirectUpstreamHost(0, target); err != nil {
+ return err
+ }
case TargetTypeSubnet:
if target.Host == "" {
return errors.New("target host is required for subnet targets")
}
+ case TargetTypeCluster:
+ // target_id carries the cluster address; the proxy resolves
+ // the upstream at request time.
default:
return fmt.Errorf("invalid target_type %q for L4 service", target.TargetType)
}
@@ -1174,6 +1285,11 @@ func (s *Service) Copy() *Service {
}
}
+ var accessGroups []string
+ if len(s.AccessGroups) > 0 {
+ accessGroups = append([]string(nil), s.AccessGroups...)
+ }
+
return &Service{
ID: s.ID,
AccountID: s.AccountID,
@@ -1195,6 +1311,8 @@ func (s *Service) Copy() *Service {
Mode: s.Mode,
ListenPort: s.ListenPort,
PortAutoAssigned: s.PortAutoAssigned,
+ Private: s.Private,
+ AccessGroups: accessGroups,
}
}
diff --git a/management/internals/modules/reverseproxy/service/service_test.go b/management/internals/modules/reverseproxy/service/service_test.go
index f1349ff65..a149ac609 100644
--- a/management/internals/modules/reverseproxy/service/service_test.go
+++ b/management/internals/modules/reverseproxy/service/service_test.go
@@ -12,6 +12,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
"github.com/netbirdio/netbird/shared/hash/argon2id"
+ "github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -1116,3 +1117,201 @@ func TestValidate_HeaderAuths(t *testing.T) {
assert.Contains(t, err.Error(), "exceeds maximum length")
})
}
+
+func TestValidate_HTTPClusterTarget(t *testing.T) {
+ rp := validProxy()
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "http",
+ Host: "backend.lan",
+ Options: TargetOptions{DirectUpstream: true},
+ Enabled: true,
+ }}
+ require.NoError(t, rp.Validate(), "HTTP cluster target with target_id, host, and direct_upstream must validate")
+}
+
+func TestValidate_HTTPClusterTarget_RequiresTargetId(t *testing.T) {
+ rp := validProxy()
+ rp.Targets = []*Target{{
+ TargetType: TargetTypeCluster,
+ Protocol: "http",
+ Host: "backend.lan",
+ Options: TargetOptions{DirectUpstream: true},
+ Enabled: true,
+ }}
+ assert.ErrorContains(t, rp.Validate(), "empty target_id", "cluster target must reject empty target_id")
+}
+
+// TestValidate_HTTPClusterTarget_RequiresHost pins the new cluster-target
+// rule that operator-supplied Host is mandatory: cluster targets dial the
+// upstream via the host network stack (direct_upstream is implied), so an
+// empty Host leaves the proxy with nothing to dial.
+func TestValidate_HTTPClusterTarget_RequiresHost(t *testing.T) {
+ rp := validProxy()
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "http",
+ Options: TargetOptions{DirectUpstream: true},
+ Enabled: true,
+ }}
+ assert.ErrorContains(t, rp.Validate(), "empty host", "cluster target must reject empty host")
+}
+
+// TestValidate_HTTPClusterTarget_RequiresDirectUpstream pins the second
+// half of the cluster-target rule: DirectUpstream must be true so the
+// stdlib transport branch in MultiTransport is taken. Without it the
+// embedded NetBird client would try to dial the cluster address through
+// the WG tunnel, which is the wrong network for a cluster upstream.
+func TestValidate_HTTPClusterTarget_RequiresDirectUpstream(t *testing.T) {
+ rp := validProxy()
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "http",
+ Host: "backend.lan",
+ Enabled: true,
+ }}
+ assert.ErrorContains(t, rp.Validate(), "direct upstream disabled", "cluster target must reject direct_upstream=false")
+}
+
+// TestValidate_L4ClusterTarget_RequiresPort confirms that an L4 cluster
+// target without an explicit port is rejected. buildPathMappings emits
+// net.JoinHostPort(target.Host, target.Port) for every L4 target — so
+// allowing port=0 would let the proxy ship ":0" upstreams. The port
+// requirement is the same as every other L4 target type.
+func TestValidate_L4ClusterTarget_RequiresPort(t *testing.T) {
+ rp := validProxy()
+ rp.Mode = ModeTCP
+ rp.ListenPort = 9000
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "tcp",
+ Enabled: true,
+ }}
+ assert.ErrorContains(t, rp.Validate(), "port is required",
+ "L4 cluster target must require an explicit port like other L4 target types")
+
+ rp.Targets[0].Port = 5432
+ rp.Targets[0].Host = "db.lan"
+ require.NoError(t, rp.Validate(), "L4 cluster target with host:port must validate")
+}
+
+func TestService_Copy_RoundtripsPrivate(t *testing.T) {
+ svc := validProxy()
+ svc.Private = true
+ svc.AccessGroups = []string{"grp-admins", "grp-ops"}
+ cp := svc.Copy()
+ require.NotNil(t, cp)
+ assert.True(t, cp.Private)
+ assert.Equal(t, []string{"grp-admins", "grp-ops"}, cp.AccessGroups)
+
+ cp.Private = false
+ assert.True(t, svc.Private)
+
+ cp.AccessGroups[0] = "grp-other"
+ assert.Equal(t, []string{"grp-admins", "grp-ops"}, svc.AccessGroups)
+}
+
+func TestService_APIRoundtrip_Private(t *testing.T) {
+ enabled := true
+ private := true
+ accessGroups := []string{"grp-admins"}
+ targets := []api.ServiceTarget{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: api.ServiceTargetTargetType("cluster"),
+ Protocol: "http",
+ Port: 80,
+ Enabled: true,
+ }}
+ req := &api.ServiceRequest{
+ Name: "svc-private",
+ Domain: "myapp.eu.proxy.netbird.io",
+ Enabled: enabled,
+ Private: &private,
+ AccessGroups: &accessGroups,
+ Targets: &targets,
+ }
+
+ svc := &Service{}
+ require.NoError(t, svc.FromAPIRequest(req, "acc-1"))
+ assert.True(t, svc.Private)
+ assert.Equal(t, []string{"grp-admins"}, svc.AccessGroups)
+
+ resp := svc.ToAPIResponse()
+ require.NotNil(t, resp.Private)
+ assert.True(t, *resp.Private)
+ require.NotNil(t, resp.AccessGroups)
+ assert.Equal(t, []string{"grp-admins"}, *resp.AccessGroups)
+}
+
+func TestValidate_Private_RequiresAccessGroups(t *testing.T) {
+ rp := validProxy()
+ rp.Private = true
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "http",
+ Host: "backend.lan",
+ Options: TargetOptions{DirectUpstream: true},
+ Enabled: true,
+ }}
+ assert.ErrorContains(t, rp.Validate(), "access group")
+}
+
+func TestValidate_Private_RejectsBearerAuth(t *testing.T) {
+ rp := validProxy()
+ rp.Private = true
+ rp.AccessGroups = []string{"grp-admins"}
+ rp.Auth.BearerAuth = &BearerAuthConfig{
+ Enabled: true,
+ DistributionGroups: []string{"grp-sso"},
+ }
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "http",
+ Host: "backend.lan",
+ Options: TargetOptions{DirectUpstream: true},
+ Enabled: true,
+ }}
+ assert.ErrorContains(t, rp.Validate(), "mutually exclusive")
+}
+
+func TestValidate_Private_AcceptsNonClusterTargets(t *testing.T) {
+ rp := validProxy()
+ rp.Private = true
+ rp.AccessGroups = []string{"grp-admins"}
+ require.NoError(t, rp.Validate())
+}
+
+func TestValidate_Private_AcceptsClusterTargetWithAccessGroups(t *testing.T) {
+ rp := validProxy()
+ rp.Private = true
+ rp.AccessGroups = []string{"grp-admins"}
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "http",
+ Host: "backend.lan",
+ Options: TargetOptions{DirectUpstream: true},
+ Enabled: true,
+ }}
+ require.NoError(t, rp.Validate())
+}
+
+func TestValidate_Private_RejectsNonHTTPMode(t *testing.T) {
+ rp := validProxy()
+ rp.Private = true
+ rp.AccessGroups = []string{"grp-admins"}
+ rp.Mode = ModeTCP
+ rp.Targets = []*Target{{
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: TargetTypeCluster,
+ Protocol: "tcp",
+ Enabled: true,
+ }}
+ assert.ErrorContains(t, rp.Validate(), "HTTP")
+}
diff --git a/management/internals/modules/reverseproxy/sessionkey/sessionkey.go b/management/internals/modules/reverseproxy/sessionkey/sessionkey.go
index aacbe5dca..1fb6a323d 100644
--- a/management/internals/modules/reverseproxy/sessionkey/sessionkey.go
+++ b/management/internals/modules/reverseproxy/sessionkey/sessionkey.go
@@ -20,6 +20,20 @@ type KeyPair struct {
type Claims struct {
jwt.RegisteredClaims
Method auth.Method `json:"method"`
+ // Email is the calling user's email address. Carried so the
+ // proxy can stamp identity on upstream requests (e.g.
+ // x-litellm-end-user-id) without an extra management
+ // round-trip on every cookie-bearing request.
+ Email string `json:"email,omitempty"`
+ // Groups carries the user's group IDs so the proxy can stamp them
+ // onto upstream requests (X-NetBird-Groups) from the cookie path
+ // without an extra management round-trip.
+ Groups []string `json:"groups,omitempty"`
+ // GroupNames carries the human-readable display names for the ids
+ // in Groups, ordered identically (positional pairing). Slice may be
+ // shorter than Groups for tokens minted before names were
+ // resolvable; the consumer falls back to ids for missing positions.
+ GroupNames []string `json:"group_names,omitempty"`
}
func GenerateKeyPair() (*KeyPair, error) {
@@ -34,7 +48,13 @@ func GenerateKeyPair() (*KeyPair, error) {
}, nil
}
-func SignToken(privKeyB64, userID, domain string, method auth.Method, expiration time.Duration) (string, error) {
+// SignToken mints a session JWT for the given user and domain. email,
+// groups, and groupNames, when non-empty, are embedded so the proxy can
+// authorise and stamp identity for policy-aware middlewares without a
+// management round-trip on every cookie-bearing request. groupNames
+// pairs positionally with groups; pass nil when names couldn't be
+// resolved.
+func SignToken(privKeyB64, userID, email, domain string, method auth.Method, groups, groupNames []string, expiration time.Duration) (string, error) {
privKeyBytes, err := base64.StdEncoding.DecodeString(privKeyB64)
if err != nil {
return "", fmt.Errorf("decode private key: %w", err)
@@ -56,7 +76,10 @@ func SignToken(privKeyB64, userID, domain string, method auth.Method, expiration
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
- Method: method,
+ Method: method,
+ Email: email,
+ Groups: append([]string(nil), groups...),
+ GroupNames: append([]string(nil), groupNames...),
}
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
diff --git a/management/internals/modules/zones/manager/manager.go b/management/internals/modules/zones/manager/manager.go
index 439671e65..d5348d3d0 100644
--- a/management/internals/modules/zones/manager/manager.go
+++ b/management/internals/modules/zones/manager/manager.go
@@ -32,7 +32,7 @@ func NewManager(store store.Store, accountManager account.Manager, permissionsMa
}
func (m *managerImpl) GetAllZones(ctx context.Context, accountID, userID string) ([]*zones.Zone, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -44,7 +44,7 @@ func (m *managerImpl) GetAllZones(ctx context.Context, accountID, userID string)
}
func (m *managerImpl) GetZone(ctx context.Context, accountID, userID, zoneID string) (*zones.Zone, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -56,7 +56,7 @@ func (m *managerImpl) GetZone(ctx context.Context, accountID, userID, zoneID str
}
func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string, zone *zones.Zone) (*zones.Zone, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -103,7 +103,7 @@ func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string,
}
func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string, updatedZone *zones.Zone) (*zones.Zone, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -151,7 +151,7 @@ func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string,
}
func (m *managerImpl) DeleteZone(ctx context.Context, accountID, userID, zoneID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/internals/modules/zones/manager/manager_test.go b/management/internals/modules/zones/manager/manager_test.go
index b45ec7874..29e7e8677 100644
--- a/management/internals/modules/zones/manager/manager_test.go
+++ b/management/internals/modules/zones/manager/manager_test.go
@@ -79,7 +79,7 @@ func TestManagerImpl_GetAllZones(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.GetAllZones(ctx, testAccountID, testUserID)
require.NoError(t, err)
@@ -95,7 +95,7 @@ func TestManagerImpl_GetAllZones(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.GetAllZones(ctx, testAccountID, testUserID)
require.Error(t, err)
@@ -112,7 +112,7 @@ func TestManagerImpl_GetAllZones(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, status.Errorf(status.Internal, "permission check failed"))
+ Return(false, ctx, status.Errorf(status.Internal, "permission check failed"))
result, err := manager.GetAllZones(ctx, testAccountID, testUserID)
require.Error(t, err)
@@ -134,7 +134,7 @@ func TestManagerImpl_GetZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.GetZone(ctx, testAccountID, testUserID, zone.ID)
require.NoError(t, err)
@@ -150,7 +150,7 @@ func TestManagerImpl_GetZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.GetZone(ctx, testAccountID, testUserID, testZoneID)
require.Error(t, err)
@@ -179,7 +179,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
assert.Equal(t, testUserID, initiatorID)
@@ -212,7 +212,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -235,7 +235,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -261,7 +261,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -293,7 +293,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -319,7 +319,7 @@ func TestManagerImpl_CreateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone)
require.Error(t, err)
@@ -354,7 +354,7 @@ func TestManagerImpl_UpdateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCalled := false
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -394,7 +394,7 @@ func TestManagerImpl_UpdateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone)
require.Error(t, err)
@@ -418,7 +418,7 @@ func TestManagerImpl_UpdateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone)
require.Error(t, err)
@@ -441,7 +441,7 @@ func TestManagerImpl_UpdateZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone)
require.Error(t, err)
@@ -471,7 +471,7 @@ func TestManagerImpl_DeleteZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCallCount := 0
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -503,7 +503,7 @@ func TestManagerImpl_DeleteZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCalled := false
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -529,7 +529,7 @@ func TestManagerImpl_DeleteZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(false, nil)
+ Return(false, ctx, nil)
err := manager.DeleteZone(ctx, testAccountID, testUserID, testZoneID)
require.Error(t, err)
@@ -545,7 +545,7 @@ func TestManagerImpl_DeleteZone(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
err := manager.DeleteZone(ctx, testAccountID, testUserID, "non-existent-zone")
require.Error(t, err)
diff --git a/management/internals/modules/zones/records/manager/manager.go b/management/internals/modules/zones/records/manager/manager.go
index 7458b41db..b041aca30 100644
--- a/management/internals/modules/zones/records/manager/manager.go
+++ b/management/internals/modules/zones/records/manager/manager.go
@@ -32,7 +32,7 @@ func NewManager(store store.Store, accountManager account.Manager, permissionsMa
}
func (m *managerImpl) GetAllRecords(ctx context.Context, accountID, userID, zoneID string) ([]*records.Record, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -44,7 +44,7 @@ func (m *managerImpl) GetAllRecords(ctx context.Context, accountID, userID, zone
}
func (m *managerImpl) GetRecord(ctx context.Context, accountID, userID, zoneID, recordID string) (*records.Record, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -56,7 +56,7 @@ func (m *managerImpl) GetRecord(ctx context.Context, accountID, userID, zoneID,
}
func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneID string, record *records.Record) (*records.Record, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -102,7 +102,7 @@ func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneI
}
func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneID string, updatedRecord *records.Record) (*records.Record, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -161,7 +161,7 @@ func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneI
}
func (m *managerImpl) DeleteRecord(ctx context.Context, accountID, userID, zoneID, recordID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/internals/modules/zones/records/manager/manager_test.go b/management/internals/modules/zones/records/manager/manager_test.go
index 0a962e0f4..a5f48c4a9 100644
--- a/management/internals/modules/zones/records/manager/manager_test.go
+++ b/management/internals/modules/zones/records/manager/manager_test.go
@@ -80,7 +80,7 @@ func TestManagerImpl_GetAllRecords(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID)
require.NoError(t, err)
@@ -96,7 +96,7 @@ func TestManagerImpl_GetAllRecords(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID)
require.Error(t, err)
@@ -113,7 +113,7 @@ func TestManagerImpl_GetAllRecords(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, status.Errorf(status.Internal, "permission check failed"))
+ Return(false, ctx, status.Errorf(status.Internal, "permission check failed"))
result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID)
require.Error(t, err)
@@ -135,7 +135,7 @@ func TestManagerImpl_GetRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.GetRecord(ctx, testAccountID, testUserID, zone.ID, record.ID)
require.NoError(t, err)
@@ -153,7 +153,7 @@ func TestManagerImpl_GetRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.GetRecord(ctx, testAccountID, testUserID, zone.ID, testRecordID)
require.Error(t, err)
@@ -181,7 +181,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
assert.Equal(t, testUserID, initiatorID)
@@ -215,7 +215,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
assert.Equal(t, testUserID, initiatorID)
@@ -244,7 +244,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
assert.Equal(t, testUserID, initiatorID)
@@ -273,7 +273,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
require.Error(t, err)
@@ -297,7 +297,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
require.Error(t, err)
@@ -323,7 +323,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
require.Error(t, err)
@@ -349,7 +349,7 @@ func TestManagerImpl_CreateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord)
require.Error(t, err)
@@ -380,7 +380,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCalled := false
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -418,7 +418,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
// Event should be stored
@@ -445,7 +445,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(false, nil)
+ Return(false, ctx, nil)
result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord)
require.Error(t, err)
@@ -470,7 +470,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord)
require.Error(t, err)
@@ -500,7 +500,7 @@ func TestManagerImpl_UpdateRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update).
- Return(true, nil)
+ Return(true, ctx, nil)
result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord)
require.Error(t, err)
@@ -523,7 +523,7 @@ func TestManagerImpl_DeleteRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
storeEventCalled := false
mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) {
@@ -549,7 +549,7 @@ func TestManagerImpl_DeleteRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(false, nil)
+ Return(false, ctx, nil)
err := manager.DeleteRecord(ctx, testAccountID, testUserID, zone.ID, testRecordID)
require.Error(t, err)
@@ -565,7 +565,7 @@ func TestManagerImpl_DeleteRecord(t *testing.T) {
mockPermissionsManager.EXPECT().
ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete).
- Return(true, nil)
+ Return(true, ctx, nil)
err := manager.DeleteRecord(ctx, testAccountID, testUserID, zone.ID, "non-existent-record")
require.Error(t, err)
diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go
index 7c655f020..46e475143 100644
--- a/management/internals/server/boot.go
+++ b/management/internals/server/boot.go
@@ -10,8 +10,10 @@ import (
"slices"
"time"
+ "github.com/gorilla/mux"
grpcMiddleware "github.com/grpc-ecosystem/go-grpc-middleware/v2"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip"
+ "github.com/rs/cors"
"github.com/rs/xid"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
@@ -19,7 +21,6 @@ import (
"google.golang.org/grpc/keepalive"
cachestore "github.com/eko/gocache/lib/v4/store"
- "github.com/netbirdio/management-integrations/integrations"
"github.com/netbirdio/netbird/encryption"
"github.com/netbirdio/netbird/formatter/hook"
@@ -27,16 +28,20 @@ import (
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/activity"
+ activitystore "github.com/netbirdio/netbird/management/server/activity/store"
nbcache "github.com/netbirdio/netbird/management/server/cache"
nbContext "github.com/netbirdio/netbird/management/server/context"
nbhttp "github.com/netbirdio/netbird/management/server/http"
"github.com/netbirdio/netbird/management/server/http/middleware"
+ "github.com/netbirdio/netbird/management/server/idp"
"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/util/crypt"
)
+const apiPrefix = "/api"
+
var (
kaep = keepalive.EnforcementPolicy{
MinTime: 15 * time.Second,
@@ -94,12 +99,17 @@ func (s *BaseServer) Store() store.Store {
func (s *BaseServer) EventStore() activity.Store {
return Create(s, func() activity.Store {
- integrationMetrics, err := integrations.InitIntegrationMetrics(context.Background(), s.Metrics())
- if err != nil {
- log.Fatalf("failed to initialize integration metrics: %v", err)
+ var err error
+ key := s.Config.DataStoreEncryptionKey
+ if key == "" {
+ log.Debugf("generate new activity store encryption key")
+ key, err = crypt.GenerateKey()
+ if err != nil {
+ log.Fatalf("failed to generate event store encryption key: %v", err)
+ }
}
- eventStore, _, err := integrations.InitEventStore(context.Background(), s.Config.Datadir, s.Config.DataStoreEncryptionKey, integrationMetrics)
+ eventStore, err := activitystore.NewSqlStore(context.Background(), s.Config.Datadir, key)
if err != nil {
log.Fatalf("failed to initialize event store: %v", err)
}
@@ -110,7 +120,7 @@ func (s *BaseServer) EventStore() activity.Store {
func (s *BaseServer) APIHandler() http.Handler {
return Create(s, func() http.Handler {
- httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.IntegratedValidator(), s.ProxyController(), s.PermissionsManager(), s.PeersManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter())
+ httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.Router(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter(), s.IsValidChildAccount)
if err != nil {
log.Fatalf("failed to create API handler: %v", err)
}
@@ -118,6 +128,22 @@ func (s *BaseServer) APIHandler() http.Handler {
})
}
+// IDPHandler returns the HTTP handler for the embedded IdP (Dex), or nil if
+// the deployment isn't using the embedded variant.
+func (s *BaseServer) IDPHandler() http.Handler {
+ embeddedIdP, ok := s.IdpManager().(*idp.EmbeddedIdPManager)
+ if !ok || embeddedIdP == nil {
+ return nil
+ }
+ return cors.AllowAll().Handler(embeddedIdP.Handler())
+}
+
+func (s *BaseServer) Router() *mux.Router {
+ return Create(s, func() *mux.Router {
+ return mux.NewRouter().PathPrefix(apiPrefix).Subrouter()
+ })
+}
+
func (s *BaseServer) RateLimiter() *middleware.APIRateLimiter {
return Create(s, func() *middleware.APIRateLimiter {
cfg, enabled := middleware.RateLimiterConfigFromEnv()
diff --git a/management/internals/server/controllers.go b/management/internals/server/controllers.go
index 794c3ebe0..1b2556809 100644
--- a/management/internals/server/controllers.go
+++ b/management/internals/server/controllers.go
@@ -19,6 +19,7 @@ import (
"github.com/netbirdio/netbird/management/server"
"github.com/netbirdio/netbird/management/server/auth"
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
+ "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"
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
@@ -38,7 +39,7 @@ func (s *BaseServer) JobManager() *job.Manager {
func (s *BaseServer) IntegratedValidator() integrated_validator.IntegratedValidator {
return Create(s, func() integrated_validator.IntegratedValidator {
- integratedPeerValidator, err := integrations.NewIntegratedValidator(
+ integratedPeerValidator, err := validator.NewIntegratedValidator(
context.Background(),
s.PeersManager(),
s.SettingsManager(),
diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go
index ea94245d5..a70da855a 100644
--- a/management/internals/server/modules.go
+++ b/management/internals/server/modules.go
@@ -57,13 +57,7 @@ func (s *BaseServer) GeoLocationManager() geolocation.Geolocation {
func (s *BaseServer) PermissionsManager() permissions.Manager {
return Create(s, func() permissions.Manager {
- manager := integrations.InitPermissionsManager(s.Store(), s.Metrics().GetMeter())
-
- s.AfterInit(func(s *BaseServer) {
- manager.SetAccountManager(s.AccountManager())
- })
-
- return manager
+ return permissions.NewManager(s.Store())
})
}
@@ -153,7 +147,6 @@ func (s *BaseServer) IdpManager() idp.Manager {
return idpManager
}
-
return nil
})
}
@@ -235,3 +228,7 @@ func (s *BaseServer) ReverseProxyDomainManager() *manager.Manager {
return &m
})
}
+
+func (s *BaseServer) IsValidChildAccount(_ context.Context, _, _, _ string) bool {
+ return false
+}
diff --git a/management/internals/server/server.go b/management/internals/server/server.go
index 9b8716da1..9411073ac 100644
--- a/management/internals/server/server.go
+++ b/management/internals/server/server.go
@@ -34,6 +34,8 @@ const (
ManagementLegacyPort = 33073
// DefaultSelfHostedDomain is the default domain used for self-hosted fresh installs.
DefaultSelfHostedDomain = "netbird.selfhosted"
+
+ ContainerKeyBaseServer = "baseServer"
)
type Server interface {
@@ -91,7 +93,7 @@ type Config struct {
// NewServer initializes and configures a new Server instance
func NewServer(cfg *Config) *BaseServer {
- return &BaseServer{
+ s := &BaseServer{
Config: cfg.NbConfig,
container: make(map[string]any),
dnsDomain: cfg.DNSDomain,
@@ -104,6 +106,9 @@ func NewServer(cfg *Config) *BaseServer {
mgmtMetricsPort: cfg.MgmtMetricsPort,
autoResolveDomains: cfg.AutoResolveDomains,
}
+ s.container[ContainerKeyBaseServer] = s
+
+ return s
}
func (s *BaseServer) AfterInit(fn func(s *BaseServer)) {
@@ -117,7 +122,7 @@ func (s *BaseServer) Start(ctx context.Context) error {
s.errCh = make(chan error, 4)
if s.autoResolveDomains {
- s.resolveDomains(srvCtx)
+ s.ResolveDomains(srvCtx)
}
s.PeersManager()
@@ -188,7 +193,7 @@ func (s *BaseServer) Start(ctx context.Context) error {
log.WithContext(srvCtx).Infof("running gRPC backward compatibility server: %s", compatListener.Addr().String())
}
- rootHandler := s.handlerFunc(srvCtx, s.GRPCServer(), s.APIHandler(), s.Metrics().GetMeter())
+ rootHandler := s.handlerFunc(srvCtx, s.GRPCServer(), s.APIHandler(), s.IDPHandler(), s.Metrics().GetMeter())
switch {
case s.certManager != nil:
// a call to certManager.Listener() always creates a new listener so we do it once
@@ -299,7 +304,7 @@ func (s *BaseServer) SetHandlerFunc(handler http.Handler) {
log.Tracef("custom handler set successfully")
}
-func (s *BaseServer) handlerFunc(_ context.Context, gRPCHandler *grpc.Server, httpHandler http.Handler, meter metric.Meter) http.Handler {
+func (s *BaseServer) handlerFunc(_ context.Context, gRPCHandler *grpc.Server, httpHandler http.Handler, idpHandler http.Handler, meter metric.Meter) http.Handler {
// Check if a custom handler was set (for multiplexing additional services)
if customHandler, ok := s.GetContainer("customHandler"); ok {
if handler, ok := customHandler.(http.Handler); ok {
@@ -318,6 +323,8 @@ func (s *BaseServer) handlerFunc(_ context.Context, gRPCHandler *grpc.Server, ht
gRPCHandler.ServeHTTP(writer, request)
case request.URL.Path == wsproxy.ProxyPath+wsproxy.ManagementComponent:
wsProxy.Handler().ServeHTTP(writer, request)
+ case idpHandler != nil && strings.HasPrefix(request.URL.Path, "/oauth2"):
+ idpHandler.ServeHTTP(writer, request)
default:
httpHandler.ServeHTTP(writer, request)
}
@@ -391,10 +398,10 @@ func (s *BaseServer) serveGRPCWithHTTP(ctx context.Context, listener net.Listene
}()
}
-// resolveDomains determines dnsDomain and mgmtSingleAccModeDomain based on store state.
+// ResolveDomains determines dnsDomain and mgmtSingleAccModeDomain based on store state.
// Fresh installs use the default self-hosted domain, while existing installs reuse the
// persisted account domain to keep addressing stable across config changes.
-func (s *BaseServer) resolveDomains(ctx context.Context) {
+func (s *BaseServer) ResolveDomains(ctx context.Context) {
st := s.Store()
setDefault := func(logMsg string, args ...any) {
diff --git a/management/internals/server/server_resolve_domains_test.go b/management/internals/server/server_resolve_domains_test.go
index db1d7e8ca..ba9eb3f74 100644
--- a/management/internals/server/server_resolve_domains_test.go
+++ b/management/internals/server/server_resolve_domains_test.go
@@ -22,7 +22,7 @@ func TestResolveDomains_FreshInstallUsesDefault(t *testing.T) {
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
Inject[store.Store](srv, mockStore)
- srv.resolveDomains(context.Background())
+ srv.ResolveDomains(context.Background())
require.Equal(t, DefaultSelfHostedDomain, srv.dnsDomain)
require.Equal(t, DefaultSelfHostedDomain, srv.mgmtSingleAccModeDomain)
@@ -40,7 +40,7 @@ func TestResolveDomains_ExistingInstallUsesPersistedDomain(t *testing.T) {
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
Inject[store.Store](srv, mockStore)
- srv.resolveDomains(context.Background())
+ srv.ResolveDomains(context.Background())
require.Equal(t, "vpn.mycompany.com", srv.dnsDomain)
require.Equal(t, "vpn.mycompany.com", srv.mgmtSingleAccModeDomain)
@@ -56,7 +56,7 @@ func TestResolveDomains_StoreErrorFallsBackToDefault(t *testing.T) {
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
Inject[store.Store](srv, mockStore)
- srv.resolveDomains(context.Background())
+ srv.ResolveDomains(context.Background())
require.Equal(t, DefaultSelfHostedDomain, srv.dnsDomain)
require.Equal(t, DefaultSelfHostedDomain, srv.mgmtSingleAccModeDomain)
diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go
index 66c4729da..76f02aab4 100644
--- a/management/internals/shared/grpc/conversion.go
+++ b/management/internals/shared/grpc/conversion.go
@@ -7,9 +7,11 @@ import (
"net/netip"
"net/url"
"strings"
+ "time"
log "github.com/sirupsen/logrus"
goproto "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/types/known/timestamppb"
integrationsConfig "github.com/netbirdio/management-integrations/integrations/config"
@@ -196,6 +198,16 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb
}
}
+ // settings == nil → field stays nil → "no info in this snapshot", client
+ // preserves the deadline it already had. settings non-nil → emit either a
+ // valid deadline or the explicit-zero "disabled" sentinel via
+ // encodeSessionExpiresAt.
+ if settings != nil {
+ response.SessionExpiresAt = encodeSessionExpiresAt(
+ peer.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration),
+ )
+ }
+
return response
}
@@ -231,6 +243,25 @@ func buildSessionPubKeysProto(ctx context.Context, in []types.VNCSessionPubKey)
return out
}
+// encodeSessionExpiresAt encodes a server-side deadline into the 3-state wire
+// representation used on LoginResponse, SyncResponse and
+// ExtendAuthSessionResponse. See the proto comments on those messages.
+//
+// - deadline.IsZero() → returns &Timestamp{} (seconds=0, nanos=0): the
+// "expiry disabled or peer is not SSO-tracked" sentinel; the client clears
+// its anchor.
+// - deadline non-zero → returns timestamppb.New(deadline): the new absolute
+// UTC deadline.
+//
+// Returning nil ("no info, preserve client's anchor") is the caller's job —
+// only meaningful on Sync builds where settings were not resolved.
+func encodeSessionExpiresAt(deadline time.Time) *timestamppb.Timestamp {
+ if deadline.IsZero() {
+ return ×tamppb.Timestamp{}
+ }
+ return timestamppb.New(deadline)
+}
+
func buildAuthorizedUsersProto(ctx context.Context, authorizedUsers map[string]map[string]struct{}) ([][]byte, map[string]*proto.MachineUserIndexes) {
userIDToIndex := make(map[string]uint32)
var hashedUsers [][]byte
diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go
index 1e75caf95..5efb24319 100644
--- a/management/internals/shared/grpc/conversion_test.go
+++ b/management/internals/shared/grpc/conversion_test.go
@@ -5,6 +5,7 @@ import (
"net/netip"
"reflect"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
@@ -200,3 +201,29 @@ func TestBuildJWTConfig_Audiences(t *testing.T) {
})
}
}
+
+// TestEncodeSessionExpiresAt pins the wire encoding the client's
+// applySessionDeadline depends on:
+//
+// - zero deadline → &Timestamp{} (seconds=0, nanos=0): the explicit
+// "expiry disabled or peer is not SSO-tracked" sentinel.
+// - non-zero → timestamppb.New(deadline): the absolute UTC deadline.
+//
+// The third state (nil pointer = "no info in this snapshot") is the caller's
+// responsibility on the Sync path when settings could not be resolved; the
+// helper itself never returns nil.
+func TestEncodeSessionExpiresAt(t *testing.T) {
+ t.Run("zero deadline encodes as explicit-zero sentinel", func(t *testing.T) {
+ got := encodeSessionExpiresAt(time.Time{})
+ assert.NotNil(t, got, "must not return nil; nil means 'no info', not 'disabled'")
+ assert.Equal(t, int64(0), got.GetSeconds())
+ assert.Equal(t, int32(0), got.GetNanos())
+ })
+
+ t.Run("non-zero deadline round-trips", func(t *testing.T) {
+ deadline := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
+ got := encodeSessionExpiresAt(deadline)
+ assert.NotNil(t, got)
+ assert.True(t, got.AsTime().Equal(deadline))
+ })
+}
diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go
index eada2d86a..0feb807f6 100644
--- a/management/internals/shared/grpc/proxy.go
+++ b/management/internals/shared/grpc/proxy.go
@@ -9,6 +9,7 @@ import (
"encoding/hex"
"errors"
"fmt"
+ "io"
"net"
"net/http"
"net/url"
@@ -136,9 +137,12 @@ type proxyConnection struct {
tokenID string
capabilities *proto.ProxyCapabilities
stream proto.ProxyService_GetMappingUpdateServer
- sendChan chan *proto.GetMappingUpdateResponse
- ctx context.Context
- cancel context.CancelFunc
+ // syncStream is set when the proxy connected via SyncMappings.
+ // When non-nil, the sender goroutine uses this instead of stream.
+ syncStream proto.ProxyService_SyncMappingsServer
+ sendChan chan *proto.GetMappingUpdateResponse
+ ctx context.Context
+ cancel context.CancelFunc
}
func enforceAccountScope(ctx context.Context, requestAccountID string) error {
@@ -206,145 +210,323 @@ func (s *ProxyServiceServer) SetProxyController(proxyController proxy.Controller
s.proxyController = proxyController
}
+// proxyConnectParams holds the validated parameters extracted from either
+// a GetMappingUpdateRequest or a SyncMappingsInit message.
+type proxyConnectParams struct {
+ proxyID string
+ address string
+ capabilities *proto.ProxyCapabilities
+}
+
// GetMappingUpdate handles the control stream with proxy clients
func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest, stream proto.ProxyService_GetMappingUpdateServer) error {
- ctx := stream.Context()
-
- peerInfo := PeerIPFromContext(ctx)
- log.Infof("New proxy connection from %s", peerInfo)
-
- proxyID := req.GetProxyId()
- if proxyID == "" {
- return status.Errorf(codes.InvalidArgument, "proxy_id is required")
- }
-
- proxyAddress := req.GetAddress()
- if !isProxyAddressValid(proxyAddress) {
- return status.Errorf(codes.InvalidArgument, "proxy address is invalid")
- }
-
- var accountID *string
- token := GetProxyTokenFromContext(ctx)
- if token != nil && token.AccountID != nil {
- accountID = token.AccountID
-
- available, err := s.proxyManager.IsClusterAddressAvailable(ctx, proxyAddress, *accountID)
- if err != nil {
- return status.Errorf(codes.Internal, "check cluster address: %v", err)
- }
- if !available {
- return status.Errorf(codes.AlreadyExists, "cluster address %s is already in use", proxyAddress)
- }
- }
-
- var tokenID string
- if token != nil {
- tokenID = token.ID
- }
-
- sessionID := uuid.NewString()
-
- if old, loaded := s.connectedProxies.Load(proxyID); loaded {
- oldConn := old.(*proxyConnection)
- log.WithFields(log.Fields{
- "proxy_id": proxyID,
- "old_session_id": oldConn.sessionID,
- "new_session_id": sessionID,
- }).Info("Superseding existing proxy connection")
- oldConn.cancel()
- }
-
- connCtx, cancel := context.WithCancel(ctx)
- conn := &proxyConnection{
- proxyID: proxyID,
- sessionID: sessionID,
- address: proxyAddress,
- accountID: accountID,
- tokenID: tokenID,
- capabilities: req.GetCapabilities(),
- stream: stream,
- sendChan: make(chan *proto.GetMappingUpdateResponse, 100),
- ctx: connCtx,
- cancel: cancel,
- }
-
- var caps *proxy.Capabilities
- if c := req.GetCapabilities(); c != nil {
- caps = &proxy.Capabilities{
- SupportsCustomPorts: c.SupportsCustomPorts,
- RequireSubdomain: c.RequireSubdomain,
- SupportsCrowdsec: c.SupportsCrowdsec,
- }
- }
- proxyRecord, err := s.proxyManager.Connect(ctx, proxyID, sessionID, proxyAddress, peerInfo, accountID, caps)
+ params, err := s.validateProxyConnect(req.GetProxyId(), req.GetAddress(), stream.Context())
if err != nil {
- cancel()
- if accountID != nil {
- return status.Errorf(codes.Internal, "failed to register BYOP proxy: %v", err)
- }
- log.WithContext(ctx).Warnf("failed to register proxy %s in database: %v", proxyID, err)
- return status.Errorf(codes.Internal, "register proxy in database: %v", err)
+ return err
+ }
+ params.capabilities = req.GetCapabilities()
+
+ conn, proxyRecord, err := s.registerProxyConnection(stream.Context(), params, &proxyConnection{
+ stream: stream,
+ })
+ if err != nil {
+ return err
}
- s.connectedProxies.Store(proxyID, conn)
- if err := s.proxyController.RegisterProxyToCluster(ctx, conn.address, proxyID); err != nil {
- log.WithContext(ctx).Warnf("Failed to register proxy %s in cluster: %v", proxyID, err)
- }
-
- if err := s.sendSnapshot(ctx, conn); err != nil {
- if s.connectedProxies.CompareAndDelete(proxyID, conn) {
- if unregErr := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, proxyID); unregErr != nil {
- log.WithContext(ctx).Debugf("cleanup after snapshot failure for proxy %s: %v", proxyID, unregErr)
- }
- }
- cancel()
- if disconnErr := s.proxyManager.Disconnect(context.Background(), proxyID, sessionID); disconnErr != nil {
- log.WithContext(ctx).Debugf("cleanup after snapshot failure for proxy %s: %v", proxyID, disconnErr)
- }
- return fmt.Errorf("send snapshot to proxy %s: %w", proxyID, err)
+ if err := s.sendSnapshot(stream.Context(), conn); err != nil {
+ s.cleanupFailedSnapshot(stream.Context(), conn)
+ return fmt.Errorf("send snapshot to proxy %s: %w", params.proxyID, err)
}
errChan := make(chan error, 2)
go s.sender(conn, errChan)
- log.WithFields(log.Fields{
- "proxy_id": proxyID,
- "session_id": sessionID,
- "address": proxyAddress,
- "cluster_addr": proxyAddress,
- "account_id": accountID,
- "total_proxies": len(s.GetConnectedProxies()),
- }).Info("Proxy registered in cluster")
- defer func() {
- if !s.connectedProxies.CompareAndDelete(proxyID, conn) {
- log.Infof("Proxy %s session %s: skipping cleanup, superseded by new connection", proxyID, sessionID)
- cancel()
+ return s.serveProxyConnection(conn, proxyRecord, errChan, false)
+}
+
+// SyncMappings implements the bidirectional SyncMappings RPC.
+// It mirrors GetMappingUpdate but provides application-level back-pressure:
+// management waits for an ack from the proxy before sending the next batch.
+func (s *ProxyServiceServer) SyncMappings(stream proto.ProxyService_SyncMappingsServer) error {
+ init, err := recvSyncInit(stream)
+ if err != nil {
+ return err
+ }
+
+ params, err := s.validateProxyConnect(init.GetProxyId(), init.GetAddress(), stream.Context())
+ if err != nil {
+ return err
+ }
+ params.capabilities = init.GetCapabilities()
+
+ conn, proxyRecord, err := s.registerProxyConnection(stream.Context(), params, &proxyConnection{
+ syncStream: stream,
+ })
+ if err != nil {
+ return err
+ }
+
+ if err := s.sendSnapshotSync(stream.Context(), conn, stream); err != nil {
+ s.cleanupFailedSnapshot(stream.Context(), conn)
+ return fmt.Errorf("send snapshot to proxy %s: %w", params.proxyID, err)
+ }
+
+ errChan := make(chan error, 2)
+ go s.sender(conn, errChan)
+ go s.drainRecv(stream, errChan)
+
+ return s.serveProxyConnection(conn, proxyRecord, errChan, true)
+}
+
+// recvSyncInit receives and validates the first message on a SyncMappings stream.
+func recvSyncInit(stream proto.ProxyService_SyncMappingsServer) (*proto.SyncMappingsInit, error) {
+ firstMsg, err := stream.Recv()
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "receive init: %v", err)
+ }
+ init := firstMsg.GetInit()
+ if init == nil {
+ return nil, status.Errorf(codes.InvalidArgument, "first message must be init")
+ }
+ return init, nil
+}
+
+// validateProxyConnect validates the proxy ID and address, and checks cluster
+// address availability for account-scoped tokens.
+func (s *ProxyServiceServer) validateProxyConnect(proxyID, address string, ctx context.Context) (proxyConnectParams, error) {
+ if proxyID == "" {
+ return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy_id is required")
+ }
+ if !isProxyAddressValid(address) {
+ return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy address is invalid")
+ }
+
+ token := GetProxyTokenFromContext(ctx)
+ if token != nil && token.AccountID != nil {
+ available, err := s.proxyManager.IsClusterAddressAvailable(ctx, address, *token.AccountID)
+ if err != nil {
+ return proxyConnectParams{}, status.Errorf(codes.Internal, "check cluster address: %v", err)
+ }
+ if !available {
+ return proxyConnectParams{}, status.Errorf(codes.AlreadyExists, "cluster address %s is already in use", address)
+ }
+ }
+
+ return proxyConnectParams{proxyID: proxyID, address: address}, nil
+}
+
+// registerProxyConnection creates a proxyConnection, registers it with the
+// proxy manager and cluster, and stores it in connectedProxies. The caller
+// provides a partially initialised connSeed with stream-specific fields set;
+// the remaining fields are filled in here.
+func (s *ProxyServiceServer) registerProxyConnection(ctx context.Context, params proxyConnectParams, connSeed *proxyConnection) (*proxyConnection, *proxy.Proxy, error) {
+ peerInfo := PeerIPFromContext(ctx)
+
+ var accountID *string
+ var tokenID string
+ if token := GetProxyTokenFromContext(ctx); token != nil {
+ if token.AccountID != nil {
+ accountID = token.AccountID
+ }
+ tokenID = token.ID
+ }
+
+ sessionID := uuid.NewString()
+ s.supersedePriorConnection(params.proxyID, sessionID)
+
+ connCtx, cancel := context.WithCancel(ctx)
+ connSeed.proxyID = params.proxyID
+ connSeed.sessionID = sessionID
+ connSeed.address = params.address
+ connSeed.accountID = accountID
+ connSeed.tokenID = tokenID
+ connSeed.capabilities = params.capabilities
+ connSeed.sendChan = make(chan *proto.GetMappingUpdateResponse, 100)
+ connSeed.ctx = connCtx
+ connSeed.cancel = cancel
+
+ var caps *proxy.Capabilities
+ if c := params.capabilities; c != nil {
+ caps = &proxy.Capabilities{
+ SupportsCustomPorts: c.SupportsCustomPorts,
+ RequireSubdomain: c.RequireSubdomain,
+ SupportsCrowdsec: c.SupportsCrowdsec,
+ Private: c.Private,
+ }
+ }
+
+ proxyRecord, err := s.proxyManager.Connect(ctx, params.proxyID, sessionID, params.address, peerInfo, accountID, caps)
+ if err != nil {
+ cancel()
+ if accountID != nil {
+ return nil, nil, status.Errorf(codes.Internal, "failed to register BYOP proxy: %v", err)
+ }
+ log.WithContext(ctx).Warnf("failed to register proxy %s in database: %v", params.proxyID, err)
+ return nil, nil, status.Errorf(codes.Internal, "register proxy in database: %v", err)
+ }
+
+ s.connectedProxies.Store(params.proxyID, connSeed)
+ if err := s.proxyController.RegisterProxyToCluster(ctx, params.address, params.proxyID); err != nil {
+ log.WithContext(ctx).Warnf("Failed to register proxy %s in cluster: %v", params.proxyID, err)
+ }
+
+ return connSeed, proxyRecord, nil
+}
+
+// supersedePriorConnection cancels any existing connection for the given proxy.
+func (s *ProxyServiceServer) supersedePriorConnection(proxyID, newSessionID string) {
+ if old, loaded := s.connectedProxies.Load(proxyID); loaded {
+ oldConn := old.(*proxyConnection)
+ log.WithFields(log.Fields{
+ "proxy_id": proxyID,
+ "old_session_id": oldConn.sessionID,
+ "new_session_id": newSessionID,
+ }).Info("Superseding existing proxy connection")
+ oldConn.cancel()
+ }
+}
+
+// cleanupFailedSnapshot removes the connection from the cluster and store
+// after a snapshot send failure.
+func (s *ProxyServiceServer) cleanupFailedSnapshot(ctx context.Context, conn *proxyConnection) {
+ if s.connectedProxies.CompareAndDelete(conn.proxyID, conn) {
+ if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, conn.proxyID); err != nil {
+ log.WithContext(ctx).Debugf("cleanup after snapshot failure for proxy %s: %v", conn.proxyID, err)
+ }
+ }
+ conn.cancel()
+ if err := s.proxyManager.Disconnect(context.Background(), conn.proxyID, conn.sessionID); err != nil {
+ log.WithContext(ctx).Debugf("cleanup after snapshot failure for proxy %s: %v", conn.proxyID, err)
+ }
+}
+
+// drainRecv consumes and discards messages from a bidirectional stream.
+// The proxy sends an ack for every incremental update; we don't need them
+// after the snapshot phase. Recv errors are forwarded to errChan.
+func (s *ProxyServiceServer) drainRecv(stream proto.ProxyService_SyncMappingsServer, errChan chan<- error) {
+ for {
+ if _, err := stream.Recv(); err != nil {
+ errChan <- err
return
}
+ }
+}
- if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, proxyID); err != nil {
- log.Warnf("Failed to unregister proxy %s from cluster: %v", proxyID, err)
- }
- if err := s.proxyManager.Disconnect(context.Background(), proxyID, sessionID); err != nil {
- log.Warnf("Failed to mark proxy %s as disconnected: %v", proxyID, err)
- }
+// serveProxyConnection runs the post-snapshot lifecycle: heartbeat, sender,
+// and wait for termination. When bidi is true, normal stream closure (EOF,
+// canceled) is treated as a clean disconnect rather than an error.
+func (s *ProxyServiceServer) serveProxyConnection(conn *proxyConnection, proxyRecord *proxy.Proxy, errChan <-chan error, bidi bool) error {
+ log.WithFields(log.Fields{
+ "proxy_id": conn.proxyID,
+ "session_id": conn.sessionID,
+ "address": conn.address,
+ "cluster_addr": conn.address,
+ "account_id": conn.accountID,
+ "total_proxies": len(s.GetConnectedProxies()),
+ }).Info("Proxy registered in cluster")
- cancel()
- log.Infof("Proxy %s session %s disconnected", proxyID, sessionID)
- }()
-
- go s.heartbeat(connCtx, conn, proxyRecord)
+ defer s.disconnectProxy(conn)
+ go s.heartbeat(conn.ctx, conn, proxyRecord)
select {
case err := <-errChan:
- log.WithContext(ctx).Warnf("Failed to send update: %v", err)
- return fmt.Errorf("send update to proxy %s: %w", proxyID, err)
- case <-connCtx.Done():
- log.WithContext(ctx).Infof("Proxy %s context canceled", proxyID)
- return connCtx.Err()
+ if bidi && isStreamClosed(err) {
+ log.Infof("Proxy %s stream closed", conn.proxyID)
+ return nil
+ }
+ log.Warnf("Failed to send update: %v", err)
+ return fmt.Errorf("send update to proxy %s: %w", conn.proxyID, err)
+ case <-conn.ctx.Done():
+ log.Infof("Proxy %s context canceled", conn.proxyID)
+ return conn.ctx.Err()
}
}
+// disconnectProxy removes the connection from cluster and store, unless it
+// has already been superseded by a newer connection.
+func (s *ProxyServiceServer) disconnectProxy(conn *proxyConnection) {
+ if !s.connectedProxies.CompareAndDelete(conn.proxyID, conn) {
+ log.Infof("Proxy %s session %s: skipping cleanup, superseded by new connection", conn.proxyID, conn.sessionID)
+ conn.cancel()
+ return
+ }
+
+ if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, conn.proxyID); err != nil {
+ log.Warnf("Failed to unregister proxy %s from cluster: %v", conn.proxyID, err)
+ }
+ if err := s.proxyManager.Disconnect(context.Background(), conn.proxyID, conn.sessionID); err != nil {
+ log.Warnf("Failed to mark proxy %s as disconnected: %v", conn.proxyID, err)
+ }
+
+ conn.cancel()
+ log.Infof("Proxy %s session %s disconnected", conn.proxyID, conn.sessionID)
+}
+
+// sendSnapshotSync sends the initial snapshot with back-pressure: it sends
+// one batch, then waits for the proxy to ack before sending the next.
+func (s *ProxyServiceServer) sendSnapshotSync(ctx context.Context, conn *proxyConnection, stream proto.ProxyService_SyncMappingsServer) error {
+ if !isProxyAddressValid(conn.address) {
+ return fmt.Errorf("proxy address is invalid")
+ }
+ if s.snapshotBatchSize <= 0 {
+ return fmt.Errorf("invalid snapshot batch size: %d", s.snapshotBatchSize)
+ }
+
+ mappings, err := s.snapshotServiceMappings(ctx, conn)
+ if err != nil {
+ return err
+ }
+
+ for i := 0; i < len(mappings); i += s.snapshotBatchSize {
+ end := i + s.snapshotBatchSize
+ if end > len(mappings) {
+ end = len(mappings)
+ }
+ for _, m := range mappings[i:end] {
+ token, err := s.tokenStore.GenerateToken(m.AccountId, m.Id, s.proxyTokenTTL())
+ if err != nil {
+ return fmt.Errorf("generate auth token for service %s: %w", m.Id, err)
+ }
+ m.AuthToken = token
+ }
+ if err := stream.Send(&proto.SyncMappingsResponse{
+ Mapping: mappings[i:end],
+ InitialSyncComplete: end == len(mappings),
+ }); err != nil {
+ return fmt.Errorf("send snapshot batch: %w", err)
+ }
+
+ if err := waitForAck(stream); err != nil {
+ return err
+ }
+ }
+
+ if len(mappings) == 0 {
+ if err := stream.Send(&proto.SyncMappingsResponse{
+ InitialSyncComplete: true,
+ }); err != nil {
+ return fmt.Errorf("send snapshot completion: %w", err)
+ }
+
+ if err := waitForAck(stream); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func waitForAck(stream proto.ProxyService_SyncMappingsServer) error {
+ msg, err := stream.Recv()
+ if err != nil {
+ return fmt.Errorf("receive ack: %w", err)
+ }
+ if msg.GetAck() == nil {
+ return fmt.Errorf("expected ack, got %T", msg.GetMsg())
+ }
+ return nil
+}
+
// heartbeat updates the proxy's last_seen timestamp every minute and
// disconnects the proxy if its access token has been revoked.
func (s *ProxyServiceServer) heartbeat(ctx context.Context, conn *proxyConnection, p *proxy.Proxy) {
@@ -381,6 +563,9 @@ func (s *ProxyServiceServer) sendSnapshot(ctx context.Context, conn *proxyConnec
if !isProxyAddressValid(conn.address) {
return fmt.Errorf("proxy address is invalid")
}
+ if s.snapshotBatchSize <= 0 {
+ return fmt.Errorf("invalid snapshot batch size: %d", s.snapshotBatchSize)
+ }
mappings, err := s.snapshotServiceMappings(ctx, conn)
if err != nil {
@@ -460,21 +645,48 @@ func isProxyAddressValid(addr string) bool {
return err == nil
}
-// sender handles sending messages to proxy
+// isStreamClosed returns true for errors that indicate normal stream
+// termination: io.EOF, context cancellation, or gRPC Canceled.
+func isStreamClosed(err error) bool {
+ if err == nil {
+ return false
+ }
+ if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
+ return true
+ }
+ return status.Code(err) == codes.Canceled
+}
+
+// sender handles sending messages to proxy.
+// When conn.syncStream is set the message is sent as SyncMappingsResponse;
+// otherwise the legacy GetMappingUpdateResponse stream is used.
func (s *ProxyServiceServer) sender(conn *proxyConnection, errChan chan<- error) {
for {
select {
case resp := <-conn.sendChan:
- if err := conn.stream.Send(resp); err != nil {
+ if err := conn.sendResponse(resp); err != nil {
errChan <- err
+ log.WithContext(conn.ctx).Tracef("Failed to send response to proxy %s: %v", conn.proxyID, err)
return
}
+ log.WithContext(conn.ctx).Tracef("Send response to proxy %s", conn.proxyID)
case <-conn.ctx.Done():
return
}
}
}
+// sendResponse sends a mapping update on whichever stream the proxy connected with.
+func (conn *proxyConnection) sendResponse(resp *proto.GetMappingUpdateResponse) error {
+ if conn.syncStream != nil {
+ return conn.syncStream.Send(&proto.SyncMappingsResponse{
+ Mapping: resp.Mapping,
+ InitialSyncComplete: resp.InitialSyncComplete,
+ })
+ }
+ return conn.stream.Send(resp)
+}
+
// SendAccessLog processes access log from proxy
func (s *ProxyServiceServer) SendAccessLog(ctx context.Context, req *proto.SendAccessLogRequest) (*proto.SendAccessLogResponse, error) {
accessLog := req.GetLog()
@@ -541,10 +753,15 @@ func (s *ProxyServiceServer) SendServiceUpdate(update *proto.GetMappingUpdateRes
return true
}
connUpdate = &proto.GetMappingUpdateResponse{
- Mapping: filtered,
- InitialSyncComplete: update.InitialSyncComplete,
+ Mapping: filtered,
+ InitialSyncComplete: update.InitialSyncComplete,
}
}
+ // Drop mappings the proxy lacks capability for (e.g. private without SupportsPrivateService).
+ connUpdate = filterMappingsForProxy(conn, connUpdate)
+ if connUpdate == nil || len(connUpdate.Mapping) == 0 {
+ return true
+ }
resp := s.perProxyMessage(connUpdate, conn.proxyID)
if resp == nil {
log.Warnf("Token generation failed for proxy %s, disconnecting to force resync", conn.proxyID)
@@ -673,16 +890,20 @@ func (s *ProxyServiceServer) SendServiceUpdateToCluster(ctx context.Context, upd
}
}
-// proxyAcceptsMapping returns whether the proxy should receive this mapping.
-// Old proxies that never reported capabilities are skipped for non-TLS L4
-// mappings with a custom listen port, since they don't understand the
-// protocol. Proxies that report capabilities (even SupportsCustomPorts=false)
-// are new enough to handle the mapping. TLS uses SNI routing and works on
-// any proxy. Delete operations are always sent so proxies can clean up.
+// proxyAcceptsMapping returns whether the proxy can receive this mapping.
+// Private mappings require SupportsPrivateService; custom-port L4 mappings
+// require SupportsCustomPorts. Remove operations always pass so proxies can
+// clean up.
func proxyAcceptsMapping(conn *proxyConnection, mapping *proto.ProxyMapping) bool {
if mapping.Type == proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED {
return true
}
+ if mapping.GetPrivate() {
+ caps := conn.capabilities
+ if caps == nil || caps.SupportsPrivateService == nil || !*caps.SupportsPrivateService {
+ return false
+ }
+ }
if mapping.ListenPort == 0 || mapping.Mode == "tls" {
return true
}
@@ -691,6 +912,29 @@ func proxyAcceptsMapping(conn *proxyConnection, mapping *proto.ProxyMapping) boo
return conn.capabilities != nil && conn.capabilities.SupportsCustomPorts != nil
}
+// filterMappingsForProxy drops mappings the proxy cannot safely receive
+// (e.g. private mappings to a proxy without SupportsPrivateService).
+// Returns the input unchanged when no filtering is needed.
+func filterMappingsForProxy(conn *proxyConnection, update *proto.GetMappingUpdateResponse) *proto.GetMappingUpdateResponse {
+ if update == nil || len(update.Mapping) == 0 {
+ return update
+ }
+ kept := make([]*proto.ProxyMapping, 0, len(update.Mapping))
+ for _, m := range update.Mapping {
+ if !proxyAcceptsMapping(conn, m) {
+ continue
+ }
+ kept = append(kept, m)
+ }
+ if len(kept) == len(update.Mapping) {
+ return update
+ }
+ return &proto.GetMappingUpdateResponse{
+ Mapping: kept,
+ InitialSyncComplete: update.InitialSyncComplete,
+ }
+}
+
// perProxyMessage returns a copy of update with a fresh one-time token for
// create/update operations. For delete operations the original mapping is
// used unchanged because proxies do not need to authenticate for removal.
@@ -736,6 +980,7 @@ func shallowCloneMapping(m *proto.ProxyMapping) *proto.ProxyMapping {
Mode: m.Mode,
ListenPort: m.ListenPort,
AccessRestrictions: m.AccessRestrictions,
+ Private: m.Private,
}
}
@@ -752,7 +997,10 @@ func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.Authen
authenticated, userId, method := s.authenticateRequest(ctx, req, service)
- token, err := s.generateSessionToken(ctx, authenticated, service, userId, method)
+ // Non-OIDC schemes (PIN/Password/Header) authenticate against per-service
+ // secrets and have no user-level group context, so groups stay nil. Email
+ // is also empty — these schemes don't resolve a user record at sign time.
+ token, err := s.generateSessionToken(ctx, authenticated, service, userId, "", method, nil, nil)
if err != nil {
return nil, err
}
@@ -841,7 +1089,7 @@ func (s *ProxyServiceServer) logAuthenticationError(ctx context.Context, err err
}
}
-func (s *ProxyServiceServer) generateSessionToken(ctx context.Context, authenticated bool, service *rpservice.Service, userId string, method proxyauth.Method) (string, error) {
+func (s *ProxyServiceServer) generateSessionToken(ctx context.Context, authenticated bool, service *rpservice.Service, userId, userEmail string, method proxyauth.Method, groupIDs, groupNames []string) (string, error) {
if !authenticated || service.SessionPrivateKey == "" {
return "", nil
}
@@ -849,8 +1097,11 @@ func (s *ProxyServiceServer) generateSessionToken(ctx context.Context, authentic
token, err := sessionkey.SignToken(
service.SessionPrivateKey,
userId,
+ userEmail,
service.Domain,
method,
+ groupIDs,
+ groupNames,
proxyauth.DefaultSessionExpiry,
)
if err != nil {
@@ -861,6 +1112,26 @@ func (s *ProxyServiceServer) generateSessionToken(ctx context.Context, authentic
return token, nil
}
+// pairGroupIDsAndNames splits a slice of resolved *types.Group records
+// into parallel id and name slices. ids[i] and names[i] always pair to
+// the same group. nil entries (orphan ids the manager couldn't resolve)
+// are skipped so the consumer can rely on positional pairing.
+func pairGroupIDsAndNames(groups []*types.Group) (ids, names []string) {
+ if len(groups) == 0 {
+ return nil, nil
+ }
+ ids = make([]string, 0, len(groups))
+ names = make([]string, 0, len(groups))
+ for _, g := range groups {
+ if g == nil {
+ continue
+ }
+ ids = append(ids, g.ID)
+ names = append(names, g.Name)
+ }
+ return ids, names
+}
+
// SendStatusUpdate handles status updates from proxy clients.
func (s *ProxyServiceServer) SendStatusUpdate(ctx context.Context, req *proto.SendStatusUpdateRequest) (*proto.SendStatusUpdateResponse, error) {
if err := enforceAccountScope(ctx, req.GetAccountId()); err != nil {
@@ -1125,7 +1396,9 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL
return verifier, redirectURL, nil
}
-// GenerateSessionToken creates a signed session JWT for the given domain and user.
+// GenerateSessionToken creates a signed session JWT for the given domain and
+// user. The user's group memberships are embedded in the token so policy-aware
+// middlewares on the proxy can authorise without an extra management round-trip.
func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) {
service, err := s.getServiceByDomain(ctx, domain)
if err != nil {
@@ -1136,11 +1409,29 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
return "", fmt.Errorf("no session key configured for domain: %s", domain)
}
+ var (
+ email string
+ groupIDs []string
+ groupNames []string
+ )
+ if s.usersManager != nil {
+ user, userGroups, uerr := s.usersManager.GetUserWithGroups(ctx, userID)
+ if uerr != nil {
+ log.WithContext(ctx).Debugf("session token mint: lookup user %s: %v", userID, uerr)
+ } else if user != nil {
+ email = user.Email
+ groupIDs, groupNames = pairGroupIDsAndNames(userGroups)
+ }
+ }
+
return sessionkey.SignToken(
service.SessionPrivateKey,
userID,
+ email,
domain,
method,
+ groupIDs,
+ groupNames,
proxyauth.DefaultSessionExpiry,
)
}
@@ -1244,7 +1535,7 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
}, nil
}
- userID, _, err := proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
+ userID, _, _, _, _, err := proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes)
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
@@ -1257,7 +1548,7 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
}, nil
}
- user, err := s.usersManager.GetUser(ctx, userID)
+ user, userGroups, err := s.usersManager.GetUserWithGroups(ctx, userID)
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
@@ -1291,12 +1582,15 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
"user_id": userID,
"error": err.Error(),
}).Debug("ValidateSession: access denied")
+ groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
//nolint:nilerr
return &proto.ValidateSessionResponse{
- Valid: false,
- UserId: user.Id,
- UserEmail: user.Email,
- DeniedReason: "not_in_group",
+ Valid: false,
+ UserId: user.Id,
+ UserEmail: user.Email,
+ DeniedReason: "not_in_group",
+ PeerGroupIds: groupIDs,
+ PeerGroupNames: groupNames,
}, nil
}
@@ -1306,10 +1600,13 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val
"email": user.Email,
}).Debug("ValidateSession: access granted")
+ groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
return &proto.ValidateSessionResponse{
- Valid: true,
- UserId: user.Id,
- UserEmail: user.Email,
+ Valid: true,
+ UserId: user.Id,
+ UserEmail: user.Email,
+ PeerGroupIds: groupIDs,
+ PeerGroupNames: groupNames,
}, nil
}
@@ -1342,3 +1639,154 @@ func (s *ProxyServiceServer) checkGroupAccess(service *rpservice.Service, user *
}
func ptr[T any](v T) *T { return &v }
+
+// ValidateTunnelPeer resolves an inbound peer by its WireGuard tunnel IP and
+// checks the peer's group membership against the service's access groups.
+// Peers without a user (machine agents, automation workloads) are first-class
+// callers; authorisation runs off peer-group memberships rather than the
+// optional owning user's auto-groups. On success a session JWT is minted so
+// the proxy can install a cookie and skip subsequent management round-trips.
+func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ domain := req.GetDomain()
+ tunnelIPStr := req.GetTunnelIp()
+
+ if domain == "" || tunnelIPStr == "" {
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: false,
+ DeniedReason: "missing domain or tunnel_ip",
+ }, nil
+ }
+
+ tunnelIP := net.ParseIP(tunnelIPStr)
+ if tunnelIP == nil {
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: false,
+ DeniedReason: "invalid_tunnel_ip",
+ }, nil
+ }
+
+ service, err := s.getServiceByDomain(ctx, domain)
+ if err != nil {
+ log.WithFields(log.Fields{"domain": domain, "error": err.Error()}).Debug("ValidateTunnelPeer: service not found")
+ //nolint:nilerr
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: false,
+ DeniedReason: "service_not_found",
+ }, nil
+ }
+
+ // Mirror ValidateSession: account-scoped (BYOP) proxy tokens may only
+ // validate and mint session cookies for their own account's domains.
+ if err := enforceAccountScope(ctx, service.AccountID); err != nil {
+ return nil, err
+ }
+
+ peer, err := s.peersManager.GetPeerByTunnelIP(ctx, service.AccountID, tunnelIP)
+ if err != nil || peer == nil {
+ log.WithFields(log.Fields{"domain": domain, "tunnel_ip": tunnelIPStr}).Debug("ValidateTunnelPeer: peer not found")
+ //nolint:nilerr
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: false,
+ DeniedReason: "peer_not_found",
+ }, nil
+ }
+
+ _, peerGroups, err := s.peersManager.GetPeerWithGroups(ctx, service.AccountID, peer.ID)
+ if err != nil {
+ log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: peer groups lookup failed")
+ //nolint:nilerr
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: false,
+ DeniedReason: "peer_not_found",
+ }, nil
+ }
+
+ 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
+ }
+ }
+ }
+
+ if err := checkPeerGroupAccess(service, groupIDs); err != nil {
+ log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: access denied")
+ //nolint:nilerr
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: false,
+ UserId: principalID,
+ UserEmail: displayIdentity,
+ DeniedReason: "not_in_group",
+ PeerGroupIds: groupIDs,
+ PeerGroupNames: groupNames,
+ }, nil
+ }
+
+ token, err := s.generateSessionToken(ctx, true, service, principalID, displayIdentity, proxyauth.MethodOIDC, groupIDs, groupNames)
+ if err != nil {
+ return nil, err
+ }
+
+ log.WithFields(log.Fields{
+ "domain": domain,
+ "tunnel_ip": tunnelIPStr,
+ "peer_id": peer.ID,
+ "principal_id": principalID,
+ }).Debug("ValidateTunnelPeer: access granted")
+
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: true,
+ UserId: principalID,
+ UserEmail: displayIdentity,
+ SessionToken: token,
+ PeerGroupIds: groupIDs,
+ PeerGroupNames: groupNames,
+ }, nil
+}
+
+// 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
+// boundary and must not trust upstream state). Bearer-auth services authorise
+// against DistributionGroups when populated. Non-private non-bearer services
+// are open.
+func checkPeerGroupAccess(service *rpservice.Service, peerGroupIDs []string) error {
+ if service.Private {
+ if len(service.AccessGroups) == 0 {
+ return fmt.Errorf("private service has no access groups")
+ }
+ return matchAnyGroup(service.AccessGroups, peerGroupIDs)
+ }
+ if service.Auth.BearerAuth != nil && service.Auth.BearerAuth.Enabled && len(service.Auth.BearerAuth.DistributionGroups) > 0 {
+ return matchAnyGroup(service.Auth.BearerAuth.DistributionGroups, peerGroupIDs)
+ }
+ return nil
+}
+
+// matchAnyGroup returns nil when peerGroupIDs intersects allowedGroups,
+// else a non-nil error.
+func matchAnyGroup(allowedGroups, peerGroupIDs []string) error {
+ if len(allowedGroups) == 0 {
+ return fmt.Errorf("no allowed groups configured")
+ }
+ allowed := make(map[string]struct{}, len(allowedGroups))
+ for _, g := range allowedGroups {
+ allowed[g] = struct{}{}
+ }
+ for _, g := range peerGroupIDs {
+ if _, ok := allowed[g]; ok {
+ return nil
+ }
+ }
+ return fmt.Errorf("peer not in allowed groups")
+}
diff --git a/management/internals/shared/grpc/proxy_clone_test.go b/management/internals/shared/grpc/proxy_clone_test.go
new file mode 100644
index 000000000..f00d40fae
--- /dev/null
+++ b/management/internals/shared/grpc/proxy_clone_test.go
@@ -0,0 +1,88 @@
+package grpc
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// authTokenField is the only per-proxy field that shallowCloneMapping must NOT
+// copy from the source, since callers assign it individually after cloning.
+const authTokenField = "AuthToken"
+
+// TestShallowCloneMapping_ClonesAllFields populates every exported field of
+// ProxyMapping with a non-zero value and verifies the clone carries each one
+// (except AuthToken). It uses reflection so adding a new field to ProxyMapping
+// without updating shallowCloneMapping fails this test.
+func TestShallowCloneMapping_ClonesAllFields(t *testing.T) {
+ src := &proto.ProxyMapping{}
+ populated := populateExportedFields(t, reflect.ValueOf(src).Elem())
+ require.NotEmpty(t, populated, "ProxyMapping should expose fields to populate")
+
+ clone := shallowCloneMapping(src)
+ require.NotNil(t, clone, "clone must not be nil")
+
+ srcVal := reflect.ValueOf(src).Elem()
+ cloneVal := reflect.ValueOf(clone).Elem()
+
+ for _, name := range populated {
+ srcField := srcVal.FieldByName(name).Interface()
+ cloneField := cloneVal.FieldByName(name).Interface()
+
+ if name == authTokenField {
+ assert.Zero(t, cloneField, "AuthToken must not be cloned; it is set per proxy after cloning")
+ continue
+ }
+
+ assert.Equal(t, srcField, cloneField, "field %s must be carried over by shallowCloneMapping", name)
+ }
+}
+
+// populateExportedFields sets a non-zero value on every settable exported field
+// of the struct and returns their names.
+func populateExportedFields(t *testing.T, v reflect.Value) []string {
+ t.Helper()
+
+ var names []string
+ typ := v.Type()
+ for i := 0; i < v.NumField(); i++ {
+ field := v.Field(i)
+ structField := typ.Field(i)
+
+ if structField.PkgPath != "" || !field.CanSet() {
+ continue
+ }
+
+ setNonZero(t, field, structField.Name)
+ names = append(names, structField.Name)
+ }
+ return names
+}
+
+// setNonZero assigns a deterministic non-zero value based on the field kind.
+func setNonZero(t *testing.T, field reflect.Value, name string) {
+ t.Helper()
+
+ switch field.Kind() {
+ case reflect.String:
+ field.SetString("non-zero-" + name)
+ case reflect.Bool:
+ field.SetBool(true)
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ field.SetInt(7)
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ field.SetUint(7)
+ case reflect.Ptr:
+ field.Set(reflect.New(field.Type().Elem()))
+ case reflect.Slice:
+ field.Set(reflect.MakeSlice(field.Type(), 1, 1))
+ case reflect.Map:
+ field.Set(reflect.MakeMapWithSize(field.Type(), 0))
+ default:
+ t.Fatalf("unhandled field kind %s for field %s; extend setNonZero", field.Kind(), name)
+ }
+}
diff --git a/management/internals/shared/grpc/proxy_group_access_test.go b/management/internals/shared/grpc/proxy_group_access_test.go
index 5980f8a30..76da7ddbc 100644
--- a/management/internals/shared/grpc/proxy_group_access_test.go
+++ b/management/internals/shared/grpc/proxy_group_access_test.go
@@ -129,6 +129,14 @@ func (m *mockUsersManager) GetUser(ctx context.Context, userID string) (*types.U
return user, nil
}
+func (m *mockUsersManager) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) {
+ user, err := m.GetUser(ctx, userID)
+ if err != nil {
+ return nil, nil, err
+ }
+ return user, nil, nil
+}
+
func TestValidateUserGroupAccess(t *testing.T) {
tests := []struct {
name string
@@ -420,3 +428,46 @@ func TestGetAccountProxyByDomain(t *testing.T) {
})
}
}
+
+func TestCheckPeerGroupAccess(t *testing.T) {
+ t.Run("private with empty AccessGroups denies", func(t *testing.T) {
+ svc := &service.Service{Private: true, AccessGroups: nil}
+ err := checkPeerGroupAccess(svc, []string{"grp-admins"})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no access groups")
+ })
+
+ t.Run("private with peer in AccessGroups allows", func(t *testing.T) {
+ svc := &service.Service{Private: true, AccessGroups: []string{"grp-admins", "grp-ops"}}
+ assert.NoError(t, checkPeerGroupAccess(svc, []string{"grp-other", "grp-ops"}))
+ })
+
+ t.Run("private with peer outside AccessGroups denies", func(t *testing.T) {
+ svc := &service.Service{Private: true, AccessGroups: []string{"grp-admins"}}
+ assert.Error(t, checkPeerGroupAccess(svc, []string{"grp-other"}))
+ })
+
+ t.Run("bearer enabled with empty DistributionGroups allows", func(t *testing.T) {
+ svc := &service.Service{
+ Auth: service.AuthConfig{BearerAuth: &service.BearerAuthConfig{Enabled: true}},
+ }
+ assert.NoError(t, checkPeerGroupAccess(svc, []string{"grp-anyone"}))
+ })
+
+ t.Run("bearer enabled gates on DistributionGroups", func(t *testing.T) {
+ svc := &service.Service{
+ Auth: service.AuthConfig{
+ BearerAuth: &service.BearerAuthConfig{
+ Enabled: true,
+ DistributionGroups: []string{"grp-allowed"},
+ },
+ },
+ }
+ assert.NoError(t, checkPeerGroupAccess(svc, []string{"grp-allowed"}))
+ assert.Error(t, checkPeerGroupAccess(svc, []string{"grp-other"}))
+ })
+
+ t.Run("non-private non-bearer is open", func(t *testing.T) {
+ assert.NoError(t, checkPeerGroupAccess(&service.Service{}, nil))
+ })
+}
diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go
index d932abada..ffc354e00 100644
--- a/management/internals/shared/grpc/server.go
+++ b/management/internals/shared/grpc/server.go
@@ -437,7 +437,7 @@ func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wg
return nil
}
- log.WithContext(ctx).Debugf("received an update for peer %s", peerKey.String())
+ log.WithContext(ctx).Tracef("received an update for peer %s", peerKey.String())
if debouncer.ProcessUpdate(update) {
// Send immediately (first update or after quiet period)
if err := s.sendUpdate(ctx, accountID, peerKey, peer, update, srv, streamStartTime); err != nil {
@@ -492,7 +492,7 @@ func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtyp
s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime)
return status.Errorf(codes.Internal, "failed sending update message")
}
- log.WithContext(ctx).Debugf("sent an update to peer %s", peerKey.String())
+ log.WithContext(ctx).Tracef("sent an update to peer %s", peerKey.String())
return nil
}
@@ -822,6 +822,80 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
}, nil
}
+// ExtendAuthSession refreshes the peer's SSO session expiry deadline using a
+// fresh JWT. The same JWT validation pipeline as Login is used. The tunnel
+// stays up; no network map sync is performed. The new deadline is returned
+// in ExtendAuthSessionResponse.SessionExpiresAt.
+func (s *Server) ExtendAuthSession(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) {
+ extendReq := &proto.ExtendAuthSessionRequest{}
+ peerKey, err := s.parseRequest(ctx, req, extendReq)
+ if err != nil {
+ return nil, err
+ }
+
+ //nolint
+ ctx = context.WithValue(ctx, nbContext.PeerIDKey, peerKey.String())
+ if accountID, accErr := s.accountManager.GetAccountIDForPeerKey(ctx, peerKey.String()); accErr == nil {
+ //nolint
+ ctx = context.WithValue(ctx, nbContext.AccountIDKey, accountID)
+ }
+
+ jwt := extendReq.GetJwtToken()
+ if jwt == "" {
+ return nil, status.Errorf(codes.InvalidArgument, "jwt token is required")
+ }
+
+ var userID string
+ const attempts = 3
+ for i := 0; i < attempts; i++ {
+ userID, err = s.validateToken(ctx, peerKey.String(), jwt)
+ if err == nil {
+ break
+ }
+ if i == attempts-1 {
+ break
+ }
+ log.WithContext(ctx).Warnf("failed validating JWT token while extending session for peer %s: %v. Retrying (idP cache).", peerKey.String(), err)
+ select {
+ case <-time.After(200 * time.Millisecond):
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ if userID == "" {
+ return nil, status.Errorf(codes.Unauthenticated, "jwt token did not yield a user id")
+ }
+
+ deadline, err := s.accountManager.ExtendPeerSession(ctx, peerKey.String(), userID)
+ if err != nil {
+ log.WithContext(ctx).Warnf("failed extending session for peer %s: %v", peerKey.String(), err)
+ return nil, mapError(ctx, err)
+ }
+
+ // Success path normally returns a non-zero deadline. A defensive zero
+ // would still encode as the explicit "disabled" sentinel rather than nil,
+ // so the client clears any stale anchor instead of preserving it.
+ resp := &proto.ExtendAuthSessionResponse{
+ SessionExpiresAt: encodeSessionExpiresAt(deadline),
+ }
+
+ wgKey, err := s.secretsManager.GetWGKey()
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "failed processing request")
+ }
+ encrypted, err := encryption.EncryptMessage(peerKey, wgKey, resp)
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "failed encrypting response")
+ }
+ return &proto.EncryptedMessage{
+ WgPubKey: wgKey.PublicKey().String(),
+ Body: encrypted,
+ }, nil
+}
+
func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, netMap *types.NetworkMap, postureChecks []*posture.Checks) (*proto.LoginResponse, error) {
var relayToken *Token
var err error
@@ -845,6 +919,12 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne
Checks: toProtocolChecks(ctx, postureChecks),
}
+ // settings is always non-nil here, so we never emit nil — encoder returns
+ // either a valid deadline or the explicit-zero "disabled" sentinel.
+ loginResp.SessionExpiresAt = encodeSessionExpiresAt(
+ peer.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration),
+ )
+
return loginResp, nil
}
diff --git a/management/internals/shared/grpc/sync_mappings_test.go b/management/internals/shared/grpc/sync_mappings_test.go
new file mode 100644
index 000000000..97f6183bb
--- /dev/null
+++ b/management/internals/shared/grpc/sync_mappings_test.go
@@ -0,0 +1,411 @@
+package grpc
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
+
+ rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// syncRecordingStream is a mock ProxyService_SyncMappingsServer that records
+// sent messages and returns pre-loaded ack responses from Recv.
+type syncRecordingStream struct {
+ grpc.ServerStream
+
+ mu sync.Mutex
+ sent []*proto.SyncMappingsResponse
+ recvMsgs []*proto.SyncMappingsRequest
+ recvIdx int
+}
+
+func (s *syncRecordingStream) Send(m *proto.SyncMappingsResponse) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.sent = append(s.sent, m)
+ return nil
+}
+
+func (s *syncRecordingStream) Recv() (*proto.SyncMappingsRequest, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.recvIdx >= len(s.recvMsgs) {
+ return nil, fmt.Errorf("no more recv messages")
+ }
+ msg := s.recvMsgs[s.recvIdx]
+ s.recvIdx++
+ return msg, nil
+}
+
+func (s *syncRecordingStream) Context() context.Context { return context.Background() }
+func (s *syncRecordingStream) SetHeader(metadata.MD) error { return nil }
+func (s *syncRecordingStream) SendHeader(metadata.MD) error { return nil }
+func (s *syncRecordingStream) SetTrailer(metadata.MD) {}
+func (s *syncRecordingStream) SendMsg(any) error { return nil }
+func (s *syncRecordingStream) RecvMsg(any) error { return nil }
+
+func ackMsg() *proto.SyncMappingsRequest {
+ return &proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ }
+}
+
+func TestSendSnapshotSync_BatchesWithAcks(t *testing.T) {
+ const cluster = "cluster.example.com"
+ const batchSize = 3
+ const totalServices = 7 // 3 + 3 + 1 → 3 batches, 3 acks (one per batch, including final)
+
+ ctrl := gomock.NewController(t)
+ mgr := rpservice.NewMockManager(ctrl)
+ mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil)
+
+ s := newSnapshotTestServer(t, batchSize)
+ s.serviceManager = mgr
+
+ stream := &syncRecordingStream{
+ recvMsgs: []*proto.SyncMappingsRequest{ackMsg(), ackMsg(), ackMsg()},
+ }
+ conn := &proxyConnection{
+ proxyID: "proxy-a",
+ address: cluster,
+ syncStream: stream,
+ }
+
+ err := s.sendSnapshotSync(context.Background(), conn, stream)
+ require.NoError(t, err)
+
+ require.Len(t, stream.sent, 3, "should send ceil(7/3) = 3 batches")
+
+ assert.Len(t, stream.sent[0].Mapping, 3)
+ assert.False(t, stream.sent[0].InitialSyncComplete)
+
+ assert.Len(t, stream.sent[1].Mapping, 3)
+ assert.False(t, stream.sent[1].InitialSyncComplete)
+
+ assert.Len(t, stream.sent[2].Mapping, 1)
+ assert.True(t, stream.sent[2].InitialSyncComplete)
+
+ // All 3 acks consumed — including the final batch.
+ assert.Equal(t, 3, stream.recvIdx)
+}
+
+func TestSendSnapshotSync_SingleBatchWaitsForAck(t *testing.T) {
+ const cluster = "cluster.example.com"
+ const batchSize = 100
+ const totalServices = 5
+
+ ctrl := gomock.NewController(t)
+ mgr := rpservice.NewMockManager(ctrl)
+ mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil)
+
+ s := newSnapshotTestServer(t, batchSize)
+ s.serviceManager = mgr
+
+ stream := &syncRecordingStream{
+ recvMsgs: []*proto.SyncMappingsRequest{ackMsg()},
+ }
+ conn := &proxyConnection{
+ proxyID: "proxy-a",
+ address: cluster,
+ syncStream: stream,
+ }
+
+ err := s.sendSnapshotSync(context.Background(), conn, stream)
+ require.NoError(t, err)
+
+ require.Len(t, stream.sent, 1)
+ assert.Len(t, stream.sent[0].Mapping, totalServices)
+ assert.True(t, stream.sent[0].InitialSyncComplete)
+ assert.Equal(t, 1, stream.recvIdx, "final batch ack must be consumed")
+}
+
+func TestSendSnapshotSync_EmptySnapshot(t *testing.T) {
+ const cluster = "cluster.example.com"
+
+ ctrl := gomock.NewController(t)
+ mgr := rpservice.NewMockManager(ctrl)
+ mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(nil, nil)
+
+ s := newSnapshotTestServer(t, 500)
+ s.serviceManager = mgr
+
+ stream := &syncRecordingStream{
+ recvMsgs: []*proto.SyncMappingsRequest{ackMsg()},
+ }
+ conn := &proxyConnection{
+ proxyID: "proxy-a",
+ address: cluster,
+ syncStream: stream,
+ }
+
+ err := s.sendSnapshotSync(context.Background(), conn, stream)
+ require.NoError(t, err)
+
+ require.Len(t, stream.sent, 1, "empty snapshot must still send sync-complete")
+ assert.Empty(t, stream.sent[0].Mapping)
+ assert.True(t, stream.sent[0].InitialSyncComplete)
+ assert.Equal(t, 1, stream.recvIdx, "empty snapshot ack must be consumed")
+}
+
+func TestSendSnapshotSync_MissingAckReturnsError(t *testing.T) {
+ const cluster = "cluster.example.com"
+ const batchSize = 2
+ const totalServices = 4 // 2 batches → 1 ack needed, but we provide none
+
+ ctrl := gomock.NewController(t)
+ mgr := rpservice.NewMockManager(ctrl)
+ mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil)
+
+ s := newSnapshotTestServer(t, batchSize)
+ s.serviceManager = mgr
+
+ // No acks available — Recv will return error.
+ stream := &syncRecordingStream{}
+ conn := &proxyConnection{
+ proxyID: "proxy-a",
+ address: cluster,
+ syncStream: stream,
+ }
+
+ err := s.sendSnapshotSync(context.Background(), conn, stream)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "receive ack")
+ // First batch should have been sent before the error.
+ require.Len(t, stream.sent, 1)
+}
+
+func TestSendSnapshotSync_WrongMessageInsteadOfAck(t *testing.T) {
+ const cluster = "cluster.example.com"
+ const batchSize = 2
+ const totalServices = 4
+
+ ctrl := gomock.NewController(t)
+ mgr := rpservice.NewMockManager(ctrl)
+ mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil)
+
+ s := newSnapshotTestServer(t, batchSize)
+ s.serviceManager = mgr
+
+ // Send an init message instead of an ack.
+ stream := &syncRecordingStream{
+ recvMsgs: []*proto.SyncMappingsRequest{
+ {Msg: &proto.SyncMappingsRequest_Init{Init: &proto.SyncMappingsInit{ProxyId: "bad"}}},
+ },
+ }
+ conn := &proxyConnection{
+ proxyID: "proxy-a",
+ address: cluster,
+ syncStream: stream,
+ }
+
+ err := s.sendSnapshotSync(context.Background(), conn, stream)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "expected ack")
+}
+
+func TestSendSnapshotSync_BackPressureOrdering(t *testing.T) {
+ // Verify batches are sent strictly sequentially — batch N+1 is not sent
+ // until the ack for batch N is received, including the final batch.
+ const cluster = "cluster.example.com"
+ const batchSize = 2
+ const totalServices = 6 // 3 batches, 3 acks
+
+ ctrl := gomock.NewController(t)
+ mgr := rpservice.NewMockManager(ctrl)
+ mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil)
+
+ s := newSnapshotTestServer(t, batchSize)
+ s.serviceManager = mgr
+
+ var mu sync.Mutex
+ var events []string
+
+ // Build a stream that logs send/recv events so we can verify ordering.
+ ackCh := make(chan struct{}, 3)
+ stream := &orderTrackingStream{
+ mu: &mu,
+ events: &events,
+ ackCh: ackCh,
+ }
+ conn := &proxyConnection{
+ proxyID: "proxy-a",
+ address: cluster,
+ syncStream: stream,
+ }
+
+ // Feed acks asynchronously after a short delay to simulate real proxy.
+ go func() {
+ for range 3 {
+ time.Sleep(10 * time.Millisecond)
+ ackCh <- struct{}{}
+ }
+ }()
+
+ err := s.sendSnapshotSync(context.Background(), conn, stream)
+ require.NoError(t, err)
+
+ mu.Lock()
+ defer mu.Unlock()
+
+ // Expected: send, recv-ack, send, recv-ack, send, recv-ack.
+ require.Len(t, events, 6)
+ assert.Equal(t, "send", events[0])
+ assert.Equal(t, "recv", events[1])
+ assert.Equal(t, "send", events[2])
+ assert.Equal(t, "recv", events[3])
+ assert.Equal(t, "send", events[4])
+ assert.Equal(t, "recv", events[5])
+}
+
+// orderTrackingStream logs "send" and "recv" events and blocks Recv until
+// an ack is signaled via ackCh.
+type orderTrackingStream struct {
+ grpc.ServerStream
+ mu *sync.Mutex
+ events *[]string
+ ackCh chan struct{}
+}
+
+func (s *orderTrackingStream) Send(_ *proto.SyncMappingsResponse) error {
+ s.mu.Lock()
+ *s.events = append(*s.events, "send")
+ s.mu.Unlock()
+ return nil
+}
+
+func (s *orderTrackingStream) Recv() (*proto.SyncMappingsRequest, error) {
+ <-s.ackCh
+ s.mu.Lock()
+ *s.events = append(*s.events, "recv")
+ s.mu.Unlock()
+ return ackMsg(), nil
+}
+
+func (s *orderTrackingStream) Context() context.Context { return context.Background() }
+func (s *orderTrackingStream) SetHeader(metadata.MD) error { return nil }
+func (s *orderTrackingStream) SendHeader(metadata.MD) error { return nil }
+func (s *orderTrackingStream) SetTrailer(metadata.MD) {}
+func (s *orderTrackingStream) SendMsg(any) error { return nil }
+func (s *orderTrackingStream) RecvMsg(any) error { return nil }
+
+func TestSendSnapshotSync_TokensGeneratedPerBatch(t *testing.T) {
+ const cluster = "cluster.example.com"
+ const batchSize = 2
+ const totalServices = 4
+ const ttl = 100 * time.Millisecond
+ const ackDelay = 200 * time.Millisecond
+
+ ctrl := gomock.NewController(t)
+ mgr := rpservice.NewMockManager(ctrl)
+ mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil)
+
+ s := newSnapshotTestServer(t, batchSize)
+ s.serviceManager = mgr
+ s.tokenTTL = ttl
+
+ // Build a stream that validates tokens immediately on Send, then
+ // delays the ack to ensure the next batch's tokens are generated fresh.
+ var validateErrs []error
+ ackCh := make(chan struct{}, 2)
+ stream := &tokenValidatingSyncStream{
+ tokenStore: s.tokenStore,
+ validateErrs: &validateErrs,
+ ackCh: ackCh,
+ }
+ conn := &proxyConnection{
+ proxyID: "proxy-a",
+ address: cluster,
+ syncStream: stream,
+ }
+
+ go func() {
+ // Delay first ack so that if tokens were all generated upfront they'd expire.
+ time.Sleep(ackDelay)
+ ackCh <- struct{}{}
+ // Final batch ack — immediate.
+ ackCh <- struct{}{}
+ }()
+
+ err := s.sendSnapshotSync(context.Background(), conn, stream)
+ require.NoError(t, err)
+ require.Empty(t, validateErrs,
+ "tokens must remain valid: per-batch generation guarantees freshness")
+}
+
+type tokenValidatingSyncStream struct {
+ grpc.ServerStream
+ tokenStore *OneTimeTokenStore
+ validateErrs *[]error
+ ackCh chan struct{}
+}
+
+func (s *tokenValidatingSyncStream) Send(m *proto.SyncMappingsResponse) error {
+ for _, mapping := range m.Mapping {
+ if err := s.tokenStore.ValidateAndConsume(mapping.AuthToken, mapping.AccountId, mapping.Id); err != nil {
+ *s.validateErrs = append(*s.validateErrs, fmt.Errorf("svc %s: %w", mapping.Id, err))
+ }
+ }
+ return nil
+}
+
+func (s *tokenValidatingSyncStream) Recv() (*proto.SyncMappingsRequest, error) {
+ <-s.ackCh
+ return ackMsg(), nil
+}
+
+func (s *tokenValidatingSyncStream) Context() context.Context { return context.Background() }
+func (s *tokenValidatingSyncStream) SetHeader(metadata.MD) error { return nil }
+func (s *tokenValidatingSyncStream) SendHeader(metadata.MD) error { return nil }
+func (s *tokenValidatingSyncStream) SetTrailer(metadata.MD) {}
+func (s *tokenValidatingSyncStream) SendMsg(any) error { return nil }
+func (s *tokenValidatingSyncStream) RecvMsg(any) error { return nil }
+
+func TestConnectionSendResponse_RoutesToSyncStream(t *testing.T) {
+ stream := &syncRecordingStream{}
+ conn := &proxyConnection{
+ syncStream: stream,
+ }
+
+ resp := &proto.GetMappingUpdateResponse{
+ Mapping: []*proto.ProxyMapping{
+ {Id: "svc-1", AccountId: "acct-1", Domain: "example.com"},
+ },
+ InitialSyncComplete: true,
+ }
+
+ err := conn.sendResponse(resp)
+ require.NoError(t, err)
+
+ require.Len(t, stream.sent, 1)
+ assert.Len(t, stream.sent[0].Mapping, 1)
+ assert.Equal(t, "svc-1", stream.sent[0].Mapping[0].Id)
+ assert.True(t, stream.sent[0].InitialSyncComplete)
+}
+
+func TestConnectionSendResponse_RoutesToLegacyStream(t *testing.T) {
+ stream := &recordingStream{}
+ conn := &proxyConnection{
+ stream: stream,
+ }
+
+ resp := &proto.GetMappingUpdateResponse{
+ Mapping: []*proto.ProxyMapping{
+ {Id: "svc-2", AccountId: "acct-2"},
+ },
+ }
+
+ err := conn.sendResponse(resp)
+ require.NoError(t, err)
+
+ require.Len(t, stream.messages, 1)
+ assert.Equal(t, "svc-2", stream.messages[0].Mapping[0].Id)
+}
diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go
index 774c5d1d3..27d9a65e7 100644
--- a/management/internals/shared/grpc/validate_session_test.go
+++ b/management/internals/shared/grpc/validate_session_test.go
@@ -102,7 +102,7 @@ func generateSessionKeyPair(t *testing.T) (string, string) {
func createSessionToken(t *testing.T, privKeyB64, userID, domain string) string {
t.Helper()
- token, err := sessionkey.SignToken(privKeyB64, userID, domain, auth.MethodOIDC, time.Hour)
+ token, err := sessionkey.SignToken(privKeyB64, userID, "", domain, auth.MethodOIDC, nil, nil, time.Hour)
require.NoError(t, err)
return token
}
@@ -125,6 +125,7 @@ func TestValidateSession_UserAllowed(t *testing.T) {
assert.True(t, resp.Valid, "User should be allowed access")
assert.Equal(t, "allowedUserId", resp.UserId)
assert.Empty(t, resp.DeniedReason)
+ assert.Equal(t, []string{"allowedGroupId"}, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's group memberships")
}
func TestValidateSession_UserNotInAllowedGroup(t *testing.T) {
@@ -145,6 +146,7 @@ func TestValidateSession_UserNotInAllowedGroup(t *testing.T) {
assert.False(t, resp.Valid, "User not in group should be denied")
assert.Equal(t, "not_in_group", resp.DeniedReason)
assert.Equal(t, "nonGroupUserId", resp.UserId)
+ assert.Empty(t, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's actual (empty) memberships on denial")
}
func TestValidateSession_UserInDifferentAccount(t *testing.T) {
@@ -392,6 +394,10 @@ func (m *testValidateSessionProxyManager) ClusterSupportsCrowdSec(_ context.Cont
return nil
}
+func (m *testValidateSessionProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
+ return nil
+}
+
type testValidateSessionUsersManager struct {
store store.Store
}
@@ -399,3 +405,24 @@ type testValidateSessionUsersManager struct {
func (m *testValidateSessionUsersManager) GetUser(ctx context.Context, userID string) (*types.User, error) {
return m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
}
+
+func (m *testValidateSessionUsersManager) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) {
+ user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
+ if err != nil {
+ return nil, nil, err
+ }
+ if len(user.AutoGroups) == 0 {
+ return user, nil, nil
+ }
+ groupsMap, err := m.store.GetGroupsByIDs(ctx, store.LockingStrengthNone, user.AccountID, user.AutoGroups)
+ if err != nil {
+ return nil, nil, err
+ }
+ groups := make([]*types.Group, 0, len(user.AutoGroups))
+ for _, id := range user.AutoGroups {
+ if g, ok := groupsMap[id]; ok && g != nil {
+ groups = append(groups, g)
+ }
+ }
+ return user, groups, nil
+}
diff --git a/management/server/account.go b/management/server/account.go
index 8e4e595f0..f16717857 100644
--- a/management/server/account.go
+++ b/management/server/account.go
@@ -282,7 +282,7 @@ func (am *DefaultAccountManager) GetIdpManager() idp.Manager {
// User that performs the update has to belong to the account.
// Returns an updated Settings
func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update)
if err != nil {
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
}
@@ -355,7 +355,17 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
oldSettings.LazyConnectionEnabled != newSettings.LazyConnectionEnabled ||
oldSettings.DNSDomain != newSettings.DNSDomain ||
oldSettings.AutoUpdateVersion != newSettings.AutoUpdateVersion ||
- oldSettings.AutoUpdateAlways != newSettings.AutoUpdateAlways {
+ oldSettings.AutoUpdateAlways != newSettings.AutoUpdateAlways ||
+ oldSettings.PeerLoginExpirationEnabled != newSettings.PeerLoginExpirationEnabled ||
+ oldSettings.PeerLoginExpiration != newSettings.PeerLoginExpiration {
+ // Session deadline is derived from LastLogin + PeerLoginExpiration
+ // on every Login/Sync response. Without a fan-out push, connected
+ // peers keep the deadline they received at login time and only see
+ // the new value after the next unrelated NetworkMap change. Add
+ // these two fields to the trigger list so admin-side expiry tweaks
+ // (e.g. shortening from 24h to 1h) reach every connected peer
+ // within seconds, which is what the proactive-warning feature
+ // relies on (see client/internal/auth/sessionwatch).
updateAccountPeers = true
}
@@ -845,7 +855,7 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u
return err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Delete)
if err != nil {
return fmt.Errorf("failed to validate user permissions: %w", err)
}
@@ -1412,7 +1422,7 @@ func (am *DefaultAccountManager) GetAccount(ctx context.Context, accountID strin
// GetAccountByID returns an account associated with this account ID.
func (am *DefaultAccountManager) GetAccountByID(ctx context.Context, accountID string, userID string) (*types.Account, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1425,7 +1435,7 @@ func (am *DefaultAccountManager) GetAccountByID(ctx context.Context, accountID s
// GetAccountMeta returns the account metadata associated with this account ID.
func (am *DefaultAccountManager) GetAccountMeta(ctx context.Context, accountID string, userID string) (*types.AccountMeta, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1438,7 +1448,7 @@ func (am *DefaultAccountManager) GetAccountMeta(ctx context.Context, accountID s
// GetAccountOnboarding retrieves the onboarding information for a specific account.
func (am *DefaultAccountManager) GetAccountOnboarding(ctx context.Context, accountID string, userID string) (*types.AccountOnboarding, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1463,7 +1473,7 @@ func (am *DefaultAccountManager) GetAccountOnboarding(ctx context.Context, accou
}
func (am *DefaultAccountManager) UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update)
if err != nil {
return nil, fmt.Errorf("failed to validate user permissions: %w", err)
}
@@ -1530,7 +1540,8 @@ func (am *DefaultAccountManager) GetAccountIDFromUserAuth(ctx context.Context, u
return accountID, user.Id, nil
}
- if err := am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false); err != nil {
+ ctx, err = am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false)
+ if err != nil {
return "", "", err
}
@@ -1976,7 +1987,7 @@ func (am *DefaultAccountManager) handleUserPeer(ctx context.Context, transaction
}
func (am *DefaultAccountManager) GetAccountSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -2544,7 +2555,7 @@ func (am *DefaultAccountManager) validateIPForUpdate(account *types.Account, pee
}
func (am *DefaultAccountManager) UpdatePeerIP(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
if err != nil {
return fmt.Errorf("validate user permissions: %w", err)
}
@@ -2634,7 +2645,7 @@ func (am *DefaultAccountManager) savePeerIPUpdate(ctx context.Context, transacti
// UpdatePeerIPv6 updates the IPv6 overlay address of a peer, validating it's
// within the account's v6 network range and not already taken.
func (am *DefaultAccountManager) UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
if err != nil {
return fmt.Errorf("validate user permissions: %w", err)
}
diff --git a/management/server/account/manager.go b/management/server/account/manager.go
index ae3de8d79..b7b159915 100644
--- a/management/server/account/manager.go
+++ b/management/server/account/manager.go
@@ -109,6 +109,7 @@ type Manager interface {
UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error)
UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error)
LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) // used by peer gRPC API
+ ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) // used by peer gRPC API for ExtendAuthSession
SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) // used by peer gRPC API
GetExternalCacheManager() ExternalCacheManager
GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error)
diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go
index 0486e63ec..81127a6b4 100644
--- a/management/server/account/manager_mock.go
+++ b/management/server/account/manager_mock.go
@@ -1304,6 +1304,21 @@ func (mr *MockManagerMockRecorder) LoginPeer(ctx, login interface{}) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoginPeer", reflect.TypeOf((*MockManager)(nil).LoginPeer), ctx, login)
}
+// ExtendPeerSession mocks base method.
+func (m *MockManager) ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ExtendPeerSession", ctx, peerPubKey, userID)
+ ret0, _ := ret[0].(time.Time)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// ExtendPeerSession indicates an expected call of ExtendPeerSession.
+func (mr *MockManagerMockRecorder) ExtendPeerSession(ctx, peerPubKey, userID interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtendPeerSession", reflect.TypeOf((*MockManager)(nil).ExtendPeerSession), ctx, peerPubKey, userID)
+}
+
// MarkPeerConnected mocks base method.
func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error {
m.ctrl.T.Helper()
diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go
index 6c781a952..852193a3b 100644
--- a/management/server/activity/codes.go
+++ b/management/server/activity/codes.go
@@ -240,6 +240,10 @@ const (
AccountLocalMfaEnabled Activity = 123
// AccountLocalMfaDisabled indicates that a user disabled TOTP MFA for local users
AccountLocalMfaDisabled Activity = 124
+ // UserExtendedPeerSession indicates that a user refreshed their peer's
+ // SSO session deadline via ExtendAuthSession without re-establishing the
+ // tunnel. Distinct from UserLoggedInPeer (full interactive login).
+ UserExtendedPeerSession Activity = 125
AccountDeleted Activity = 99999
)
@@ -394,6 +398,8 @@ var activityMap = map[Activity]Code{
AccountLocalMfaEnabled: {"Account local MFA enabled", "account.setting.local.mfa.enable"},
AccountLocalMfaDisabled: {"Account local MFA disabled", "account.setting.local.mfa.disable"},
+ UserExtendedPeerSession: {"User extended peer session", "user.peer.session.extend"},
+
DomainAdded: {"Domain added", "domain.add"},
DomainDeleted: {"Domain deleted", "domain.delete"},
DomainValidated: {"Domain validated", "domain.validate"},
diff --git a/management/server/context/keys.go b/management/server/context/keys.go
index 9697997a8..aa534c5d9 100644
--- a/management/server/context/keys.go
+++ b/management/server/context/keys.go
@@ -1,10 +1,28 @@
package context
-import "github.com/netbirdio/netbird/shared/context"
+import (
+ "context"
+
+ nbcontext "github.com/netbirdio/netbird/shared/context"
+)
const (
- RequestIDKey = context.RequestIDKey
- AccountIDKey = context.AccountIDKey
- UserIDKey = context.UserIDKey
- PeerIDKey = context.PeerIDKey
+ RequestIDKey = nbcontext.RequestIDKey
+ AccountIDKey = nbcontext.AccountIDKey
+ RoleKey = nbcontext.RoleKey
+ UserIDKey = nbcontext.UserIDKey
+ PeerIDKey = nbcontext.PeerIDKey
+ UserAgentKey = nbcontext.UserAgentKey
)
+
+// RoleFromContext returns the role stored in ctx, or empty string and false if absent.
+func RoleFromContext(ctx context.Context) (string, bool) {
+ role, ok := ctx.Value(RoleKey).(string)
+ return role, ok
+}
+
+// WithRole returns a new context carrying the given role.
+func WithRole(ctx context.Context, role string) context.Context {
+ //nolint
+ return context.WithValue(ctx, RoleKey, role)
+}
diff --git a/management/server/dns.go b/management/server/dns.go
index c62fa5185..dcc3f21c7 100644
--- a/management/server/dns.go
+++ b/management/server/dns.go
@@ -22,7 +22,7 @@ const (
// GetDNSSettings validates a user role and returns the DNS settings for the provided account ID
func (am *DefaultAccountManager) GetDNSSettings(ctx context.Context, accountID string, userID string) (*types.DNSSettings, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -39,7 +39,7 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID
return status.Errorf(status.InvalidArgument, "the dns settings provided are nil")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/event.go b/management/server/event.go
index d26c569ae..4211f2dda 100644
--- a/management/server/event.go
+++ b/management/server/event.go
@@ -23,7 +23,7 @@ func isEnabled() bool {
// GetEvents returns a list of activity events of an account
func (am *DefaultAccountManager) GetEvents(ctx context.Context, accountID, userID string) ([]*activity.Event, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Events, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Events, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/group.go b/management/server/group.go
index 870a441ac..7e02af245 100644
--- a/management/server/group.go
+++ b/management/server/group.go
@@ -32,7 +32,7 @@ func (e *GroupLinkError) Error() string {
// CheckGroupPermissions validates if a user has the necessary permissions to view groups
func (am *DefaultAccountManager) CheckGroupPermissions(ctx context.Context, accountID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read)
+ allowed, _, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read)
if err != nil {
return err
}
@@ -70,7 +70,7 @@ func (am *DefaultAccountManager) GetGroupByName(ctx context.Context, groupName,
// CreateGroup object of the peers
func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, userID string, newGroup *types.Group) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -125,7 +125,7 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use
// UpdateGroup object of the peers
func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, userID string, newGroup *types.Group) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -200,7 +200,7 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
// It is the caller's responsibility to ensure proper locking is in place before invoking this method.
// This method will not create group peer membership relations. Use AddPeerToGroup or RemovePeerFromGroup methods for that.
func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, userID string, groups []*types.Group) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -268,7 +268,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us
// It is the caller's responsibility to ensure proper locking is in place before invoking this method.
// This method will not create group peer membership relations. Use AddPeerToGroup or RemovePeerFromGroup methods for that.
func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, userID string, groups []*types.Group) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -427,7 +427,7 @@ func (am *DefaultAccountManager) DeleteGroup(ctx context.Context, accountID, use
// If an error occurs while deleting a group, the function skips it and continues deleting other groups.
// Errors are collected and returned at the end.
func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, userID string, groupIDs []string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/groups/manager.go b/management/server/groups/manager.go
index d110ab564..c9a877d6f 100644
--- a/management/server/groups/manager.go
+++ b/management/server/groups/manager.go
@@ -42,7 +42,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, accou
}
func (m *managerImpl) GetAllGroups(ctx context.Context, accountID, userID string) ([]*types.Group, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read)
if err != nil {
return nil, err
}
@@ -73,7 +73,7 @@ func (m *managerImpl) GetAllGroupsMap(ctx context.Context, accountID, userID str
}
func (m *managerImpl) AddResourceToGroup(ctx context.Context, accountID, userID, groupID string, resource *types.Resource) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update)
if err != nil {
return err
}
diff --git a/management/server/http/handler.go b/management/server/http/handler.go
index 1e2c710db..0abdb854d 100644
--- a/management/server/http/handler.go
+++ b/management/server/http/handler.go
@@ -15,15 +15,13 @@ import (
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
- "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxytoken"
+ "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
idpmanager "github.com/netbirdio/netbird/management/server/idp"
- "github.com/netbirdio/management-integrations/integrations"
-
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/internals/modules/zones"
zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager"
@@ -32,12 +30,10 @@ import (
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/settings"
- "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/http/handlers/proxy"
- nbpeers "github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/server/auth"
"github.com/netbirdio/netbird/management/server/geolocation"
nbgroups "github.com/netbirdio/netbird/management/server/groups"
@@ -56,17 +52,14 @@ import (
"github.com/netbirdio/netbird/management/server/http/middleware"
"github.com/netbirdio/netbird/management/server/http/middleware/bypass"
nbinstance "github.com/netbirdio/netbird/management/server/instance"
- "github.com/netbirdio/netbird/management/server/integrations/integrated_validator"
nbnetworks "github.com/netbirdio/netbird/management/server/networks"
"github.com/netbirdio/netbird/management/server/networks/resources"
"github.com/netbirdio/netbird/management/server/networks/routers"
"github.com/netbirdio/netbird/management/server/telemetry"
)
-const apiPrefix = "/api"
-
// NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints.
-func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *middleware.APIRateLimiter) (http.Handler, error) {
+func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *middleware.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc) (http.Handler, error) {
// Register bypass paths for unauthenticated endpoints
if err := bypass.AddBypassPath("/api/instance"); err != nil {
@@ -100,25 +93,16 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
accountManager.GetUserFromUserAuth,
rateLimiter,
appMetrics.GetMeter(),
+ isValidChildAccount,
)
corsMiddleware := cors.AllowAll()
- rootRouter := mux.NewRouter()
metricsMiddleware := appMetrics.HTTPMiddleware()
- prefix := apiPrefix
- router := rootRouter.PathPrefix(prefix).Subrouter()
-
router.Use(metricsMiddleware.Handler, corsMiddleware.Handler, authMiddleware.Handler)
- if _, err := integrations.RegisterHandlers(ctx, prefix, router, accountManager, integratedValidator, appMetrics.GetMeter(), permissionsManager, peersManager, proxyController, settingsManager); err != nil {
- return nil, fmt.Errorf("register integrations endpoints: %w", err)
- }
-
- // Check if embedded IdP is enabled for instance manager
- embeddedIdP, embeddedIdpEnabled := idpManager.(*idpmanager.EmbeddedIdPManager)
- instanceManager, err := nbinstance.NewManager(ctx, accountManager.GetStore(), embeddedIdP)
+ instanceManager, err := nbinstance.NewManager(ctx, accountManager.GetStore(), idpManager)
if err != nil {
return nil, fmt.Errorf("failed to create instance manager: %w", err)
}
@@ -154,10 +138,5 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
oauthHandler.RegisterEndpoints(router)
}
- // Mount embedded IdP handler at /oauth2 path if configured
- if embeddedIdpEnabled {
- rootRouter.PathPrefix("/oauth2").Handler(corsMiddleware.Handler(embeddedIdP.Handler()))
- }
-
- return rootRouter, nil
+ return router, nil
}
diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go
index 28f414309..7dfbf093d 100644
--- a/management/server/http/handlers/peers/peers_handler.go
+++ b/management/server/http/handlers/peers/peers_handler.go
@@ -407,48 +407,48 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) {
return
}
- allowed, err := h.permissionsManager.ValidateUserPermissions(r.Context(), accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), accountID, userID, modules.Peers, operations.Read)
if err != nil {
- util.WriteError(r.Context(), status.NewPermissionValidationError(err), w)
+ util.WriteError(ctx, status.NewPermissionValidationError(err), w)
return
}
- account, err := h.accountManager.GetAccountByID(r.Context(), accountID, activity.SystemInitiator)
+ account, err := h.accountManager.GetAccountByID(ctx, accountID, activity.SystemInitiator)
if err != nil {
- util.WriteError(r.Context(), err, w)
+ util.WriteError(ctx, err, w)
return
}
if !allowed && !userAuth.IsChild {
if account.Settings.RegularUsersViewBlocked {
- util.WriteJSONObject(r.Context(), w, []api.AccessiblePeer{})
+ util.WriteJSONObject(ctx, w, []api.AccessiblePeer{})
return
}
peer, ok := account.Peers[peerID]
if !ok {
- util.WriteError(r.Context(), status.Errorf(status.NotFound, "peer not found"), w)
+ util.WriteError(ctx, status.Errorf(status.NotFound, "peer not found"), w)
return
}
if peer.UserID != user.Id {
- util.WriteJSONObject(r.Context(), w, []api.AccessiblePeer{})
+ util.WriteJSONObject(ctx, w, []api.AccessiblePeer{})
return
}
}
- validPeers, _, err := h.accountManager.GetValidatedPeers(r.Context(), accountID)
+ validPeers, _, err := h.accountManager.GetValidatedPeers(ctx, accountID)
if err != nil {
- log.WithContext(r.Context()).Errorf("failed to list approved peers: %v", err)
- util.WriteError(r.Context(), fmt.Errorf("internal error"), w)
+ log.WithContext(ctx).Errorf("failed to list approved peers: %v", err)
+ util.WriteError(ctx, fmt.Errorf("internal error"), w)
return
}
dnsDomain := h.networkMapController.GetDNSDomain(account.Settings)
- netMap := account.GetPeerNetworkMapFromComponents(r.Context(), peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
+ netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
- util.WriteJSONObject(r.Context(), w, toAccessiblePeers(netMap, dnsDomain))
+ util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, dnsDomain))
}
func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) {
@@ -470,18 +470,20 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request)
// Policies.Create permissions, but checking up-front means a future
// refactor that bypasses one of those calls can't silently widen the
// endpoint's authority.
- if allowed, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Peers, operations.Create); err != nil {
- util.WriteError(r.Context(), status.NewPermissionValidationError(err), w)
+ allowed, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Peers, operations.Create)
+ if err != nil {
+ util.WriteError(ctx, status.NewPermissionValidationError(err), w)
return
} else if !allowed {
- util.WriteError(r.Context(), status.NewPermissionDeniedError(), w)
+ util.WriteError(ctx, status.NewPermissionDeniedError(), w)
return
}
- if allowed, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Policies, operations.Create); err != nil {
- util.WriteError(r.Context(), status.NewPermissionValidationError(err), w)
+ allowed, ctx, err = h.permissionsManager.ValidateUserPermissions(ctx, userAuth.AccountId, userAuth.UserId, modules.Policies, operations.Create)
+ if err != nil {
+ util.WriteError(ctx, status.NewPermissionValidationError(err), w)
return
} else if !allowed {
- util.WriteError(r.Context(), status.NewPermissionDeniedError(), w)
+ util.WriteError(ctx, status.NewPermissionDeniedError(), w)
return
}
diff --git a/management/server/http/handlers/peers/peers_handler_test.go b/management/server/http/handlers/peers/peers_handler_test.go
index 9db095c8d..047213879 100644
--- a/management/server/http/handlers/peers/peers_handler_test.go
+++ b/management/server/http/handlers/peers/peers_handler_test.go
@@ -116,15 +116,15 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
ctrl2 := gomock.NewController(t)
permissionsManager := permissions.NewMockManager(ctrl2)
- permissionsManager.EXPECT().ValidateAccountAccess(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+ permissionsManager.EXPECT().ValidateAccountAccess(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(context.Background(), nil).AnyTimes()
permissionsManager.EXPECT().
ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).
- DoAndReturn(func(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, error) {
+ DoAndReturn(func(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error) {
user, ok := account.Users[userID]
if !ok {
- return false, fmt.Errorf("user not found")
+ return false, ctx, fmt.Errorf("user not found")
}
- return user.HasAdminPower() || user.IsServiceUser, nil
+ return user.HasAdminPower() || user.IsServiceUser, ctx, nil
}).
AnyTimes()
diff --git a/management/server/http/handlers/policies/geolocation_handler_test.go b/management/server/http/handlers/policies/geolocation_handler_test.go
index 094a36e38..f5723b8fc 100644
--- a/management/server/http/handlers/policies/geolocation_handler_test.go
+++ b/management/server/http/handlers/policies/geolocation_handler_test.go
@@ -51,7 +51,7 @@ func initGeolocationTestData(t *testing.T) *geolocationsHandler {
permissionsManagerMock.
EXPECT().
ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), modules.Policies, operations.Read).
- Return(true, nil).
+ Return(true, context.Background(), nil).
AnyTimes()
return &geolocationsHandler{
diff --git a/management/server/http/middleware/auth_middleware.go b/management/server/http/middleware/auth_middleware.go
index 6d075d9c2..34df0de23 100644
--- a/management/server/http/middleware/auth_middleware.go
+++ b/management/server/http/middleware/auth_middleware.go
@@ -11,8 +11,6 @@ import (
log "github.com/sirupsen/logrus"
"go.opentelemetry.io/otel/metric"
- "github.com/netbirdio/management-integrations/integrations"
-
serverauth "github.com/netbirdio/netbird/management/server/auth"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/http/middleware/bypass"
@@ -27,6 +25,8 @@ type SyncUserJWTGroupsFunc func(ctx context.Context, userAuth auth.UserAuth) err
type GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error)
+type IsValidChildAccountFunc func(ctx context.Context, userID, accountID, childAccountID string) bool
+
// AuthMiddleware middleware to verify personal access tokens (PAT) and JWT tokens
type AuthMiddleware struct {
authManager serverauth.Manager
@@ -35,6 +35,7 @@ type AuthMiddleware struct {
syncUserJWTGroups SyncUserJWTGroupsFunc
rateLimiter *APIRateLimiter
patUsageTracker *PATUsageTracker
+ isValidChildAccount IsValidChildAccountFunc
}
// NewAuthMiddleware instance constructor
@@ -45,6 +46,7 @@ func NewAuthMiddleware(
getUserFromUserAuth GetUserFromUserAuthFunc,
rateLimiter *APIRateLimiter,
meter metric.Meter,
+ isValidChildAccount IsValidChildAccountFunc,
) *AuthMiddleware {
var patUsageTracker *PATUsageTracker
if meter != nil {
@@ -62,6 +64,7 @@ func NewAuthMiddleware(
getUserFromUserAuth: getUserFromUserAuth,
rateLimiter: rateLimiter,
patUsageTracker: patUsageTracker,
+ isValidChildAccount: isValidChildAccount,
}
}
@@ -124,7 +127,7 @@ func (m *AuthMiddleware) checkJWTFromRequest(r *http.Request, authHeaderParts []
}
if impersonate, ok := r.URL.Query()["account"]; ok && len(impersonate) == 1 {
- if integrations.IsValidChildAccount(ctx, userAuth.UserId, userAuth.AccountId, impersonate[0]) {
+ if m.isValidChildAccount(ctx, userAuth.UserId, userAuth.AccountId, impersonate[0]) {
userAuth.AccountId = impersonate[0]
userAuth.IsChild = true
}
@@ -203,7 +206,7 @@ func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, authHeaderParts []
}
if impersonate, ok := r.URL.Query()["account"]; ok && len(impersonate) == 1 {
- if integrations.IsValidChildAccount(r.Context(), userAuth.UserId, userAuth.AccountId, impersonate[0]) {
+ if m.isValidChildAccount(r.Context(), userAuth.UserId, userAuth.AccountId, impersonate[0]) {
userAuth.AccountId = impersonate[0]
userAuth.IsChild = true
}
diff --git a/management/server/http/middleware/auth_middleware_test.go b/management/server/http/middleware/auth_middleware_test.go
index 8f736fbfd..24cf8fce5 100644
--- a/management/server/http/middleware/auth_middleware_test.go
+++ b/management/server/http/middleware/auth_middleware_test.go
@@ -211,6 +211,7 @@ func TestAuthMiddleware_Handler(t *testing.T) {
},
disabledLimiter,
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handlerToTest := authMiddleware.Handler(nextHandler)
@@ -270,6 +271,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
},
NewAPIRateLimiter(rateLimitConfig),
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -322,6 +324,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
},
NewAPIRateLimiter(rateLimitConfig),
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -365,6 +368,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
},
NewAPIRateLimiter(rateLimitConfig),
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -409,6 +413,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
},
NewAPIRateLimiter(rateLimitConfig),
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -473,6 +478,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
},
NewAPIRateLimiter(rateLimitConfig),
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -532,6 +538,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
},
NewAPIRateLimiter(rateLimitConfig),
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -587,6 +594,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
},
NewAPIRateLimiter(rateLimitConfig),
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -687,6 +695,7 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) {
},
disabledLimiter,
nil,
+ func(_ context.Context, _, _, _ string) bool { return false },
)
for _, tc := range tt {
diff --git a/management/server/http/testing/integration/networks_handler_integration_test.go b/management/server/http/testing/integration/networks_handler_integration_test.go
index 54f204a8f..a0a49a9ec 100644
--- a/management/server/http/testing/integration/networks_handler_integration_test.go
+++ b/management/server/http/testing/integration/networks_handler_integration_test.go
@@ -1319,7 +1319,7 @@ func Test_NetworkRouters_Update(t *testing.T) {
},
},
{
- name: "Update non-existing router creates it",
+ name: "Update non-existing router returns not found",
networkId: "testNetworkId",
routerId: "nonExistingRouterId",
requestBody: &api.NetworkRouterRequest{
@@ -1328,11 +1328,7 @@ func Test_NetworkRouters_Update(t *testing.T) {
Metric: 100,
Enabled: true,
},
- expectedStatus: http.StatusOK,
- verifyResponse: func(t *testing.T, router *api.NetworkRouter) {
- t.Helper()
- assert.Equal(t, "nonExistingRouterId", router.Id)
- },
+ expectedStatus: http.StatusNotFound,
},
{
name: "Update router with both peer and peer_groups",
diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go
index 3c4ea98d0..8da9c7ad4 100644
--- a/management/server/http/testing/testing_tools/channel/channel.go
+++ b/management/server/http/testing/testing_tools/channel/channel.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/golang-jwt/jwt/v5"
+ "github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"go.opentelemetry.io/otel/metric/noop"
@@ -135,7 +136,8 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
customZonesManager := zonesManager.NewManager(store, am, permissionsManager, "")
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
- apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil)
+ apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
+ apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil)
if err != nil {
t.Fatalf("Failed to create API handler: %v", err)
}
@@ -264,7 +266,8 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
customZonesManager := zonesManager.NewManager(store, am, permissionsManager, "")
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
- apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil)
+ apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
+ apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil)
if err != nil {
t.Fatalf("Failed to create API handler: %v", err)
}
diff --git a/management/server/identity_provider.go b/management/server/identity_provider.go
index f965f36b8..86bbcd893 100644
--- a/management/server/identity_provider.go
+++ b/management/server/identity_provider.go
@@ -88,7 +88,7 @@ func validateIdentityProviderConfig(ctx context.Context, idpConfig *types.Identi
// GetIdentityProviders returns all identity providers for an account
func (am *DefaultAccountManager) GetIdentityProviders(ctx context.Context, accountID, userID string) ([]*types.IdentityProvider, error) {
- ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -117,7 +117,7 @@ func (am *DefaultAccountManager) GetIdentityProviders(ctx context.Context, accou
// GetIdentityProvider returns a specific identity provider by ID
func (am *DefaultAccountManager) GetIdentityProvider(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) {
- ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -143,7 +143,7 @@ func (am *DefaultAccountManager) GetIdentityProvider(ctx context.Context, accoun
// CreateIdentityProvider creates a new identity provider
func (am *DefaultAccountManager) CreateIdentityProvider(ctx context.Context, accountID, userID string, idpConfig *types.IdentityProvider) (*types.IdentityProvider, error) {
- ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Create)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -180,7 +180,7 @@ func (am *DefaultAccountManager) CreateIdentityProvider(ctx context.Context, acc
// UpdateIdentityProvider updates an existing identity provider
func (am *DefaultAccountManager) UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idpConfig *types.IdentityProvider) (*types.IdentityProvider, error) {
- ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Update)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -213,7 +213,7 @@ func (am *DefaultAccountManager) UpdateIdentityProvider(ctx context.Context, acc
// DeleteIdentityProvider deletes an identity provider
func (am *DefaultAccountManager) DeleteIdentityProvider(ctx context.Context, accountID, idpID, userID string) error {
- ok, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Delete)
+ ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/integrations/integrated_validator/validator/validator.go b/management/server/integrations/integrated_validator/validator/validator.go
new file mode 100644
index 000000000..db1d34373
--- /dev/null
+++ b/management/server/integrations/integrated_validator/validator/validator.go
@@ -0,0 +1,62 @@
+package validator
+
+import (
+ "context"
+
+ cachestore "github.com/eko/gocache/lib/v4/store"
+
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/server/activity"
+ nbpeer "github.com/netbirdio/netbird/management/server/peer"
+ "github.com/netbirdio/netbird/management/server/settings"
+ "github.com/netbirdio/netbird/management/server/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+type IntegratedValidatorImpl struct{}
+
+func NewIntegratedValidator(_ context.Context, _ peers.Manager, _ settings.Manager, _ activity.Store, _ cachestore.StoreInterface) (*IntegratedValidatorImpl, error) {
+ return &IntegratedValidatorImpl{}, nil
+}
+
+func (v *IntegratedValidatorImpl) ValidateExtraSettings(context.Context, *types.ExtraSettings, *types.ExtraSettings, string, string) error {
+ return nil
+}
+
+func (v *IntegratedValidatorImpl) ValidatePeer(_ context.Context, update *nbpeer.Peer, _ *nbpeer.Peer, _ string, _ string, _ string, _ []string, _ *types.ExtraSettings) (*nbpeer.Peer, bool, error) {
+ return update, false, nil
+}
+
+func (v *IntegratedValidatorImpl) PreparePeer(_ context.Context, _ string, peer *nbpeer.Peer, _ []string, _ *types.ExtraSettings, _ bool) *nbpeer.Peer {
+ return peer.Copy()
+}
+
+func (v *IntegratedValidatorImpl) IsNotValidPeer(_ context.Context, _ string, _ *nbpeer.Peer, _ []string, _ *types.ExtraSettings) (bool, bool, error) {
+ return false, false, nil
+}
+
+func (v *IntegratedValidatorImpl) GetValidatedPeers(_ context.Context, _ string, _ []*types.Group, peers []*nbpeer.Peer, _ *types.ExtraSettings) (map[string]struct{}, error) {
+ validatedPeers := make(map[string]struct{})
+ for _, p := range peers {
+ validatedPeers[p.ID] = struct{}{}
+ }
+ return validatedPeers, nil
+}
+
+func (v *IntegratedValidatorImpl) GetInvalidPeers(_ context.Context, _ string, _ *types.ExtraSettings) (map[string]string, error) {
+ return make(map[string]string), nil
+}
+
+func (v *IntegratedValidatorImpl) PeerDeleted(_ context.Context, _, _ string, _ *types.ExtraSettings) error {
+ return nil
+}
+
+func (v *IntegratedValidatorImpl) SetPeerInvalidationListener(_ func(accountID string, peerIDs []string)) {
+}
+
+func (v *IntegratedValidatorImpl) Stop(_ context.Context) {
+}
+
+func (v *IntegratedValidatorImpl) ValidateFlowResponse(_ context.Context, _ string, flowResponse *proto.PKCEAuthorizationFlow) *proto.PKCEAuthorizationFlow {
+ return flowResponse
+}
diff --git a/management/server/metrics/selfhosted.go b/management/server/metrics/selfhosted.go
index 8732cf89f..efe50c88f 100644
--- a/management/server/metrics/selfhosted.go
+++ b/management/server/metrics/selfhosted.go
@@ -17,6 +17,7 @@ import (
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
log "github.com/sirupsen/logrus"
+ "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
nbversion "github.com/netbirdio/netbird/version"
)
@@ -53,6 +54,7 @@ type DataSource interface {
GetAllAccounts(ctx context.Context) []*types.Account
GetStoreEngine() types.Engine
GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error)
+ GetProxyMetrics(ctx context.Context) (store.ProxyMetrics, error)
}
// ConnManager peer connection manager that holds state for current active connections
@@ -223,6 +225,12 @@ func (w *Worker) generateProperties(ctx context.Context) properties {
servicesAuthPassword int
servicesAuthPin int
servicesAuthOIDC int
+ // Private-service signals — track adoption of NetBird-only mode
+ // (services backed by an embedded proxy peer + access groups).
+ servicesPrivate int
+ servicesPrivateWithGroups int
+ servicesPrivateAccessGroupsSum int
+ servicesWithDirectUpstream int
)
start := time.Now()
metricsProperties := make(properties)
@@ -380,9 +388,31 @@ func (w *Worker) generateProperties(ctx context.Context) properties {
if service.Auth.BearerAuth != nil && service.Auth.BearerAuth.Enabled {
servicesAuthOIDC++
}
+
+ if service.Private {
+ servicesPrivate++
+ if len(service.AccessGroups) > 0 {
+ servicesPrivateWithGroups++
+ }
+ servicesPrivateAccessGroupsSum += len(service.AccessGroups)
+ }
+
+ for _, target := range service.Targets {
+ if target.Options.DirectUpstream {
+ servicesWithDirectUpstream++
+ break
+ }
+ }
}
}
+ // Proxy / BYOP cluster signals come from the proxies table aggregated
+ // across all accounts in a single store query; nil on FileStore.
+ proxyMetrics, err := w.dataSource.GetProxyMetrics(ctx)
+ if err != nil {
+ log.WithContext(ctx).Debugf("collect proxy metrics: %v", err)
+ }
+
minActivePeerVersion, maxActivePeerVersion := getMinMaxVersion(peerActiveVersions)
metricsProperties["uptime"] = uptime
metricsProperties["accounts"] = accounts
@@ -430,6 +460,15 @@ func (w *Worker) generateProperties(ctx context.Context) properties {
metricsProperties["services_auth_password"] = servicesAuthPassword
metricsProperties["services_auth_pin"] = servicesAuthPin
metricsProperties["services_auth_oidc"] = servicesAuthOIDC
+ metricsProperties["services_private"] = servicesPrivate
+ metricsProperties["services_private_with_access_groups"] = servicesPrivateWithGroups
+ metricsProperties["services_private_access_groups_sum"] = servicesPrivateAccessGroupsSum
+ metricsProperties["services_with_direct_upstream"] = servicesWithDirectUpstream
+ metricsProperties["proxy_clusters"] = proxyMetrics.Clusters
+ metricsProperties["proxy_clusters_byop"] = proxyMetrics.ClustersBYOP
+ metricsProperties["proxy_clusters_private"] = proxyMetrics.ClustersPrivate
+ metricsProperties["proxies"] = proxyMetrics.Proxies
+ metricsProperties["proxies_connected"] = proxyMetrics.ProxiesConnected
metricsProperties["custom_domains"] = customDomains
metricsProperties["custom_domains_validated"] = customDomainsValidated
diff --git a/management/server/metrics/selfhosted_test.go b/management/server/metrics/selfhosted_test.go
index 78f5c53be..ca9e10262 100644
--- a/management/server/metrics/selfhosted_test.go
+++ b/management/server/metrics/selfhosted_test.go
@@ -12,6 +12,7 @@ import (
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/posture"
+ "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/route"
)
@@ -123,7 +124,7 @@ func (mockDatasource) GetAllAccounts(_ context.Context) []*types.Account {
Enabled: true,
Targets: []*rpservice.Target{
{TargetType: "peer"},
- {TargetType: "host"},
+ {TargetType: "host", Options: rpservice.TargetOptions{DirectUpstream: true}},
},
Auth: rpservice.AuthConfig{
PasswordAuth: &rpservice.PasswordAuthConfig{Enabled: true},
@@ -141,6 +142,16 @@ func (mockDatasource) GetAllAccounts(_ context.Context) []*types.Account {
},
Meta: rpservice.Meta{Status: string(rpservice.StatusPending)},
},
+ {
+ ID: "svc3-private",
+ Enabled: true,
+ Private: true,
+ AccessGroups: []string{"grp-eng", "grp-ops"},
+ Targets: []*rpservice.Target{
+ {TargetType: "cluster", Options: rpservice.TargetOptions{DirectUpstream: true}},
+ },
+ Meta: rpservice.Meta{Status: string(rpservice.StatusActive)},
+ },
},
},
{
@@ -254,6 +265,18 @@ func (mockDatasource) GetCustomDomainsCounts(_ context.Context) (int64, int64, e
return 3, 2, nil
}
+// GetProxyMetrics returns canned proxy/cluster counts so the
+// generateProperties test can assert the BYOP signals end-to-end.
+func (mockDatasource) GetProxyMetrics(_ context.Context) (store.ProxyMetrics, error) {
+ return store.ProxyMetrics{
+ Clusters: 3,
+ ClustersBYOP: 1,
+ ClustersPrivate: 1,
+ Proxies: 4,
+ ProxiesConnected: 2,
+ }, nil
+}
+
// TestGenerateProperties tests and validate the properties generation by using the mockDatasource for the Worker.generateProperties
func TestGenerateProperties(t *testing.T) {
ds := mockDatasource{}
@@ -393,17 +416,17 @@ func TestGenerateProperties(t *testing.T) {
t.Errorf("expected 3 embedded_idp_count, got %v", properties["embedded_idp_count"])
}
- if properties["services"] != 2 {
- t.Errorf("expected 2 services, got %v", properties["services"])
+ if properties["services"] != 3 {
+ t.Errorf("expected 3 services, got %v", properties["services"])
}
- if properties["services_enabled"] != 1 {
- t.Errorf("expected 1 services_enabled, got %v", properties["services_enabled"])
+ if properties["services_enabled"] != 2 {
+ t.Errorf("expected 2 services_enabled, got %v", properties["services_enabled"])
}
- if properties["services_targets"] != 3 {
- t.Errorf("expected 3 services_targets, got %v", properties["services_targets"])
+ if properties["services_targets"] != 4 {
+ t.Errorf("expected 4 services_targets, got %v", properties["services_targets"])
}
- if properties["services_status_active"] != 1 {
- t.Errorf("expected 1 services_status_active, got %v", properties["services_status_active"])
+ if properties["services_status_active"] != 2 {
+ t.Errorf("expected 2 services_status_active, got %v", properties["services_status_active"])
}
if properties["services_status_pending"] != 1 {
t.Errorf("expected 1 services_status_pending, got %v", properties["services_status_pending"])
@@ -420,6 +443,9 @@ func TestGenerateProperties(t *testing.T) {
if properties["services_target_type_domain"] != 1 {
t.Errorf("expected 1 services_target_type_domain, got %v", properties["services_target_type_domain"])
}
+ if properties["services_target_type_cluster"] != 1 {
+ t.Errorf("expected 1 services_target_type_cluster, got %v", properties["services_target_type_cluster"])
+ }
if properties["services_auth_password"] != 1 {
t.Errorf("expected 1 services_auth_password, got %v", properties["services_auth_password"])
}
@@ -429,6 +455,33 @@ func TestGenerateProperties(t *testing.T) {
if properties["services_auth_pin"] != 0 {
t.Errorf("expected 0 services_auth_pin, got %v", properties["services_auth_pin"])
}
+ if properties["services_private"] != 1 {
+ t.Errorf("expected 1 services_private, got %v", properties["services_private"])
+ }
+ if properties["services_private_with_access_groups"] != 1 {
+ t.Errorf("expected 1 services_private_with_access_groups, got %v", properties["services_private_with_access_groups"])
+ }
+ if properties["services_private_access_groups_sum"] != 2 {
+ t.Errorf("expected 2 services_private_access_groups_sum, got %v", properties["services_private_access_groups_sum"])
+ }
+ if properties["services_with_direct_upstream"] != 2 {
+ t.Errorf("expected 2 services_with_direct_upstream, got %v", properties["services_with_direct_upstream"])
+ }
+ if properties["proxy_clusters"] != int64(3) {
+ t.Errorf("expected 3 proxy_clusters, got %v", properties["proxy_clusters"])
+ }
+ if properties["proxy_clusters_byop"] != int64(1) {
+ t.Errorf("expected 1 proxy_clusters_byop, got %v", properties["proxy_clusters_byop"])
+ }
+ if properties["proxy_clusters_private"] != int64(1) {
+ t.Errorf("expected 1 proxy_clusters_private, got %v", properties["proxy_clusters_private"])
+ }
+ if properties["proxies"] != int64(4) {
+ t.Errorf("expected 4 proxies, got %v", properties["proxies"])
+ }
+ if properties["proxies_connected"] != int64(2) {
+ t.Errorf("expected 2 proxies_connected, got %v", properties["proxies_connected"])
+ }
if properties["custom_domains"] != int64(3) {
t.Errorf("expected 3 custom_domains, got %v", properties["custom_domains"])
}
diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go
index aba408184..32549a521 100644
--- a/management/server/mock_server/account_mock.go
+++ b/management/server/mock_server/account_mock.go
@@ -98,6 +98,7 @@ type MockAccountManager struct {
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)
+ 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
ApproveUserFunc func(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error)
@@ -860,6 +861,14 @@ func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLog
return nil, nil, nil, status.Errorf(codes.Unimplemented, "method LoginPeer is not implemented")
}
+// ExtendPeerSession mocks ExtendPeerSession of the AccountManager interface
+func (am *MockAccountManager) ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) {
+ if am.ExtendPeerSessionFunc != nil {
+ return am.ExtendPeerSessionFunc(ctx, peerPubKey, userID)
+ }
+ return time.Time{}, status.Errorf(codes.Unimplemented, "method ExtendPeerSession is not implemented")
+}
+
// SyncPeer mocks SyncPeer of the AccountManager interface
func (am *MockAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
if am.SyncPeerFunc != nil {
diff --git a/management/server/nameserver.go b/management/server/nameserver.go
index 5859bfb0d..c836fefeb 100644
--- a/management/server/nameserver.go
+++ b/management/server/nameserver.go
@@ -23,7 +23,7 @@ var errInvalidDomainName = errors.New("invalid domain name")
// GetNameServerGroup gets a nameserver group object from account and nameserver group IDs
func (am *DefaultAccountManager) GetNameServerGroup(ctx context.Context, accountID, userID, nsGroupID string) (*nbdns.NameServerGroup, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -36,7 +36,7 @@ func (am *DefaultAccountManager) GetNameServerGroup(ctx context.Context, account
// CreateNameServerGroup creates and saves a new nameserver group
func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, accountID string, name, description string, nameServerList []nbdns.NameServer, groups []string, primary bool, domains []string, enabled bool, userID string, searchDomainEnabled bool) (*nbdns.NameServerGroup, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -94,7 +94,7 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun
return status.Errorf(status.InvalidArgument, "nameserver group provided is nil")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -141,7 +141,7 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun
// DeleteNameServerGroup deletes nameserver group with nsGroupID
func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, accountID, nsGroupID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -184,7 +184,7 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco
// ListNameServerGroups returns a list of nameserver groups from account
func (am *DefaultAccountManager) ListNameServerGroups(ctx context.Context, accountID string, userID string) ([]*nbdns.NameServerGroup, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/networks/manager.go b/management/server/networks/manager.go
index c96b60bb2..f825ae015 100644
--- a/management/server/networks/manager.go
+++ b/management/server/networks/manager.go
@@ -49,7 +49,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, resou
}
func (m *managerImpl) GetAllNetworks(ctx context.Context, accountID, userID string) ([]*types.Network, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -61,7 +61,7 @@ func (m *managerImpl) GetAllNetworks(ctx context.Context, accountID, userID stri
}
func (m *managerImpl) CreateNetwork(ctx context.Context, userID string, network *types.Network) (*types.Network, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -82,7 +82,7 @@ func (m *managerImpl) CreateNetwork(ctx context.Context, userID string, network
}
func (m *managerImpl) GetNetwork(ctx context.Context, accountID, userID, networkID string) (*types.Network, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -94,7 +94,7 @@ func (m *managerImpl) GetNetwork(ctx context.Context, accountID, userID, network
}
func (m *managerImpl) UpdateNetwork(ctx context.Context, userID string, network *types.Network) (*types.Network, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -113,7 +113,7 @@ func (m *managerImpl) UpdateNetwork(ctx context.Context, userID string, network
}
func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, networkID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/networks/manager_test.go b/management/server/networks/manager_test.go
index 6fb19d157..24d5f49b7 100644
--- a/management/server/networks/manager_test.go
+++ b/management/server/networks/manager_test.go
@@ -34,8 +34,11 @@ func Test_GetAllNetworksReturnsNetworks(t *testing.T) {
networks, err := manager.GetAllNetworks(ctx, accountID, userID)
require.NoError(t, err)
- require.Len(t, networks, 1)
- require.Equal(t, "testNetworkId", networks[0].ID)
+ ids := make([]string, 0, len(networks))
+ for _, n := range networks {
+ ids = append(ids, n.ID)
+ }
+ require.ElementsMatch(t, []string{"testNetworkId", "secondNetworkId"}, ids)
}
func Test_GetAllNetworksReturnsPermissionDenied(t *testing.T) {
diff --git a/management/server/networks/resources/manager.go b/management/server/networks/resources/manager.go
index 5a0e26533..51a269163 100644
--- a/management/server/networks/resources/manager.go
+++ b/management/server/networks/resources/manager.go
@@ -54,7 +54,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, group
}
func (m *managerImpl) GetAllResourcesInNetwork(ctx context.Context, accountID, userID, networkID string) ([]*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -66,7 +66,7 @@ func (m *managerImpl) GetAllResourcesInNetwork(ctx context.Context, accountID, u
}
func (m *managerImpl) GetAllResourcesInAccount(ctx context.Context, accountID, userID string) ([]*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -78,7 +78,7 @@ func (m *managerImpl) GetAllResourcesInAccount(ctx context.Context, accountID, u
}
func (m *managerImpl) GetAllResourceIDsInAccount(ctx context.Context, accountID, userID string) (map[string][]string, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -100,7 +100,7 @@ func (m *managerImpl) GetAllResourceIDsInAccount(ctx context.Context, accountID,
}
func (m *managerImpl) CreateResource(ctx context.Context, userID string, resource *types.NetworkResource) (*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -168,7 +168,7 @@ func (m *managerImpl) CreateResource(ctx context.Context, userID string, resourc
}
func (m *managerImpl) GetResource(ctx context.Context, accountID, userID, networkID, resourceID string) (*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -189,7 +189,7 @@ func (m *managerImpl) GetResource(ctx context.Context, accountID, userID, networ
}
func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resource *types.NetworkResource) (*types.NetworkResource, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -314,7 +314,7 @@ func (m *managerImpl) updateResourceGroups(ctx context.Context, transaction stor
}
func (m *managerImpl) DeleteResource(ctx context.Context, accountID, userID, networkID, resourceID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/networks/routers/manager.go b/management/server/networks/routers/manager.go
index c7c3f2ff4..9fa2b95f7 100644
--- a/management/server/networks/routers/manager.go
+++ b/management/server/networks/routers/manager.go
@@ -47,7 +47,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, accou
}
func (m *managerImpl) GetAllRoutersInNetwork(ctx context.Context, accountID, userID, networkID string) ([]*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -59,7 +59,7 @@ func (m *managerImpl) GetAllRoutersInNetwork(ctx context.Context, accountID, use
}
func (m *managerImpl) GetAllRoutersInAccount(ctx context.Context, accountID, userID string) (map[string][]*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -81,7 +81,7 @@ func (m *managerImpl) GetAllRoutersInAccount(ctx context.Context, accountID, use
}
func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *types.NetworkRouter) (*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Create)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -102,7 +102,7 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t
router.ID = xid.New().String()
- err = transaction.SaveNetworkRouter(ctx, router)
+ err = transaction.CreateNetworkRouter(ctx, router)
if err != nil {
return fmt.Errorf("failed to create network router: %w", err)
}
@@ -126,7 +126,7 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t
}
func (m *managerImpl) GetRouter(ctx context.Context, accountID, userID, networkID, routerID string) (*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -147,7 +147,7 @@ func (m *managerImpl) GetRouter(ctx context.Context, accountID, userID, networkI
}
func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *types.NetworkRouter) (*types.NetworkRouter, error) {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Update)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -162,11 +162,20 @@ func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *t
return fmt.Errorf("failed to get network: %w", err)
}
- if network.ID != router.NetworkID {
+ existing, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, router.AccountID, router.ID)
+ if err != nil {
+ return fmt.Errorf("failed to get network router: %w", err)
+ }
+
+ if existing.AccountID != router.AccountID {
+ return status.NewNetworkRouterNotFoundError(router.ID)
+ }
+
+ if existing.NetworkID != router.NetworkID {
return status.NewRouterNotPartOfNetworkError(router.ID, router.NetworkID)
}
- err = transaction.SaveNetworkRouter(ctx, router)
+ err = transaction.UpdateNetworkRouter(ctx, router)
if err != nil {
return fmt.Errorf("failed to update network router: %w", err)
}
@@ -190,7 +199,7 @@ func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *t
}
func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, networkID, routerID string) error {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
+ ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/networks/routers/manager_test.go b/management/server/networks/routers/manager_test.go
index 6be90baa7..7b6d5f14f 100644
--- a/management/server/networks/routers/manager_test.go
+++ b/management/server/networks/routers/manager_test.go
@@ -195,6 +195,7 @@ func Test_UpdateRouterSuccessfully(t *testing.T) {
if err != nil {
require.NoError(t, err)
}
+ router.ID = "testRouterId"
s, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir())
if err != nil {
@@ -210,6 +211,102 @@ func Test_UpdateRouterSuccessfully(t *testing.T) {
require.Equal(t, router.Metric, updatedRouter.Metric)
}
+func Test_UpdateRouterRejectsCrossAccountID(t *testing.T) {
+ ctx := context.Background()
+ userID := "testAdminId"
+
+ // Admin of testAccountId tries to update a router that belongs to otherAccountId
+ // by passing the other account's router ID through the URL.
+ router, err := types.NewNetworkRouter("testAccountId", "testNetworkId", "testPeerId", []string{}, false, 1, true)
+ if err != nil {
+ require.NoError(t, err)
+ }
+ router.ID = "otherRouterId"
+
+ s, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(cleanUp)
+ permissionsManager := permissions.NewManager(s)
+ am := mock_server.MockAccountManager{}
+ manager := NewManager(s, permissionsManager, &am)
+
+ updatedRouter, err := manager.UpdateRouter(ctx, userID, router)
+ require.Error(t, err)
+ require.Nil(t, updatedRouter)
+
+ // The other account's router must be untouched.
+ stored, err := s.GetNetworkRouterByID(ctx, store.LockingStrengthNone, "otherAccountId", "otherRouterId")
+ require.NoError(t, err)
+ require.Equal(t, "otherAccountId", stored.AccountID)
+ require.Equal(t, "otherNetworkId", stored.NetworkID)
+ require.Equal(t, "otherPeer", stored.Peer)
+ require.Equal(t, 1, stored.Metric)
+}
+
+func Test_CreateRouterRejectsCrossAccountID(t *testing.T) {
+ ctx := context.Background()
+ userID := "testAdminId"
+
+ // Admin of testAccountId tries to create a router in otherAccountId's network.
+ // The permission check is on router.AccountID (their own), but the network
+ // lookup must fail because (testAccountId, otherNetworkId) does not exist.
+ router, err := types.NewNetworkRouter("testAccountId", "otherNetworkId", "testPeerId", []string{}, false, 1, true)
+ if err != nil {
+ require.NoError(t, err)
+ }
+
+ s, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(cleanUp)
+ permissionsManager := permissions.NewManager(s)
+ am := mock_server.MockAccountManager{}
+ manager := NewManager(s, permissionsManager, &am)
+
+ createdRouter, err := manager.CreateRouter(ctx, userID, router)
+ require.Error(t, err)
+ require.Nil(t, createdRouter)
+
+ // No router should have been created in either account's scope under otherNetworkId.
+ routersInOther, err := s.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, "otherAccountId", "otherNetworkId")
+ require.NoError(t, err)
+ require.Len(t, routersInOther, 1)
+ require.Equal(t, "otherRouterId", routersInOther[0].ID)
+}
+
+func Test_UpdateRouterRejectsNetworkMismatch(t *testing.T) {
+ ctx := context.Background()
+ userID := "testAdminId"
+
+ // The router exists in testNetworkId, but the caller submits secondNetworkId
+ // (a different network in the same account). The update must be refused.
+ router, err := types.NewNetworkRouter("testAccountId", "secondNetworkId", "testPeerId", []string{}, false, 1, true)
+ if err != nil {
+ require.NoError(t, err)
+ }
+ router.ID = "testRouterId"
+
+ s, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(cleanUp)
+ permissionsManager := permissions.NewManager(s)
+ am := mock_server.MockAccountManager{}
+ manager := NewManager(s, permissionsManager, &am)
+
+ updatedRouter, err := manager.UpdateRouter(ctx, userID, router)
+ require.Error(t, err)
+ require.Nil(t, updatedRouter)
+
+ stored, err := s.GetNetworkRouterByID(ctx, store.LockingStrengthNone, "testAccountId", "testRouterId")
+ require.NoError(t, err)
+ require.Equal(t, "testNetworkId", stored.NetworkID)
+}
+
func Test_UpdateRouterFailsWithPermissionDenied(t *testing.T) {
ctx := context.Background()
userID := "testUserId"
diff --git a/management/server/peer.go b/management/server/peer.go
index 34b681f51..d4e3ebb49 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -30,6 +30,7 @@ import (
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/telemetry"
"github.com/netbirdio/netbird/shared/management/status"
+ "github.com/netbirdio/netbird/version"
)
const remoteJobsMinVer = "0.64.0"
@@ -42,7 +43,7 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID
return nil, err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -125,6 +126,18 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK
}
}
+ // An embedded proxy peer flipping to connected is the trigger for
+ // SynthesizePrivateServiceZones to emit DNS A records pointing at its
+ // tunnel IP. Without an account-wide netmap recompute, user peers keep
+ // the stale synth (or no synth at all on first connect) until some
+ // other change pokes the controller. Fire OnPeersUpdated so the
+ // buffered recompute fans the new state out to every peer.
+ if peer.ProxyMeta.Embedded {
+ if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}); err != nil {
+ log.WithContext(ctx).Warnf("notify network map controller of embedded proxy %s connect: %v", peer.ID, err)
+ }
+ }
+
return nil
}
@@ -160,6 +173,17 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP
return nil
}
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusApplied)
+
+ // Symmetric with MarkPeerConnected: when an embedded proxy peer goes
+ // offline, drive an account-wide netmap recompute so the synthesized
+ // DNS records that pointed at it are pulled. Without this the records
+ // linger client-side at TTL until something else triggers a refresh.
+ if peer.ProxyMeta.Embedded {
+ if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}); err != nil {
+ log.WithContext(ctx).Warnf("notify network map controller of embedded proxy %s disconnect: %v", peer.ID, err)
+ }
+ }
+
return nil
}
@@ -186,7 +210,7 @@ func (am *DefaultAccountManager) updatePeerLocationIfChanged(ctx context.Context
// UpdatePeer updates peer. Only Peer.Name, Peer.SSHEnabled, Peer.LoginExpirationEnabled and Peer.InactivityExpirationEnabled can be updated.
func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -331,7 +355,7 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user
}
func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -349,7 +373,7 @@ func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, p
}
meetMinVer, err := posture.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion)
- if !strings.Contains(p.Meta.WtVersion, "dev") && (!meetMinVer || err != nil) {
+ if !version.IsDevelopmentVersion(p.Meta.WtVersion) && (!meetMinVer || err != nil) {
return status.Errorf(status.PreconditionFailed, "peer version %s does not meet the minimum required version %s for remote jobs", p.Meta.WtVersion, remoteJobsMinVer)
}
@@ -407,7 +431,7 @@ func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, p
func (am *DefaultAccountManager) GetAllPeerJobs(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) {
// todo: Create permissions for job
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -433,7 +457,7 @@ func (am *DefaultAccountManager) GetAllPeerJobs(ctx context.Context, accountID,
}
func (am *DefaultAccountManager) GetPeerJobByID(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -460,7 +484,7 @@ func (am *DefaultAccountManager) GetPeerJobByID(ctx context.Context, accountID,
// DeletePeer removes peer from the account by its IP
func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peerID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -620,7 +644,7 @@ func (am *DefaultAccountManager) handleUserAddedPeer(ctx context.Context, accoun
}
if temporary {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Create)
+ allowed, _, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -1128,6 +1152,79 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
return p, nmap, pc, err
}
+// ExtendPeerSession refreshes the peer's SSO session deadline by updating
+// LastLogin after a successful JWT validation. The tunnel is untouched: no
+// network map sync, no peer reconnect.
+//
+// Preconditions enforced here:
+// - userID must be present (caller validated the JWT and extracted the user ID).
+// - The peer must exist and be SSO-registered (AddedWithSSOLogin) with
+// LoginExpirationEnabled.
+// - Account-level PeerLoginExpirationEnabled must be true.
+// - The JWT user must match peer.UserID (mirrors LoginPeer at peer.go ~1028).
+//
+// Returns the new absolute UTC deadline.
+func (am *DefaultAccountManager) ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) {
+ if userID == "" {
+ return time.Time{}, status.Errorf(status.PermissionDenied, "session extend requires a JWT")
+ }
+
+ accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, peerPubKey)
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return time.Time{}, err
+ }
+ if !settings.PeerLoginExpirationEnabled {
+ return time.Time{}, status.Errorf(status.PreconditionFailed, "peer login expiration is disabled for the account")
+ }
+
+ var refreshed *nbpeer.Peer
+ err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ peer, err := transaction.GetPeerByPeerPubKey(ctx, store.LockingStrengthUpdate, peerPubKey)
+ if err != nil {
+ return err
+ }
+
+ if !peer.AddedWithSSOLogin() || !peer.LoginExpirationEnabled {
+ return status.Errorf(status.PreconditionFailed, "peer is not eligible for session extension")
+ }
+
+ if peer.UserID != userID {
+ log.WithContext(ctx).Warnf("user mismatch when extending session for peer %s: peer user %s, jwt user %s", peer.ID, peer.UserID, userID)
+ return status.NewPeerLoginMismatchError()
+ }
+
+ peer = peer.UpdateLastLogin()
+ if err := transaction.SavePeer(ctx, accountID, peer); err != nil {
+ return err
+ }
+
+ if err := transaction.SaveUserLastLogin(ctx, accountID, userID, peer.GetLastLogin()); err != nil {
+ log.WithContext(ctx).Debugf("failed to update user last login during session extend: %v", err)
+ }
+
+ am.StoreEvent(ctx, userID, peer.ID, accountID, activity.UserExtendedPeerSession, peer.EventMeta(am.networkMapController.GetDNSDomain(settings)))
+ refreshed = peer
+ return nil
+ })
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ // Reschedule the per-account expiration job. schedulePeerLoginExpiration
+ // is a no-op when a job is already running, but the running job will pick
+ // up the new LastLogin on its next tick. Calling it here is harmless and
+ // guarantees a job is in flight even if a prior one ended right before
+ // the extend.
+ am.schedulePeerLoginExpiration(ctx, accountID)
+
+ 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)
@@ -1283,7 +1380,7 @@ func (am *DefaultAccountManager) GetPeer(ctx context.Context, accountID, peerID,
return nil, err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go
index 32bf74da3..8c3e9dc3d 100644
--- a/management/server/peer/peer.go
+++ b/management/server/peer/peer.go
@@ -369,6 +369,22 @@ func (p *Peer) LoginExpired(expiresIn time.Duration) (bool, time.Duration) {
return timeLeft <= 0, timeLeft
}
+// SessionExpiresAt returns the absolute UTC instant at which the peer's SSO
+// session expires, derived from LastLogin and the account-level
+// PeerLoginExpiration setting. Returns the zero value when login expiration
+// does not apply (peer not SSO-registered, peer-level toggle off, or account
+// expiry disabled). Callers should treat the zero value as "no deadline".
+func (p *Peer) SessionExpiresAt(accountExpirationEnabled bool, expiresIn time.Duration) time.Time {
+ if !accountExpirationEnabled || !p.AddedWithSSOLogin() || !p.LoginExpirationEnabled {
+ return time.Time{}
+ }
+ last := p.GetLastLogin()
+ if last.IsZero() {
+ return time.Time{}
+ }
+ return last.Add(expiresIn).UTC()
+}
+
// FQDN returns peers FQDN combined of the peer's DNS label and the system's DNS domain
func (p *Peer) FQDN(dnsDomain string) string {
if dnsDomain == "" {
diff --git a/management/server/permissions/manager.go b/management/server/permissions/manager.go
index e6bdd2025..995f234d8 100644
--- a/management/server/permissions/manager.go
+++ b/management/server/permissions/manager.go
@@ -9,6 +9,7 @@ import (
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity"
+ nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/permissions/roles"
@@ -18,9 +19,9 @@ import (
)
type Manager interface {
- ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, error)
+ ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error)
ValidateRoleModuleAccess(ctx context.Context, accountID string, role roles.RolePermissions, module modules.Module, operation operations.Operation) bool
- ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error
+ ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error)
GetPermissionsByRole(ctx context.Context, role types.UserRole) (roles.Permissions, error)
SetAccountManager(accountManager account.Manager)
@@ -42,42 +43,43 @@ func (m *managerImpl) ValidateUserPermissions(
userID string,
module modules.Module,
operation operations.Operation,
-) (bool, error) {
+) (bool, context.Context, error) {
if userID == activity.SystemInitiator {
- return true, nil
+ return true, ctx, nil
}
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
if err != nil {
- return false, err
+ return false, ctx, err
}
if user == nil {
- return false, status.NewUserNotFoundError(userID)
+ return false, ctx, status.NewUserNotFoundError(userID)
}
if user.IsBlocked() && !user.PendingApproval {
- return false, status.NewUserBlockedError()
+ return false, ctx, status.NewUserBlockedError()
}
if user.IsBlocked() && user.PendingApproval {
- return false, status.NewUserPendingApprovalError()
+ return false, ctx, status.NewUserPendingApprovalError()
}
- if err := m.ValidateAccountAccess(ctx, accountID, user, false); err != nil {
- return false, err
+ ctxEnriched, err := m.ValidateAccountAccess(ctx, accountID, user, false)
+ if err != nil {
+ return false, ctx, err
}
if operation == operations.Read && user.IsServiceUser {
- return true, nil // this should be replaced by proper granular access role
+ return true, ctxEnriched, nil // this should be replaced by proper granular access role
}
role, ok := roles.RolesMap[user.Role]
if !ok {
- return false, status.NewUserRoleNotFoundError(string(user.Role))
+ return false, ctxEnriched, status.NewUserRoleNotFoundError(string(user.Role))
}
- return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), nil
+ return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), ctxEnriched, nil
}
func (m *managerImpl) ValidateRoleModuleAccess(
@@ -98,11 +100,14 @@ func (m *managerImpl) ValidateRoleModuleAccess(
return role.AutoAllowNew[operation]
}
-func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error {
+func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) {
if user.AccountID != accountID {
- return status.NewUserNotPartOfAccountError()
+ return ctx, status.NewUserNotPartOfAccountError()
}
- return nil
+
+ ctx = nbcontext.WithRole(ctx, string(user.Role))
+
+ return ctx, nil
}
func (m *managerImpl) GetPermissionsByRole(ctx context.Context, role types.UserRole) (roles.Permissions, error) {
diff --git a/management/server/permissions/manager_mock.go b/management/server/permissions/manager_mock.go
index ec9f263f9..934e33398 100644
--- a/management/server/permissions/manager_mock.go
+++ b/management/server/permissions/manager_mock.go
@@ -67,11 +67,12 @@ func (mr *MockManagerMockRecorder) SetAccountManager(accountManager interface{})
}
// ValidateAccountAccess mocks base method.
-func (m *MockManager) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error {
+func (m *MockManager) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ValidateAccountAccess", ctx, accountID, user, allowOwnerAndAdmin)
- ret0, _ := ret[0].(error)
- return ret0
+ ret0, _ := ret[0].(context.Context)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
}
// ValidateAccountAccess indicates an expected call of ValidateAccountAccess.
@@ -95,12 +96,13 @@ func (mr *MockManagerMockRecorder) ValidateRoleModuleAccess(ctx, accountID, role
}
// ValidateUserPermissions mocks base method.
-func (m *MockManager) ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, error) {
+func (m *MockManager) ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ValidateUserPermissions", ctx, accountID, userID, module, operation)
ret0, _ := ret[0].(bool)
- ret1, _ := ret[1].(error)
- return ret0, ret1
+ ret1, _ := ret[1].(context.Context)
+ ret2, _ := ret[2].(error)
+ return ret0, ret1, ret2
}
// ValidateUserPermissions indicates an expected call of ValidateUserPermissions.
diff --git a/management/server/policy.go b/management/server/policy.go
index 40f3908e3..d67b3206e 100644
--- a/management/server/policy.go
+++ b/management/server/policy.go
@@ -19,7 +19,7 @@ import (
// GetPolicy from the store
func (am *DefaultAccountManager) GetPolicy(ctx context.Context, accountID, policyID, userID string) (*types.Policy, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -36,7 +36,7 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user
if !create {
operation = operations.Update
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -108,7 +108,7 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user
// DeletePolicy from the store
func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, policyID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -151,7 +151,7 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po
// ListPolicies from the store.
func (am *DefaultAccountManager) ListPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/posture/nb_version.go b/management/server/posture/nb_version.go
index 33bf01ad1..6e4757021 100644
--- a/management/server/posture/nb_version.go
+++ b/management/server/posture/nb_version.go
@@ -6,7 +6,6 @@ import (
"strings"
"github.com/hashicorp/go-version"
- log "github.com/sirupsen/logrus"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
)
@@ -33,9 +32,6 @@ func (n *NBVersionCheck) Check(ctx context.Context, peer nbpeer.Peer) (bool, err
return true, nil
}
- log.WithContext(ctx).Debugf("peer %s NB version %s is older than minimum allowed version %s",
- peer.ID, peer.Meta.WtVersion, n.MinVersion)
-
return false, nil
}
diff --git a/management/server/posture/os_version.go b/management/server/posture/os_version.go
index 2ef97a066..724bebfcc 100644
--- a/management/server/posture/os_version.go
+++ b/management/server/posture/os_version.go
@@ -100,8 +100,6 @@ func checkMinVersion(ctx context.Context, peerGoOS, peerVersion string, check *M
return true, nil
}
- log.WithContext(ctx).Debugf("peer %s OS version %s is older than minimum allowed version %s", peerGoOS, peerVersion, check.MinVersion)
-
return false, nil
}
@@ -125,7 +123,5 @@ func checkMinKernelVersion(ctx context.Context, peerGoOS, peerVersion string, ch
return true, nil
}
- log.WithContext(ctx).Debugf("peer %s kernel version %s is older than minimum allowed version %s", peerGoOS, peerVersion, check.MinKernelVersion)
-
return false, nil
}
diff --git a/management/server/posture_checks.go b/management/server/posture_checks.go
index 1e3ce4b8a..56a732bf5 100644
--- a/management/server/posture_checks.go
+++ b/management/server/posture_checks.go
@@ -16,7 +16,7 @@ import (
)
func (am *DefaultAccountManager) GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -33,7 +33,7 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI
if !create {
operation = operations.Update
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -89,7 +89,7 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI
// DeletePostureChecks deletes a posture check by ID.
func (am *DefaultAccountManager) DeletePostureChecks(ctx context.Context, accountID, postureChecksID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -126,7 +126,7 @@ func (am *DefaultAccountManager) DeletePostureChecks(ctx context.Context, accoun
// ListPostureChecks returns a list of posture checks.
func (am *DefaultAccountManager) ListPostureChecks(ctx context.Context, accountID, userID string) ([]*posture.Checks, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/route.go b/management/server/route.go
index a9561faf0..8fd1cb02a 100644
--- a/management/server/route.go
+++ b/management/server/route.go
@@ -21,7 +21,7 @@ import (
// GetRoute gets a route object from account and route IDs
func (am *DefaultAccountManager) GetRoute(ctx context.Context, accountID string, routeID route.ID, userID string) (*route.Route, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -134,7 +134,7 @@ func getRouteDescriptor(prefix netip.Prefix, domains domain.List) string {
// CreateRoute creates and saves a new route
func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID string, prefix netip.Prefix, networkType route.NetworkType, domains domain.List, peerID string, peerGroupIDs []string, description string, netID route.NetID, masquerade bool, metric int, groups, accessControlGroupIDs []string, enabled bool, userID string, keepRoute bool, skipAutoApply bool) (*route.Route, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -199,7 +199,7 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri
// SaveRoute saves route
func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userID string, routeToSave *route.Route) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Update)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -253,7 +253,7 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI
// DeleteRoute deletes route with routeID
func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID string, routeID route.ID, userID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -296,7 +296,7 @@ func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID stri
// ListRoutes returns a list of routes from account
func (am *DefaultAccountManager) ListRoutes(ctx context.Context, accountID, userID string) ([]*route.Route, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/settings/manager.go b/management/server/settings/manager.go
index 345d857f9..f84739193 100644
--- a/management/server/settings/manager.go
+++ b/management/server/settings/manager.go
@@ -59,7 +59,7 @@ func (m *managerImpl) GetExtraSettingsManager() extra_settings.Manager {
func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) {
if userID != activity.SystemInitiator {
- ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read)
+ ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
diff --git a/management/server/setupkey.go b/management/server/setupkey.go
index 8d0509871..cfc44377d 100644
--- a/management/server/setupkey.go
+++ b/management/server/setupkey.go
@@ -56,7 +56,7 @@ type SetupKeyUpdateOperation struct {
func (am *DefaultAccountManager) CreateSetupKey(ctx context.Context, accountID string, keyName string, keyType types.SetupKeyType,
expiresIn time.Duration, autoGroups []string, usageLimit int, userID string, ephemeral bool, allowExtraDNSLabels bool) (*types.SetupKey, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -105,7 +105,7 @@ func (am *DefaultAccountManager) SaveSetupKey(ctx context.Context, accountID str
return nil, status.Errorf(status.InvalidArgument, "provided setup key to update is nil")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -162,7 +162,7 @@ func (am *DefaultAccountManager) SaveSetupKey(ctx context.Context, accountID str
// ListSetupKeys returns a list of all setup keys of the account
func (am *DefaultAccountManager) ListSetupKeys(ctx context.Context, accountID, userID string) ([]*types.SetupKey, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -175,7 +175,7 @@ func (am *DefaultAccountManager) ListSetupKeys(ctx context.Context, accountID, u
// GetSetupKey looks up a SetupKey by KeyID, returns NotFound error if not found.
func (am *DefaultAccountManager) GetSetupKey(ctx context.Context, accountID, userID, keyID string) (*types.SetupKey, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -198,7 +198,7 @@ func (am *DefaultAccountManager) GetSetupKey(ctx context.Context, accountID, use
// DeleteSetupKey removes the setup key from the account
func (am *DefaultAccountManager) DeleteSetupKey(ctx context.Context, accountID, userID, keyID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/store/file_store.go b/management/server/store/file_store.go
index 81185b020..bcf563cd0 100644
--- a/management/server/store/file_store.go
+++ b/management/server/store/file_store.go
@@ -274,3 +274,9 @@ func (s *FileStore) SetFieldEncrypt(_ *crypt.FieldEncrypt) {
func (s *FileStore) GetCustomDomainsCounts(_ context.Context) (int64, int64, error) {
return 0, 0, nil
}
+
+// GetProxyMetrics is a no-op for FileStore — proxy/cluster state isn't
+// persisted in the JSON file format.
+func (s *FileStore) GetProxyMetrics(_ context.Context) (ProxyMetrics, error) {
+ return ProxyMetrics{}, nil
+}
diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go
index f3c6b741b..c6ced2642 100644
--- a/management/server/store/sql_store.go
+++ b/management/server/store/sql_store.go
@@ -1090,6 +1090,38 @@ func (s *SqlStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, er
return total, validated, nil
}
+// GetProxyMetrics aggregates per-cluster + per-proxy counts for the
+// self-hosted telemetry payload. Single round-trip via conditional
+// aggregations so a large proxies table doesn't fan out into multiple
+// queries.
+func (s *SqlStore) GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) {
+ var m ProxyMetrics
+ activeCutoff := time.Now().Add(-proxyActiveThreshold)
+
+ // COUNT(DISTINCT ... CASE WHEN ...) is portable across sqlite/postgres
+ // (MySQL too) and keeps the round-trip to one. proxy.StatusConnected
+ // is the same string the cluster-capability queries use; the active
+ // window matches the cluster-capability semantics (only proxies
+ // heartbeating within ~2 * heartbeat interval count as connected).
+ row := s.db.WithContext(ctx).
+ Model(&proxy.Proxy{}).
+ Select(
+ "COUNT(DISTINCT cluster_address) AS clusters, "+
+ "COUNT(DISTINCT CASE WHEN account_id IS NOT NULL THEN cluster_address END) AS clusters_byop, "+
+ "COUNT(DISTINCT CASE WHEN private = ? THEN cluster_address END) AS clusters_private, "+
+ "COUNT(*) AS proxies, "+
+ "COUNT(CASE WHEN status = ? AND last_seen > ? THEN 1 END) AS proxies_connected",
+ true,
+ proxy.StatusConnected,
+ activeCutoff,
+ ).
+ Row()
+ if err := row.Scan(&m.Clusters, &m.ClustersBYOP, &m.ClustersPrivate, &m.Proxies, &m.ProxiesConnected); err != nil {
+ return ProxyMetrics{}, fmt.Errorf("scan proxy metrics: %w", err)
+ }
+ return m, nil
+}
+
func (s *SqlStore) GetAllAccounts(ctx context.Context) (all []*types.Account) {
var accounts []types.Account
result := s.db.Find(&accounts)
@@ -1184,6 +1216,7 @@ func (s *SqlStore) getAccountGorm(ctx context.Context, accountID string) (*types
Preload("NetworkResources").
Preload("Onboarding").
Preload("Services.Targets").
+ Preload("Domains").
Take(&account, idQueryCondition, accountID)
if result.Error != nil {
log.WithContext(ctx).Errorf("error when getting account %s from the store: %s", accountID, result.Error)
@@ -1270,7 +1303,7 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types.
}
var wg sync.WaitGroup
- errChan := make(chan error, 12)
+ errChan := make(chan error, 16)
wg.Add(1)
go func() {
@@ -1371,6 +1404,17 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types.
account.Services = services
}()
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ domains, err := s.ListCustomDomains(ctx, accountID)
+ if err != nil {
+ errChan <- err
+ return
+ }
+ account.Domains = domains
+ }()
+
wg.Add(1)
go func() {
defer wg.Done()
@@ -2178,7 +2222,8 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
const serviceQuery = `SELECT id, account_id, name, domain, enabled, auth,
meta_created_at, meta_certificate_issued_at, meta_status, proxy_cluster,
pass_host_header, rewrite_redirects, session_private_key, session_public_key,
- mode, listen_port, port_auto_assigned, source, source_peer, terminated
+ mode, listen_port, port_auto_assigned, source, source_peer, terminated,
+ private, access_groups
FROM services WHERE account_id = $1`
const targetsQuery = `SELECT id, account_id, service_id, path, host, port, protocol,
@@ -2193,10 +2238,11 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
services, err := pgx.CollectRows(serviceRows, func(row pgx.CollectableRow) (*rpservice.Service, error) {
var s rpservice.Service
var auth []byte
+ var accessGroups []byte
var createdAt, certIssuedAt sql.NullTime
var status, proxyCluster, sessionPrivateKey, sessionPublicKey sql.NullString
var mode, source, sourcePeer sql.NullString
- var terminated, portAutoAssigned sql.NullBool
+ var terminated, portAutoAssigned, private sql.NullBool
var listenPort sql.NullInt64
err := row.Scan(
&s.ID,
@@ -2219,6 +2265,8 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
&source,
&sourcePeer,
&terminated,
+ &private,
+ &accessGroups,
)
if err != nil {
return nil, err
@@ -2230,6 +2278,16 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
}
}
+ if len(accessGroups) > 0 {
+ if err := json.Unmarshal(accessGroups, &s.AccessGroups); err != nil {
+ return nil, fmt.Errorf("unmarshal access_groups: %w", err)
+ }
+ }
+
+ if private.Valid {
+ s.Private = private.Bool
+ }
+
s.Meta = rpservice.Meta{}
if createdAt.Valid {
s.Meta.CreatedAt = createdAt.Time
@@ -4315,11 +4373,27 @@ func (s *SqlStore) GetNetworkRouterByID(ctx context.Context, lockStrength Lockin
return netRouter, nil
}
-func (s *SqlStore) SaveNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error {
- result := s.db.Save(router)
+func (s *SqlStore) CreateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error {
+ if err := s.db.Create(router).Error; err != nil {
+ log.WithContext(ctx).Errorf("failed to create network router in store: %v", err)
+ return status.Errorf(status.Internal, "failed to create network router in store")
+ }
+
+ return nil
+}
+
+func (s *SqlStore) UpdateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error {
+ result := s.db.
+ Select("*").
+ Where(accountAndIDQueryCondition, router.AccountID, router.ID).
+ Updates(router)
if result.Error != nil {
- log.WithContext(ctx).Errorf("failed to save network router to store: %v", result.Error)
- return status.Errorf(status.Internal, "failed to save network router to store")
+ log.WithContext(ctx).Errorf("failed to update network router in store: %v", result.Error)
+ return status.Errorf(status.Internal, "failed to update network router in store")
+ }
+
+ if result.RowsAffected == 0 {
+ return status.NewNetworkRouterNotFoundError(router.ID)
}
return nil
@@ -4672,7 +4746,13 @@ func (s *SqlStore) GetPeerByIP(ctx context.Context, lockStrength LockingStrength
result := tx.
Take(&peer, fmt.Sprintf("account_id = ? AND %s = ?", column), accountID, jsonValue)
if result.Error != nil {
- // no logging here
+ // A tunnel-IP miss is an expected outcome (e.g. the proxy's
+ // ValidateTunnelPeer probing an address that isn't in the
+ // account roster); surface it as NotFound so callers can tell
+ // it apart from a real store failure.
+ if errors.Is(result.Error, gorm.ErrRecordNotFound) {
+ return nil, status.Errorf(status.NotFound, "peer with ip %s not found", ip.String())
+ }
return nil, status.Errorf(status.Internal, "failed to get peer from store")
}
@@ -5810,6 +5890,7 @@ var validCapabilityColumns = map[string]struct{}{
"supports_custom_ports": {},
"require_subdomain": {},
"supports_crowdsec": {},
+ "private": {},
}
// GetClusterSupportsCustomPorts returns whether any active proxy in the cluster
@@ -5824,6 +5905,12 @@ func (s *SqlStore) GetClusterRequireSubdomain(ctx context.Context, clusterAddr s
return s.getClusterCapability(ctx, clusterAddr, "require_subdomain")
}
+// GetClusterSupportsPrivate reports whether any active proxy in the cluster
+// has the private capability (nil = unreported).
+func (s *SqlStore) GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
+ return s.getClusterCapability(ctx, clusterAddr, "private")
+}
+
// GetClusterSupportsCrowdSec returns whether all active proxies in the cluster
// have CrowdSec configured. Returns nil when no proxy reported the capability.
// Unlike other capabilities that use ANY-true (for rolling upgrades), CrowdSec
@@ -5893,6 +5980,7 @@ func (s *SqlStore) getClusterCapability(ctx context.Context, clusterAddr, column
}
err := s.db.
+ WithContext(ctx).
Model(&proxy.Proxy{}).
Select("COUNT(CASE WHEN "+column+" IS NOT NULL THEN 1 END) > 0 AS has_capability, "+
"COALESCE(MAX(CASE WHEN "+column+" = true THEN 1 ELSE 0 END), 0) = 1 AS any_true").
diff --git a/management/server/store/sql_store_get_account_test.go b/management/server/store/sql_store_get_account_test.go
index 9a9de8cdd..56f2a6c41 100644
--- a/management/server/store/sql_store_get_account_test.go
+++ b/management/server/store/sql_store_get_account_test.go
@@ -4,6 +4,8 @@ import (
"context"
"net"
"net/netip"
+ "os"
+ "runtime"
"testing"
"time"
@@ -21,6 +23,63 @@ import (
"github.com/netbirdio/netbird/route"
)
+// TestGetAccount_LoadsCustomDomains verifies GetAccount populates account.Domains.
+// SynthesizePrivateServiceZones depends on this relation to anchor a custom-domain
+// private service's DNS zone; without the preload the relation is empty and the
+// service is silently skipped, so a custom domain never resolves on clients.
+func TestGetAccount_LoadsCustomDomains(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("The SQLite store is not properly supported by Windows yet")
+ }
+
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir())
+ require.NoError(t, err)
+ defer cleanup()
+
+ assertGetAccountLoadsCustomDomains(t, store)
+}
+
+func TestPostgresql_GetAccount_LoadsCustomDomains(t *testing.T) {
+ if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" {
+ t.Skip("skip CI tests on darwin and windows")
+ }
+
+ t.Setenv("NETBIRD_STORE_ENGINE", string(types.PostgresStoreEngine))
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir())
+ require.NoError(t, err)
+ t.Cleanup(cleanup)
+
+ assertGetAccountLoadsCustomDomains(t, store)
+}
+
+// assertGetAccountLoadsCustomDomains exercises both the gorm and pgx GetAccount
+// paths: it persists two custom domains and asserts the relation comes back
+// populated, which SynthesizePrivateServiceZones relies on.
+func assertGetAccountLoadsCustomDomains(t *testing.T, store Store) {
+ t.Helper()
+ ctx := context.Background()
+
+ accountID := "acct-custom-domains"
+ require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, accountID, "user-1", "")))
+
+ _, err := store.CreateCustomDomain(ctx, accountID, "example.com", "eu.proxy.netbird.io", true)
+ require.NoError(t, err, "creating the first custom domain must succeed")
+ _, err = store.CreateCustomDomain(ctx, accountID, "apps.acme.io", "us.proxy.netbird.io", false)
+ require.NoError(t, err, "creating the second custom domain must succeed")
+
+ account, err := store.GetAccount(ctx, accountID)
+ require.NoError(t, err)
+ require.Len(t, account.Domains, 2, "GetAccount must preload the account's custom domains")
+
+ byDomain := map[string]string{}
+ for _, d := range account.Domains {
+ require.NotNil(t, d)
+ byDomain[d.Domain] = d.TargetCluster
+ }
+ assert.Equal(t, "eu.proxy.netbird.io", byDomain["example.com"], "custom domain must carry its target cluster")
+ assert.Equal(t, "us.proxy.netbird.io", byDomain["apps.acme.io"], "custom domain must carry its target cluster")
+}
+
// TestGetAccount_ComprehensiveFieldValidation validates that GetAccount properly loads
// all fields and nested objects from the database, including deeply nested structures.
func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) {
diff --git a/management/server/store/sql_store_service_test.go b/management/server/store/sql_store_service_test.go
new file mode 100644
index 000000000..34999da4b
--- /dev/null
+++ b/management/server/store/sql_store_service_test.go
@@ -0,0 +1,46 @@
+package store
+
+import (
+ "context"
+ "os"
+ "runtime"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+)
+
+func TestSqlStore_GetAccount_PrivateServiceRoundtrip(t *testing.T) {
+ if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
+ t.Skip("skip CI tests on darwin and windows")
+ }
+
+ runTestForAllEngines(t, "", func(t *testing.T, store Store) {
+ ctx := context.Background()
+ account := newAccountWithId(ctx, "account_private_svc", "testuser", "")
+ require.NoError(t, store.SaveAccount(ctx, account))
+
+ svc := &rpservice.Service{
+ ID: "svc-private",
+ AccountID: account.Id,
+ Name: "private-svc",
+ Domain: "private.example",
+ ProxyCluster: "cluster.example",
+ Enabled: true,
+ Mode: rpservice.ModeHTTP,
+ Private: true,
+ AccessGroups: []string{"grp-admins", "grp-ops"},
+ }
+ require.NoError(t, store.CreateService(ctx, svc))
+
+ loaded, err := store.GetAccount(ctx, account.Id)
+ require.NoError(t, err)
+ require.Len(t, loaded.Services, 1)
+
+ got := loaded.Services[0]
+ assert.True(t, got.Private)
+ assert.Equal(t, []string{"grp-admins", "grp-ops"}, got.AccessGroups)
+ })
+}
diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go
index 7515add62..0c90eaf5f 100644
--- a/management/server/store/sql_store_test.go
+++ b/management/server/store/sql_store_test.go
@@ -491,6 +491,27 @@ func Test_GetAccount(t *testing.T) {
})
}
+// TestSqlStore_GetPeerByIP_NotFound pins the not-found semantics the
+// proxy's ValidateTunnelPeer relies on: a tunnel-IP that isn't in the
+// account roster must surface as a NotFound error (not a generic
+// Internal) so callers can distinguish an expected miss from a real
+// store failure. A known IP still resolves.
+func TestSqlStore_GetPeerByIP_NotFound(t *testing.T) {
+ runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) {
+ const accountID = "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+
+ peer, err := store.GetPeerByIP(context.Background(), LockingStrengthNone, accountID, net.ParseIP("192.168.0.0"))
+ require.NoError(t, err, "known tunnel IP must resolve")
+ require.NotNil(t, peer)
+
+ _, err = store.GetPeerByIP(context.Background(), LockingStrengthNone, accountID, net.ParseIP("100.65.0.99"))
+ require.Error(t, err, "unknown tunnel IP must error")
+ parsedErr, ok := status.FromError(err)
+ require.True(t, ok, "error must be a status error")
+ require.Equal(t, status.NotFound, parsedErr.Type(), "tunnel-IP miss must be NotFound, not Internal")
+ })
+}
+
func TestSqlStore_SavePeer(t *testing.T) {
store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
t.Cleanup(cleanUp)
@@ -2399,7 +2420,7 @@ func TestSqlStore_GetNetworkRouterByID(t *testing.T) {
}
}
-func TestSqlStore_SaveNetworkRouter(t *testing.T) {
+func TestSqlStore_CreateNetworkRouter(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
t.Cleanup(cleanup)
require.NoError(t, err)
@@ -2410,7 +2431,7 @@ func TestSqlStore_SaveNetworkRouter(t *testing.T) {
netRouter, err := routerTypes.NewNetworkRouter(accountID, networkID, "", []string{"net-router-grp"}, true, 0, true)
require.NoError(t, err)
- err = store.SaveNetworkRouter(context.Background(), netRouter)
+ err = store.CreateNetworkRouter(context.Background(), netRouter)
require.NoError(t, err)
savedNetRouter, err := store.GetNetworkRouterByID(context.Background(), LockingStrengthNone, accountID, netRouter.ID)
@@ -2418,6 +2439,39 @@ func TestSqlStore_SaveNetworkRouter(t *testing.T) {
require.Equal(t, netRouter, savedNetRouter)
}
+func TestSqlStore_UpdateNetworkRouter(t *testing.T) {
+ store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
+ t.Cleanup(cleanup)
+ require.NoError(t, err)
+
+ accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
+ networkID := "ct286bi7qv930dsrrug0"
+ routerID := "ctc20ji7qv9ck2sebc80"
+
+ netRouter := &routerTypes.NetworkRouter{
+ ID: routerID,
+ AccountID: accountID,
+ NetworkID: networkID,
+ Peer: "",
+ PeerGroups: []string{"net-router-grp"},
+ Masquerade: true,
+ Metric: 42,
+ Enabled: true,
+ }
+
+ err = store.UpdateNetworkRouter(context.Background(), netRouter)
+ require.NoError(t, err)
+
+ savedNetRouter, err := store.GetNetworkRouterByID(context.Background(), LockingStrengthNone, accountID, routerID)
+ require.NoError(t, err)
+ require.Equal(t, netRouter, savedNetRouter)
+
+ // Updating a router under a different account must not match any row.
+ netRouter.AccountID = "non-existent-account"
+ err = store.UpdateNetworkRouter(context.Background(), netRouter)
+ require.Error(t, err)
+}
+
func TestSqlStore_DeleteNetworkRouter(t *testing.T) {
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir())
t.Cleanup(cleanup)
diff --git a/management/server/store/store.go b/management/server/store/store.go
index 42cdcf36d..746207f27 100644
--- a/management/server/store/store.go
+++ b/management/server/store/store.go
@@ -228,7 +228,8 @@ type Store interface {
GetNetworkRoutersByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*routerTypes.NetworkRouter, error)
GetNetworkRoutersByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*routerTypes.NetworkRouter, error)
GetNetworkRouterByID(ctx context.Context, lockStrength LockingStrength, accountID, routerID string) (*routerTypes.NetworkRouter, error)
- SaveNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error
+ CreateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error
+ UpdateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error
DeleteNetworkRouter(ctx context.Context, accountID, routerID string) error
GetNetworkResourcesByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*resourceTypes.NetworkResource, error)
@@ -311,6 +312,7 @@ type Store interface {
GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
+ GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error
GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error)
CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error)
@@ -319,9 +321,38 @@ type Store interface {
GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error)
+ // GetProxyMetrics returns aggregated proxy / cluster counts for the
+ // self-hosted metrics worker. Self-hosted only — file-based stores
+ // return a zero-valued struct.
+ GetProxyMetrics(ctx context.Context) (ProxyMetrics, error)
+
GetRoutingPeerNetworks(ctx context.Context, accountID, peerID string) ([]string, error)
}
+// ProxyMetrics aggregates self-hosted proxy + cluster usage signals
+// surfaced to the telemetry payload. Each field is best-effort: when a
+// store cannot answer (e.g. FileStore) all fields are zero.
+type ProxyMetrics struct {
+ // Clusters counts distinct cluster_address values across the proxies
+ // table — every cluster the management server has heard from, online or not.
+ Clusters int64
+ // ClustersBYOP counts distinct cluster_address values that are owned
+ // by an account (account_id IS NOT NULL). These are bring-your-own-proxy
+ // installations as opposed to NetBird-operated shared clusters.
+ ClustersBYOP int64
+ // ClustersPrivate counts distinct cluster_address values where at
+ // least one proxy reported the private capability (embedded
+ // `netbird proxy` running inside a client).
+ ClustersPrivate int64
+ // Proxies is the total number of proxy rows currently persisted.
+ Proxies int64
+ // ProxiesConnected is the subset of proxies whose status is
+ // "connected" AND last_seen falls within the active heartbeat window
+ // (~2 * heartbeat interval). Proxies the controller hasn't pruned
+ // yet but that are visibly stale don't count.
+ ProxiesConnected int64
+}
+
const (
postgresDsnEnv = "NB_STORE_ENGINE_POSTGRES_DSN"
postgresDsnEnvLegacy = "NETBIRD_STORE_ENGINE_POSTGRES_DSN"
diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go
index 4f9d875d2..dfd5af78d 100644
--- a/management/server/store/store_mock.go
+++ b/management/server/store/store_mock.go
@@ -310,6 +310,20 @@ func (mr *MockStoreMockRecorder) CreateGroups(ctx, accountID, groups interface{}
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroups", reflect.TypeOf((*MockStore)(nil).CreateGroups), ctx, accountID, groups)
}
+// CreateNetworkRouter mocks base method.
+func (m *MockStore) CreateNetworkRouter(ctx context.Context, router *types0.NetworkRouter) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "CreateNetworkRouter", ctx, router)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// CreateNetworkRouter indicates an expected call of CreateNetworkRouter.
+func (mr *MockStoreMockRecorder) CreateNetworkRouter(ctx, router interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNetworkRouter", reflect.TypeOf((*MockStore)(nil).CreateNetworkRouter), ctx, router)
+}
+
// CreatePeerJob mocks base method.
func (m *MockStore) CreatePeerJob(ctx context.Context, job *types2.Job) error {
m.ctrl.T.Helper()
@@ -1447,6 +1461,20 @@ func (mr *MockStoreMockRecorder) GetClusterSupportsCustomPorts(ctx, clusterAddr
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsCustomPorts", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsCustomPorts), ctx, clusterAddr)
}
+// GetClusterSupportsPrivate mocks base method.
+func (m *MockStore) GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetClusterSupportsPrivate", ctx, clusterAddr)
+ ret0, _ := ret[0].(*bool)
+ return ret0
+}
+
+// GetClusterSupportsPrivate indicates an expected call of GetClusterSupportsPrivate.
+func (mr *MockStoreMockRecorder) GetClusterSupportsPrivate(ctx, clusterAddr interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsPrivate", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsPrivate), ctx, clusterAddr)
+}
+
// GetCustomDomain mocks base method.
func (m *MockStore) GetCustomDomain(ctx context.Context, accountID, domainID string) (*domain.Domain, error) {
m.ctrl.T.Helper()
@@ -2062,6 +2090,21 @@ func (mr *MockStoreMockRecorder) GetProxyClusters(ctx, accountID interface{}) *g
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyClusters", reflect.TypeOf((*MockStore)(nil).GetProxyClusters), ctx, accountID)
}
+// GetProxyMetrics mocks base method.
+func (m *MockStore) GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetProxyMetrics", ctx)
+ ret0, _ := ret[0].(ProxyMetrics)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetProxyMetrics indicates an expected call of GetProxyMetrics.
+func (mr *MockStoreMockRecorder) GetProxyMetrics(ctx interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyMetrics", reflect.TypeOf((*MockStore)(nil).GetProxyMetrics), ctx)
+}
+
// GetResourceGroups mocks base method.
func (m *MockStore) GetResourceGroups(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) ([]*types2.Group, error) {
m.ctrl.T.Helper()
@@ -2612,6 +2655,36 @@ func (mr *MockStoreMockRecorder) MarkPATUsed(ctx, patID interface{}) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPATUsed", reflect.TypeOf((*MockStore)(nil).MarkPATUsed), ctx, patID)
}
+// MarkPeerConnectedIfNewerSession mocks base method.
+func (m *MockStore) MarkPeerConnectedIfNewerSession(ctx context.Context, accountID, peerID string, newSessionStartedAt int64) (bool, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "MarkPeerConnectedIfNewerSession", ctx, accountID, peerID, newSessionStartedAt)
+ ret0, _ := ret[0].(bool)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// MarkPeerConnectedIfNewerSession indicates an expected call of MarkPeerConnectedIfNewerSession.
+func (mr *MockStoreMockRecorder) MarkPeerConnectedIfNewerSession(ctx, accountID, peerID, newSessionStartedAt interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnectedIfNewerSession", reflect.TypeOf((*MockStore)(nil).MarkPeerConnectedIfNewerSession), ctx, accountID, peerID, newSessionStartedAt)
+}
+
+// MarkPeerDisconnectedIfSameSession mocks base method.
+func (m *MockStore) MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "MarkPeerDisconnectedIfSameSession", ctx, accountID, peerID, sessionStartedAt)
+ ret0, _ := ret[0].(bool)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// MarkPeerDisconnectedIfSameSession indicates an expected call of MarkPeerDisconnectedIfSameSession.
+func (mr *MockStoreMockRecorder) MarkPeerDisconnectedIfSameSession(ctx, accountID, peerID, sessionStartedAt interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerDisconnectedIfSameSession", reflect.TypeOf((*MockStore)(nil).MarkPeerDisconnectedIfSameSession), ctx, accountID, peerID, sessionStartedAt)
+}
+
// MarkPendingJobsAsFailed mocks base method.
func (m *MockStore) MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error {
m.ctrl.T.Helper()
@@ -2822,20 +2895,6 @@ func (mr *MockStoreMockRecorder) SaveNetworkResource(ctx, resource interface{})
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNetworkResource", reflect.TypeOf((*MockStore)(nil).SaveNetworkResource), ctx, resource)
}
-// SaveNetworkRouter mocks base method.
-func (m *MockStore) SaveNetworkRouter(ctx context.Context, router *types0.NetworkRouter) error {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "SaveNetworkRouter", ctx, router)
- ret0, _ := ret[0].(error)
- return ret0
-}
-
-// SaveNetworkRouter indicates an expected call of SaveNetworkRouter.
-func (mr *MockStoreMockRecorder) SaveNetworkRouter(ctx, router interface{}) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNetworkRouter", reflect.TypeOf((*MockStore)(nil).SaveNetworkRouter), ctx, router)
-}
-
// SavePAT mocks base method.
func (m *MockStore) SavePAT(ctx context.Context, pat *types2.PersonalAccessToken) error {
m.ctrl.T.Helper()
@@ -2892,36 +2951,6 @@ func (mr *MockStoreMockRecorder) SavePeerStatus(ctx, accountID, peerID, status i
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeerStatus", reflect.TypeOf((*MockStore)(nil).SavePeerStatus), ctx, accountID, peerID, status)
}
-// MarkPeerConnectedIfNewerSession mocks base method.
-func (m *MockStore) MarkPeerConnectedIfNewerSession(ctx context.Context, accountID, peerID string, newSessionStartedAt int64) (bool, error) {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "MarkPeerConnectedIfNewerSession", ctx, accountID, peerID, newSessionStartedAt)
- ret0, _ := ret[0].(bool)
- ret1, _ := ret[1].(error)
- return ret0, ret1
-}
-
-// MarkPeerConnectedIfNewerSession indicates an expected call of MarkPeerConnectedIfNewerSession.
-func (mr *MockStoreMockRecorder) MarkPeerConnectedIfNewerSession(ctx, accountID, peerID, newSessionStartedAt interface{}) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnectedIfNewerSession", reflect.TypeOf((*MockStore)(nil).MarkPeerConnectedIfNewerSession), ctx, accountID, peerID, newSessionStartedAt)
-}
-
-// MarkPeerDisconnectedIfSameSession mocks base method.
-func (m *MockStore) MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error) {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "MarkPeerDisconnectedIfSameSession", ctx, accountID, peerID, sessionStartedAt)
- ret0, _ := ret[0].(bool)
- ret1, _ := ret[1].(error)
- return ret0, ret1
-}
-
-// MarkPeerDisconnectedIfSameSession indicates an expected call of MarkPeerDisconnectedIfSameSession.
-func (mr *MockStoreMockRecorder) MarkPeerDisconnectedIfSameSession(ctx, accountID, peerID, sessionStartedAt interface{}) *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerDisconnectedIfSameSession", reflect.TypeOf((*MockStore)(nil).MarkPeerDisconnectedIfSameSession), ctx, accountID, peerID, sessionStartedAt)
-}
-
// SavePolicy mocks base method.
func (m *MockStore) SavePolicy(ctx context.Context, policy *types2.Policy) error {
m.ctrl.T.Helper()
@@ -3173,6 +3202,20 @@ func (mr *MockStoreMockRecorder) UpdateGroups(ctx, accountID, groups interface{}
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroups", reflect.TypeOf((*MockStore)(nil).UpdateGroups), ctx, accountID, groups)
}
+// UpdateNetworkRouter mocks base method.
+func (m *MockStore) UpdateNetworkRouter(ctx context.Context, router *types0.NetworkRouter) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateNetworkRouter", ctx, router)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpdateNetworkRouter indicates an expected call of UpdateNetworkRouter.
+func (mr *MockStoreMockRecorder) UpdateNetworkRouter(ctx, router interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateNetworkRouter", reflect.TypeOf((*MockStore)(nil).UpdateNetworkRouter), ctx, router)
+}
+
// UpdateProxyHeartbeat mocks base method.
func (m *MockStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error {
m.ctrl.T.Helper()
diff --git a/management/server/telemetry/accountmanager_metrics.go b/management/server/telemetry/accountmanager_metrics.go
index bb6fb7e12..4b370bde3 100644
--- a/management/server/telemetry/accountmanager_metrics.go
+++ b/management/server/telemetry/accountmanager_metrics.go
@@ -13,6 +13,7 @@ type AccountManagerMetrics struct {
ctx context.Context
updateAccountPeersDurationMs metric.Float64Histogram
updateAccountPeersCounter metric.Int64Counter
+ nmapCounter metric.Int64Counter
getPeerNetworkMapDurationMs metric.Float64Histogram
networkMapObjectCount metric.Int64Histogram
peerMetaUpdateCount metric.Int64Counter
@@ -59,6 +60,13 @@ func NewAccountManagerMetrics(ctx context.Context, meter metric.Meter) (*Account
return nil, err
}
+ nmapCounter, err := meter.Int64Counter("management.network.map.counter",
+ metric.WithUnit("1"),
+ metric.WithDescription("Number of network maps computed, labeled by resource and operation trigger"))
+ if err != nil {
+ return nil, err
+ }
+
peerMetaUpdateCount, err := meter.Int64Counter("management.account.peer.meta.update.counter",
metric.WithUnit("1"),
metric.WithDescription("Number of updates with new meta data from the peers"))
@@ -93,6 +101,7 @@ func NewAccountManagerMetrics(ctx context.Context, meter metric.Meter) (*Account
peerMetaUpdateCount: peerMetaUpdateCount,
peerStatusUpdateCounter: peerStatusUpdateCounter,
peerStatusUpdateDurationMs: peerStatusUpdateDurationMs,
+ nmapCounter: nmapCounter,
}, nil
}
@@ -145,6 +154,16 @@ func (metrics *AccountManagerMetrics) CountUpdateAccountPeersTriggered(resource,
)
}
+// CountNmapTriggered increments the counter for calculated network maps with resource and operation labels.
+func (metrics *AccountManagerMetrics) CountNmapTriggered(resource, operation string) {
+ metrics.nmapCounter.Add(metrics.ctx, 1,
+ metric.WithAttributes(
+ attribute.String("resource", resource),
+ attribute.String("operation", operation),
+ ),
+ )
+}
+
// CountPeerMetUpdate counts the number of peer meta updates
func (metrics *AccountManagerMetrics) CountPeerMetUpdate() {
metrics.peerMetaUpdateCount.Add(metrics.ctx, 1)
diff --git a/management/server/telemetry/http_api_metrics.go b/management/server/telemetry/http_api_metrics.go
index e48e6d64a..360d36949 100644
--- a/management/server/telemetry/http_api_metrics.go
+++ b/management/server/telemetry/http_api_metrics.go
@@ -21,6 +21,8 @@ const (
httpRequestCounterPrefix = "management.http.request.counter"
httpResponseCounterPrefix = "management.http.response.counter"
httpRequestDurationPrefix = "management.http.request.duration.ms"
+
+ RequestIDHeader = "X-Request-Id"
)
// WrappedResponseWriter is a wrapper for http.ResponseWriter that allows the
@@ -172,6 +174,10 @@ func (m *HTTPMiddleware) Handler(h http.Handler) http.Handler {
reqID := xid.New().String()
//nolint
ctx = context.WithValue(ctx, nbContext.RequestIDKey, reqID)
+ //nolint
+ ctx = context.WithValue(ctx, nbContext.UserAgentKey, r.UserAgent())
+
+ rw.Header().Set(RequestIDHeader, reqID)
log.WithContext(ctx).Tracef("HTTP request %v: %v %v", reqID, r.Method, r.URL)
diff --git a/management/server/testdata/networks.sql b/management/server/testdata/networks.sql
index bcb202084..911b3bb27 100644
--- a/management/server/testdata/networks.sql
+++ b/management/server/testdata/networks.sql
@@ -9,9 +9,13 @@ INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM
CREATE TABLE `networks` (`id` text,`account_id` text,`name` text,`description` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_networks` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`));
INSERT INTO networks VALUES('testNetworkId','testAccountId','some-name','some-description');
+INSERT INTO networks VALUES('secondNetworkId','testAccountId','second-name','second-description');
CREATE TABLE `network_routers` (`id` text,`network_id` text,`account_id` text,`peer` text,`peer_groups` text,`masquerade` numeric,`metric` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_network_routers` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`));
INSERT INTO network_routers VALUES('testRouterId','testNetworkId','testAccountId','','["csquuo4jcko732k1ag00"]',0,9999);
+INSERT INTO accounts VALUES('otherAccountId','','2024-10-02 16:01:38.000000000+00:00','other.com','private',1,'otherNetworkIdentifier','{"IP":"100.65.0.0","Mask":"//8AAA=="}','',0,'[]',0,86400000000000,0,0,0,'',NULL,NULL,NULL);
+INSERT INTO networks VALUES('otherNetworkId','otherAccountId','other-net','other-description');
+INSERT INTO network_routers VALUES('otherRouterId','otherNetworkId','otherAccountId','otherPeer',NULL,0,1);
CREATE TABLE `network_resources` (`id` text,`network_id` text,`account_id` text,`name` text,`description` text,`type` text,`address` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_network_resources` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`));
INSERT INTO network_resources VALUES('testResourceId','testNetworkId','testAccountId','some-name','some-description','host','3.3.3.3/32');
diff --git a/management/server/types/account.go b/management/server/types/account.go
index 8110216be..f9228b54a 100644
--- a/management/server/types/account.go
+++ b/management/server/types/account.go
@@ -29,10 +29,13 @@ import (
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/shared/management/status"
+ "github.com/netbirdio/netbird/version"
)
const (
- defaultTTL = 300
+ defaultTTL = 300
+ // privateServiceDNSRecordTTL is short so proxy-peer changes propagate quickly to clients.
+ privateServiceDNSRecordTTL = 5
DefaultPeerLoginExpiration = 24 * time.Hour
DefaultPeerInactivityExpiration = 10 * time.Minute
@@ -258,6 +261,149 @@ func getUniqueHostLabel(name string, peerLabels LookupMap) string {
return ""
}
+// SynthesizePrivateServiceZones returns in-memory CustomZones with A records pointing each enabled private service the peer can reach at the cluster's proxy-peer IPs. One zone per cluster (multiple services share); records gated by AccessGroups.
+func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZone {
+ peer, ok := a.Peers[peerID]
+ if !ok || peer == nil {
+ return nil
+ }
+ if len(a.Services) == 0 {
+ return nil
+ }
+
+ proxyPeersByCluster := a.GetProxyPeers()
+ if len(proxyPeersByCluster) == 0 {
+ return nil
+ }
+
+ peerGroups := a.GetPeerGroups(peerID)
+ zonesByApex := map[string]*nbdns.CustomZone{}
+
+ for _, svc := range a.Services {
+ if svc == nil || !svc.Enabled || !svc.Private {
+ continue
+ }
+ if len(svc.AccessGroups) == 0 {
+ continue
+ }
+ if !peerInDistributionGroups(peerGroups, svc.AccessGroups) {
+ continue
+ }
+ proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
+ if len(proxyPeers) == 0 {
+ continue
+ }
+
+ serviceDomainZone := a.privateServiceDomainZone(svc)
+ if serviceDomainZone == "" {
+ continue
+ }
+
+ zone, exists := zonesByApex[serviceDomainZone]
+ if !exists {
+ // NonAuthoritative makes this a match-only zone: queries for
+ // names without an explicit record fall through to the
+ // upstream resolver instead of returning NXDOMAIN. Without
+ // it, adding a single private service would black-hole every
+ // other name under the zone apex.
+ zone = &nbdns.CustomZone{
+ Domain: dns.Fqdn(serviceDomainZone),
+ Records: []nbdns.SimpleRecord{},
+ NonAuthoritative: true,
+ }
+ zonesByApex[serviceDomainZone] = zone
+ }
+
+ emitted := 0
+ skippedDisconnected := 0
+ for _, p := range proxyPeers {
+ if p == nil || !p.IP.IsValid() {
+ continue
+ }
+ // Only emit a record when the proxy peer is actually
+ // connected. A disconnected proxy peer's tunnel IP won't
+ // answer; pointing DNS at it would produce a black hole
+ // for as long as the record is cached client-side.
+ if p.Status == nil || !p.Status.Connected {
+ skippedDisconnected++
+ continue
+ }
+ zone.Records = append(zone.Records, nbdns.SimpleRecord{
+ Name: dns.Fqdn(svc.Domain),
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: privateServiceDNSRecordTTL,
+ RData: p.IP.String(),
+ })
+ emitted++
+ }
+ // Disagreement with the firewall path is the typical
+ // "domain doesn't reach client but firewall rules do"
+ // symptom: the synth service is otherwise fine, only the
+ // proxy peer's persisted Connected flag is wrong (most
+ // likely the connection reaper marked it disconnected even
+ // though the gRPC stream is alive).
+ if emitted == 0 && skippedDisconnected > 0 {
+ log.Debugf("private-zone synth: svc %s domain=%s cluster=%s emitted_zero proxy_peers=%d all_disconnected=%d (firewall would still fire)",
+ svc.ID, svc.Domain, svc.ProxyCluster, len(proxyPeers), skippedDisconnected)
+ }
+ }
+
+ out := make([]nbdns.CustomZone, 0, len(zonesByApex))
+ for _, zone := range zonesByApex {
+ if len(zone.Records) == 0 {
+ continue
+ }
+ out = append(out, *zone)
+ }
+ if len(out) == 0 && len(a.Services) > 0 {
+ // Targeted diagnostic for the "firewall yes, DNS no" divergence —
+ // fires only when services exist but synth returns zero zones,
+ // so accounts without private services produce no noise.
+ log.Debugf("private-zone synth: peer %s account %s returned 0 zones from %d candidate service(s)",
+ peerID, a.Id, len(a.Services))
+ }
+ return out
+}
+
+// privateServiceDomainZone returns the DNS zone name for the given private service domain by
+// looking at the proxy cluster domain then the custom domains.
+func (a *Account) privateServiceDomainZone(svc *service.Service) string {
+ if domainFromSuffix(svc.Domain, svc.ProxyCluster) {
+ return svc.ProxyCluster
+ }
+
+ // Longest matching custom domain wins
+ zoneName := ""
+ for _, d := range a.Domains {
+ if d == nil || d.TargetCluster != svc.ProxyCluster {
+ continue
+ }
+ if domainFromSuffix(svc.Domain, d.Domain) && len(d.Domain) > len(zoneName) {
+ zoneName = d.Domain
+ }
+ }
+ return zoneName
+}
+
+func domainFromSuffix(domain, suffix string) bool {
+ if suffix == "" {
+ return false
+ }
+ return domain == suffix || strings.HasSuffix(domain, "."+suffix)
+}
+
+// peerInDistributionGroups reports whether any of the peer's groups
+// matches the service's bearer-auth distribution_groups.
+func peerInDistributionGroups(peerGroups LookupMap, distributionGroups []string) bool {
+ for _, gid := range distributionGroups {
+ if _, ok := peerGroups[gid]; ok {
+ return true
+ }
+ }
+ return false
+}
+
func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdns.CustomZone {
var merr *multierror.Error
@@ -1483,6 +1629,53 @@ func (a *Account) injectServiceProxyPolicies(ctx context.Context, service *servi
a.injectTargetProxyPolicies(ctx, service, target, proxyPeers)
}
+ a.injectPrivateServicePolicies(service, proxyPeers)
+}
+
+// injectPrivateServicePolicies synthesises an in-memory ACL: AccessGroups → cluster proxy peers on TCP 80/443.
+func (a *Account) injectPrivateServicePolicies(svc *service.Service, proxyPeers []*nbpeer.Peer) {
+ if !svc.Private {
+ return
+ }
+ if len(svc.AccessGroups) == 0 {
+ return
+ }
+ if len(proxyPeers) == 0 {
+ return
+ }
+ for _, proxyPeer := range proxyPeers {
+ a.Policies = append(a.Policies, a.createPrivateServicePolicy(svc, proxyPeer))
+ }
+}
+
+func (a *Account) createPrivateServicePolicy(svc *service.Service, proxyPeer *nbpeer.Peer) *Policy {
+ policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID)
+ sources := append([]string(nil), svc.AccessGroups...)
+ return &Policy{
+ ID: policyID,
+ Name: fmt.Sprintf("Private Access to %s", svc.Name),
+ Enabled: true,
+ Rules: []*PolicyRule{
+ {
+ ID: policyID,
+ PolicyID: policyID,
+ Name: fmt.Sprintf("Allow access groups to reach %s", svc.Name),
+ Enabled: true,
+ Sources: sources,
+ DestinationResource: Resource{
+ ID: proxyPeer.ID,
+ Type: ResourceTypePeer,
+ },
+ Bidirectional: false,
+ Protocol: PolicyRuleProtocolTCP,
+ Action: PolicyTrafficActionAccept,
+ PortRanges: []RulePortRange{
+ {Start: 80, End: 80},
+ {Start: 443, End: 443},
+ },
+ },
+ },
+ }
}
func (a *Account) injectTargetProxyPolicies(ctx context.Context, service *service.Service, target *service.Target, proxyPeers []*nbpeer.Peer) {
@@ -1629,7 +1822,7 @@ func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *n
// peerSupportedFirewallFeatures checks if the peer version supports port ranges.
func peerSupportedFirewallFeatures(peerVer string) supportedFeatures {
- if strings.Contains(peerVer, "dev") {
+ if version.IsDevelopmentVersion(peerVer) {
return supportedFeatures{true, true}
}
diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go
index 2b4f7e051..a42028351 100644
--- a/management/server/types/account_components.go
+++ b/management/server/types/account_components.go
@@ -119,6 +119,7 @@ func (a *Account) GetPeerNetworkMapComponents(
peerGroups := a.GetPeerGroups(peerID)
components.AccountZones = filterPeerAppliedZones(ctx, accountZones, peerGroups)
+ components.AccountZones = append(components.AccountZones, a.SynthesizePrivateServiceZones(peerID)...)
for _, nsGroup := range a.NameServerGroups {
if nsGroup.Enabled {
diff --git a/management/server/types/account_private_netmap_test.go b/management/server/types/account_private_netmap_test.go
new file mode 100644
index 000000000..dc097ce26
--- /dev/null
+++ b/management/server/types/account_private_netmap_test.go
@@ -0,0 +1,85 @@
+package types
+
+import (
+ "context"
+ "testing"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ nbdns "github.com/netbirdio/netbird/dns"
+ nbpeer "github.com/netbirdio/netbird/management/server/peer"
+)
+
+func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Peers["user-peer"].Meta.WtVersion = "0.50.0"
+ account.Peers["proxy-peer"].Meta.WtVersion = "0.50.0"
+
+ ctx := context.Background()
+ account.InjectProxyPolicies(ctx)
+
+ validated := map[string]struct{}{
+ "user-peer": {},
+ "proxy-peer": {},
+ }
+
+ t.Run("user-peer update", func(t *testing.T) {
+ nm := account.GetPeerNetworkMapFromComponents(ctx, "user-peer", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil)
+ require.NotNil(t, nm)
+
+ zone, ok := findCustomZone(nm.DNSConfig.CustomZones, "eu.proxy.netbird.io")
+ require.True(t, ok)
+ require.Len(t, zone.Records, 1)
+ assert.Equal(t, "myapp.eu.proxy.netbird.io.", zone.Records[0].Name)
+ assert.Equal(t, int(dns.TypeA), zone.Records[0].Type)
+ assert.Equal(t, "100.64.0.99", zone.Records[0].RData)
+
+ assert.Contains(t, netmapPeerIDs(nm.Peers), "proxy-peer")
+ assertPrivateServiceFirewallRules(t, nm.FirewallRules, "100.64.0.99", FirewallRuleDirectionOUT)
+ })
+
+ t.Run("proxy-peer update", func(t *testing.T) {
+ nm := account.GetPeerNetworkMapFromComponents(ctx, "proxy-peer", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil)
+ require.NotNil(t, nm)
+
+ assert.Contains(t, netmapPeerIDs(nm.Peers), "user-peer")
+ assertPrivateServiceFirewallRules(t, nm.FirewallRules, "100.64.0.10", FirewallRuleDirectionIN)
+ })
+}
+
+func netmapPeerIDs(peers []*nbpeer.Peer) []string {
+ ids := make([]string, 0, len(peers))
+ for _, p := range peers {
+ if p == nil {
+ continue
+ }
+ ids = append(ids, p.ID)
+ }
+ return ids
+}
+
+func assertPrivateServiceFirewallRules(t *testing.T, rules []*FirewallRule, peerIP string, direction int) {
+ t.Helper()
+ wantPorts := map[uint16]bool{80: false, 443: false}
+ for _, r := range rules {
+ if r == nil || r.PeerIP != peerIP || r.Direction != direction {
+ continue
+ }
+ if r.Protocol != string(PolicyRuleProtocolTCP) || r.Action != string(PolicyTrafficActionAccept) {
+ continue
+ }
+ switch {
+ case r.PortRange.Start == r.PortRange.End && r.PortRange.Start != 0:
+ wantPorts[r.PortRange.Start] = true
+ case r.Port == "80":
+ wantPorts[80] = true
+ case r.Port == "443":
+ wantPorts[443] = true
+ }
+ }
+ for port, found := range wantPorts {
+ assert.Truef(t, found, "missing TCP accept rule on port %d for peer %s direction %d", port, peerIP, direction)
+ }
+}
diff --git a/management/server/types/account_private_zones_test.go b/management/server/types/account_private_zones_test.go
new file mode 100644
index 000000000..efbbbffaf
--- /dev/null
+++ b/management/server/types/account_private_zones_test.go
@@ -0,0 +1,433 @@
+package types
+
+import (
+ "context"
+ "net"
+ "net/netip"
+ "testing"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ nbdns "github.com/netbirdio/netbird/dns"
+ proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
+ "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+ nbpeer "github.com/netbirdio/netbird/management/server/peer"
+)
+
+func privateZoneTestAccount(t *testing.T) *Account {
+ t.Helper()
+ return &Account{
+ Id: "acct-1",
+ Settings: &Settings{},
+ Network: &Network{
+ Identifier: "net-1",
+ Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
+ },
+ Peers: map[string]*nbpeer.Peer{
+ "user-peer": {
+ ID: "user-peer",
+ AccountID: "acct-1",
+ Key: "user-peer-key",
+ IP: netip.MustParseAddr("100.64.0.10"),
+ Status: &nbpeer.PeerStatus{Connected: true},
+ },
+ "proxy-peer": {
+ ID: "proxy-peer",
+ AccountID: "acct-1",
+ Key: "proxy-peer-key",
+ IP: netip.MustParseAddr("100.64.0.99"),
+ Status: &nbpeer.PeerStatus{Connected: true},
+ ProxyMeta: nbpeer.ProxyMeta{
+ Embedded: true,
+ Cluster: "eu.proxy.netbird.io",
+ },
+ },
+ },
+ Groups: map[string]*Group{
+ "grp-admins": {
+ ID: "grp-admins",
+ Name: "admins",
+ Peers: []string{"user-peer"},
+ },
+ },
+ Services: []*service.Service{
+ {
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Name: "myapp",
+ Domain: "myapp.eu.proxy.netbird.io",
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ },
+ },
+ }
+}
+
+func TestSynthesizePrivateServiceZones_PeerInGroup_GetsRecord(t *testing.T) {
+ account := privateZoneTestAccount(t)
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 1, "one cluster should produce one zone")
+ zone := zones[0]
+ assert.Equal(t, "eu.proxy.netbird.io.", zone.Domain, "zone apex must be the cluster FQDN")
+ assert.True(t, zone.NonAuthoritative, "synth zone must be match-only so unrelated sibling names fall through to the upstream resolver")
+ require.Len(t, zone.Records, 1, "one private service yields one A record")
+ rec := zone.Records[0]
+ assert.Equal(t, "myapp.eu.proxy.netbird.io.", rec.Name, "record name is the service FQDN")
+ assert.Equal(t, int(dns.TypeA), rec.Type, "record type must be A")
+ assert.Equal(t, "100.64.0.99", rec.RData, "record points at the embedded proxy peer's tunnel IP")
+ assert.Equal(t, privateServiceDNSRecordTTL, rec.TTL, "TTL must match the synth-records constant")
+ assert.Equal(t, nbdns.DefaultClass, rec.Class, "record class must be the package default")
+}
+
+func TestSynthesizePrivateServiceZones_PeerNotInGroup_NoRecord(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Groups["grp-admins"].Peers = nil
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "peer outside distribution_groups must not see private-service records")
+}
+
+func TestSynthesizePrivateServiceZones_NotPrivate_NoRecord(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Services[0].Private = false
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "non-private service must not produce DNS records")
+}
+
+func TestSynthesizePrivateServiceZones_NoAccessGroups_NoRecord(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Services[0].AccessGroups = nil
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "private service without bearer auth must not produce DNS records")
+}
+
+func TestSynthesizePrivateServiceZones_NoProxyPeers_NoRecord(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ delete(account.Peers, "proxy-peer")
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "no embedded proxy peer in cluster means no record to emit")
+}
+
+func TestSynthesizePrivateServiceZones_DisabledService_NoRecord(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Services[0].Enabled = false
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "disabled service must not produce DNS records")
+}
+
+func TestSynthesizePrivateServiceZones_DisconnectedProxyPeer_NoRecord(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Peers["proxy-peer"].Status = &nbpeer.PeerStatus{Connected: false}
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "disconnected proxy peer must not produce a DNS record (would be a black hole)")
+}
+
+func TestSynthesizePrivateServiceZones_PartiallyDisconnectedProxyPeers_OnlyConnectedSurface(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Peers["proxy-peer-2"] = &nbpeer.Peer{
+ ID: "proxy-peer-2",
+ AccountID: "acct-1",
+ Key: "proxy-peer-2-key",
+ IP: netip.MustParseAddr("100.64.0.100"),
+ Status: &nbpeer.PeerStatus{Connected: false},
+ ProxyMeta: nbpeer.ProxyMeta{Embedded: true, Cluster: "eu.proxy.netbird.io"},
+ }
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 1)
+ require.Len(t, zones[0].Records, 1, "only the connected proxy peer must surface")
+ assert.Equal(t, "100.64.0.99", zones[0].Records[0].RData)
+}
+
+func TestSynthesizePrivateServiceZones_MultipleProxyPeers_RoundRobin(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Peers["proxy-peer-2"] = &nbpeer.Peer{
+ ID: "proxy-peer-2",
+ AccountID: "acct-1",
+ Key: "proxy-peer-2-key",
+ IP: netip.MustParseAddr("100.64.0.100"),
+ Status: &nbpeer.PeerStatus{Connected: true},
+ ProxyMeta: nbpeer.ProxyMeta{Embedded: true, Cluster: "eu.proxy.netbird.io"},
+ }
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 1, "still one cluster yields one zone")
+ require.Len(t, zones[0].Records, 2, "two proxy peers must produce two A records on the same name")
+ rdata := []string{zones[0].Records[0].RData, zones[0].Records[1].RData}
+ assert.ElementsMatch(t, []string{"100.64.0.99", "100.64.0.100"}, rdata, "both proxy peer IPs must surface")
+}
+
+// findCustomZone returns the CustomZone whose Domain equals the FQDN
+// of want, or a zero value when not found. Tests use it to assert
+// that the synth zone reaches dnsUpdate.CustomZones end-to-end.
+func findCustomZone(zones []nbdns.CustomZone, want string) (nbdns.CustomZone, bool) {
+ wantFqdn := dns.Fqdn(want)
+ for _, z := range zones {
+ if z.Domain == wantFqdn {
+ return z, true
+ }
+ }
+ return nbdns.CustomZone{}, false
+}
+
+// TestPrivateZone_GetPeerNetworkMapFromComponents_ShipsSynthZone
+// covers the components-based builder path. The components builder
+// appends SynthesizePrivateServiceZones to AccountZones; the
+// CalculateNetworkMapFromComponents step then merges AccountZones
+// into dnsUpdate.CustomZones.
+func TestPrivateZone_GetPeerNetworkMapFromComponents_ShipsSynthZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ ctx := context.Background()
+ validated := map[string]struct{}{
+ "user-peer": {},
+ "proxy-peer": {},
+ }
+
+ nm := account.GetPeerNetworkMapFromComponents(ctx, "user-peer", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil)
+ require.NotNil(t, nm, "network map must be produced for an in-account peer")
+
+ zone, ok := findCustomZone(nm.DNSConfig.CustomZones, "eu.proxy.netbird.io")
+ require.True(t, ok, "shipped CustomZones must include the synth zone for the cluster")
+ require.Len(t, zone.Records, 1, "exactly one record per private service per connected proxy peer")
+ rec := zone.Records[0]
+ assert.Equal(t, "myapp.eu.proxy.netbird.io.", rec.Name, "record name is the service FQDN")
+ assert.Equal(t, "100.64.0.99", rec.RData, "record points at the embedded proxy peer's tunnel IP")
+}
+
+// TestPrivateZone_GetPeerNetworkMap_PeerOutsideGroups_OmitsSynthZone
+// confirms the negative case the user encountered: a peer whose
+// groups don't overlap the policy's distribution_groups gets a
+// network map with no synth zone (and the wildcard / peer zones still
+// flow through). This is the test mirror of the runtime confusion
+// where the user looked at a non-distribution-group peer and assumed
+// the synth path was broken.
+func TestPrivateZone_GetPeerNetworkMap_PeerOutsideGroups_OmitsSynthZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Peers["outsider"] = &nbpeer.Peer{
+ ID: "outsider",
+ AccountID: "acct-1",
+ Key: "outsider-key",
+ IP: netip.MustParseAddr("100.64.0.20"),
+ Status: &nbpeer.PeerStatus{Connected: true},
+ }
+ ctx := context.Background()
+ validated := map[string]struct{}{
+ "user-peer": {},
+ "proxy-peer": {},
+ "outsider": {},
+ }
+
+ nm := account.GetPeerNetworkMapFromComponents(ctx, "outsider", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil)
+ require.NotNil(t, nm)
+
+ _, ok := findCustomZone(nm.DNSConfig.CustomZones, "eu.proxy.netbird.io")
+ assert.False(t, ok, "peer outside the distribution_groups must not see the synth zone")
+}
+
+func TestSynthesizePrivateServiceZones_CustomDomain_ZoneApexIsRegisteredDomain(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ // A custom-domain service: Domain is the custom FQDN, ProxyCluster
+ // is the cluster serving it, and account.Domains holds the registered
+ // custom domain. The synth zone apex must be the registered domain,
+ // not the cluster, or the client's match-only zone never intercepts
+ // the query.
+ account.Services[0].Domain = "app.example.com"
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
+ }
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 1, "custom-domain service must still produce one zone")
+ zone := zones[0]
+ assert.Equal(t, "example.com.", zone.Domain, "zone apex must be the registered custom domain, not the cluster or the service FQDN")
+ assert.True(t, zone.NonAuthoritative, "synth zone must remain match-only")
+ require.Len(t, zone.Records, 1, "custom-domain service yields one A record")
+ rec := zone.Records[0]
+ assert.Equal(t, "app.example.com.", rec.Name, "record name is the custom service FQDN")
+ assert.Equal(t, "100.64.0.99", rec.RData, "record points at the embedded proxy peer's tunnel IP")
+}
+
+func TestSynthesizePrivateServiceZones_CustomAndFreeDomain_SeparateZones(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
+ }
+ account.Services = append(account.Services, &service.Service{
+ ID: "svc-2",
+ AccountID: "acct-1",
+ Name: "custom",
+ Domain: "app.example.com",
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ })
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 2, "a free-domain and a custom-domain service must not collapse into one zone")
+
+ free, ok := findCustomZone(zones, "eu.proxy.netbird.io")
+ require.True(t, ok, "free-domain service keeps the shared cluster-apex zone")
+ require.Len(t, free.Records, 1, "cluster zone carries only the free-domain record")
+ assert.Equal(t, "myapp.eu.proxy.netbird.io.", free.Records[0].Name, "cluster zone record is the free-domain FQDN")
+
+ custom, ok := findCustomZone(zones, "example.com")
+ require.True(t, ok, "custom-domain service gets its own zone at the registered custom domain apex")
+ require.Len(t, custom.Records, 1, "custom zone carries only the custom-domain record")
+ assert.Equal(t, "app.example.com.", custom.Records[0].Name, "custom zone record is the custom-domain FQDN")
+}
+
+func TestSynthesizePrivateServiceZones_TwoServicesSameCustomDomain_OneZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
+ }
+ account.Services[0].Domain = "a.example.com"
+ account.Services = append(account.Services, &service.Service{
+ ID: "svc-2",
+ AccountID: "acct-1",
+ Name: "bapp",
+ Domain: "b.example.com",
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ })
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 1, "two services under the same registered custom domain must share one zone")
+ assert.Equal(t, "example.com.", zones[0].Domain, "shared zone apex is the registered custom domain")
+ require.Len(t, zones[0].Records, 2, "both services surface as records in the shared custom-domain zone")
+ names := []string{zones[0].Records[0].Name, zones[0].Records[1].Name}
+ assert.ElementsMatch(t, []string{"a.example.com.", "b.example.com."}, names, "both custom-domain service FQDNs must surface")
+}
+
+func TestSynthesizePrivateServiceZones_CustomDomainNotRegistered_NoZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ // Service domain is outside the cluster and no account.Domains entry
+ // covers it: there is no apex that would intercept the query, so the
+ // service must be skipped rather than emit an unmatchable record.
+ account.Services[0].Domain = "app.example.com"
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "a custom-domain service with no registered domain apex must not produce a zone")
+}
+
+func TestSynthesizePrivateServiceZones_CustomDomainClusterMismatch_NoZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ // The registered custom domain matches the service FQDN by suffix but
+ // targets a different cluster than the service's ProxyCluster. It must
+ // be ignored, leaving no apex to intercept the query — otherwise the
+ // zone would point at this cluster's proxy peers under a domain owned
+ // by a different cluster.
+ account.Services[0].Domain = "app.example.com"
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "us.proxy.netbird.io", Validated: true},
+ }
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ assert.Empty(t, zones, "a custom domain targeting a different cluster must not anchor the service zone")
+}
+
+func TestSynthesizePrivateServiceZones_TwoServicesSameCluster_OneZone(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Services = append(account.Services, &service.Service{
+ ID: "svc-2",
+ AccountID: "acct-1",
+ Name: "anotherapp",
+ Domain: "anotherapp.eu.proxy.netbird.io",
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ })
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 1, "two services on the same cluster must collapse into one zone")
+ require.Len(t, zones[0].Records, 2, "two services yield two A records")
+ names := []string{zones[0].Records[0].Name, zones[0].Records[1].Name}
+ assert.ElementsMatch(t, []string{"myapp.eu.proxy.netbird.io.", "anotherapp.eu.proxy.netbird.io."}, names, "both service domains must surface")
+}
+
+func TestSynthesizePrivateServiceZones_MixedClusterCustomAndPublic(t *testing.T) {
+ account := privateZoneTestAccount(t)
+ account.Domains = []*proxydomain.Domain{
+ {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
+ }
+
+ privateService := func(id, domain string) *service.Service {
+ return &service.Service{
+ ID: id,
+ AccountID: "acct-1",
+ Name: id,
+ Domain: domain,
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ }
+ }
+ publicService := func(id, domain string) *service.Service {
+ s := privateService(id, domain)
+ s.Private = false
+ return s
+ }
+
+ account.Services = []*service.Service{
+ // 3 private services under the cluster suffix.
+ privateService("cluster-1", "cluster1.eu.proxy.netbird.io"),
+ privateService("cluster-2", "cluster2.eu.proxy.netbird.io"),
+ privateService("cluster-3", "cluster3.eu.proxy.netbird.io"),
+ // 4 private services under the custom domain suffix.
+ privateService("custom-1", "custom1.example.com"),
+ privateService("custom-2", "custom2.example.com"),
+ privateService("custom-3", "custom3.example.com"),
+ privateService("custom-4", "custom4.example.com"),
+ // 2 public services, one per suffix, must not surface.
+ publicService("public-cluster", "public.eu.proxy.netbird.io"),
+ publicService("public-custom", "public.example.com"),
+ }
+
+ zones := account.SynthesizePrivateServiceZones("user-peer")
+ require.Len(t, zones, 2, "one zone per apex: the cluster apex and the custom domain apex")
+
+ cluster, ok := findCustomZone(zones, "eu.proxy.netbird.io")
+ require.True(t, ok, "cluster-suffix services collapse into the cluster-apex zone")
+ clusterNames := recordNames(cluster)
+ assert.ElementsMatch(t,
+ []string{"cluster1.eu.proxy.netbird.io.", "cluster2.eu.proxy.netbird.io.", "cluster3.eu.proxy.netbird.io."},
+ clusterNames,
+ "only the 3 private cluster services surface in the cluster zone (public one excluded)")
+
+ custom, ok := findCustomZone(zones, "example.com")
+ require.True(t, ok, "custom-suffix services collapse into the custom-domain-apex zone")
+ customNames := recordNames(custom)
+ assert.ElementsMatch(t,
+ []string{"custom1.example.com.", "custom2.example.com.", "custom3.example.com.", "custom4.example.com."},
+ customNames,
+ "only the 4 private custom services surface in the custom zone (public one excluded)")
+}
+
+// recordNames returns the record names of a zone for order-independent assertions.
+func recordNames(zone nbdns.CustomZone) []string {
+ names := make([]string, 0, len(zone.Records))
+ for _, r := range zone.Records {
+ names = append(names, r.Name)
+ }
+ return names
+}
diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go
index a1a616882..a691bd53d 100644
--- a/management/server/types/account_test.go
+++ b/management/server/types/account_test.go
@@ -3,6 +3,7 @@ package types
import (
"context"
"fmt"
+ "net"
"net/netip"
"testing"
@@ -11,6 +12,7 @@ import (
"github.com/stretchr/testify/require"
nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/internals/modules/zones"
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
@@ -82,9 +84,9 @@ func setupTestAccount() *Account {
},
Groups: map[string]*Group{
"groupAll": {
- ID: "groupAll",
- Name: "All",
- Peers: []string{"peer1", "peer2", "peer3", "peer11", "peer12", "peer21", "peer31", "peer32", "peer41", "peer51", "peer61"},
+ ID: "groupAll",
+ Name: "All",
+ Peers: []string{"peer1", "peer2", "peer3", "peer11", "peer12", "peer21", "peer31", "peer32", "peer41", "peer51", "peer61"},
Issued: GroupIssuedAPI,
},
"group1": {
@@ -644,41 +646,7 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) {
expectedPorts: []string{"20-25", "10-100", "22022"},
},
{
- name: "dev suffix version supports all features",
- peer: &nbpeer.Peer{
- ID: "peer1",
- SSHEnabled: true,
- Meta: nbpeer.PeerSystemMeta{
- WtVersion: "0.50.0-dev",
- Flags: nbpeer.Flags{ServerSSHAllowed: true},
- },
- },
- rule: &PolicyRule{
- Protocol: PolicyRuleProtocolTCP,
- Ports: []string{"22"},
- },
- base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"},
- expectedPorts: []string{"22", "22022"},
- },
- {
- name: "dev suffix version supports all features",
- peer: &nbpeer.Peer{
- ID: "peer1",
- SSHEnabled: true,
- Meta: nbpeer.PeerSystemMeta{
- WtVersion: "dev",
- Flags: nbpeer.Flags{ServerSSHAllowed: true},
- },
- },
- rule: &PolicyRule{
- Protocol: PolicyRuleProtocolTCP,
- Ports: []string{"22"},
- },
- base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"},
- expectedPorts: []string{"22", "22022"},
- },
- {
- name: "development suffix version supports all features",
+ name: "development version supports all features",
peer: &nbpeer.Peer{
ID: "peer1",
SSHEnabled: true,
@@ -1583,3 +1551,203 @@ func Test_filterPeerAppliedZones(t *testing.T) {
})
}
}
+
+func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
+ ctx := context.Background()
+
+ userPeerIP := netip.MustParseAddr("100.64.0.10")
+ proxyPeerIP := netip.MustParseAddr("100.64.0.99")
+
+ account := &Account{
+ Id: "acct-1",
+ Network: &Network{
+ Identifier: "net-1",
+ Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
+ },
+ Peers: map[string]*nbpeer.Peer{
+ "user-peer": {
+ ID: "user-peer",
+ AccountID: "acct-1",
+ Key: "user-peer-key",
+ IP: userPeerIP,
+ },
+ "proxy-peer": {
+ ID: "proxy-peer",
+ AccountID: "acct-1",
+ Key: "proxy-peer-key",
+ IP: proxyPeerIP,
+ ProxyMeta: nbpeer.ProxyMeta{
+ Embedded: true,
+ Cluster: "eu.proxy.netbird.io",
+ },
+ },
+ },
+ Groups: map[string]*Group{
+ "grp-admins": {
+ ID: "grp-admins",
+ Name: "admins",
+ Peers: []string{"user-peer"},
+ },
+ },
+ Services: []*service.Service{
+ {
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Name: "myapp",
+ Domain: "myapp.eu.proxy.netbird.io",
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ Targets: []*service.Target{
+ {
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: service.TargetTypeCluster,
+ Protocol: "http",
+ Host: "127.0.0.1",
+ Port: 8080,
+ Enabled: true,
+ },
+ },
+ },
+ },
+ }
+
+ account.InjectProxyPolicies(ctx)
+
+ var found *Policy
+ for _, p := range account.Policies {
+ if p != nil && p.ID == "private-access-svc-1-proxy-peer" {
+ found = p
+ break
+ }
+ }
+ require.NotNil(t, found, "expected synthesised private-access policy in account.Policies")
+ require.Len(t, found.Rules, 1, "policy should have exactly one rule")
+ rule := found.Rules[0]
+ assert.Equal(t, []string{"grp-admins"}, rule.Sources, "sources should be group IDs verbatim")
+ assert.Equal(t, "proxy-peer", rule.DestinationResource.ID, "destination resource should be the proxy peer ID")
+ assert.Equal(t, ResourceTypePeer, rule.DestinationResource.Type, "destination resource type should be peer")
+
+ validatedPeersMap := map[string]struct{}{
+ "user-peer": {},
+ "proxy-peer": {},
+ }
+
+ proxyPeer := account.Peers["proxy-peer"]
+ aclPeers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(ctx, proxyPeer, validatedPeersMap, nil)
+
+ var sawUserAsAclPeer bool
+ for _, p := range aclPeers {
+ if p.ID == "user-peer" {
+ sawUserAsAclPeer = true
+ break
+ }
+ }
+ assert.True(t, sawUserAsAclPeer, "proxy peer should see the user peer as an ACL peer")
+
+ var inboundRules []*FirewallRule
+ for _, r := range firewallRules {
+ if r.Direction == FirewallRuleDirectionIN && r.PeerIP == userPeerIP.String() {
+ inboundRules = append(inboundRules, r)
+ }
+ }
+ assert.NotEmpty(t, inboundRules, "proxy peer should have inbound firewall rules from the user peer")
+}
+
+func TestInjectPrivateServicePolicies_NotPrivate_NoPolicy(t *testing.T) {
+ ctx := context.Background()
+ account := privateServiceTestAccount(t)
+ account.Services[0].Private = false
+
+ account.InjectProxyPolicies(ctx)
+ assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "non-private service must not synthesise an access policy")
+}
+
+func TestInjectPrivateServicePolicies_EmptyAccessGroups_NoPolicy(t *testing.T) {
+ ctx := context.Background()
+ account := privateServiceTestAccount(t)
+ account.Services[0].AccessGroups = nil
+
+ account.InjectProxyPolicies(ctx)
+ assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "private service with no access groups must not synthesise a policy")
+}
+
+func TestInjectPrivateServicePolicies_NoProxyPeers_NoPolicy(t *testing.T) {
+ ctx := context.Background()
+ account := privateServiceTestAccount(t)
+ delete(account.Peers, "proxy-peer")
+
+ account.InjectProxyPolicies(ctx)
+ assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "policy must not synthesise when the cluster has no proxy peers")
+}
+
+func privateServiceTestAccount(t *testing.T) *Account {
+ t.Helper()
+ return &Account{
+ Id: "acct-1",
+ Network: &Network{
+ Identifier: "net-1",
+ Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
+ },
+ Peers: map[string]*nbpeer.Peer{
+ "user-peer": {
+ ID: "user-peer",
+ AccountID: "acct-1",
+ Key: "user-peer-key",
+ IP: netip.MustParseAddr("100.64.0.10"),
+ },
+ "proxy-peer": {
+ ID: "proxy-peer",
+ AccountID: "acct-1",
+ Key: "proxy-peer-key",
+ IP: netip.MustParseAddr("100.64.0.99"),
+ ProxyMeta: nbpeer.ProxyMeta{
+ Embedded: true,
+ Cluster: "eu.proxy.netbird.io",
+ },
+ },
+ },
+ Groups: map[string]*Group{
+ "grp-admins": {
+ ID: "grp-admins",
+ Name: "admins",
+ Peers: []string{"user-peer"},
+ },
+ },
+ Services: []*service.Service{
+ {
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Name: "myapp",
+ Domain: "myapp.eu.proxy.netbird.io",
+ ProxyCluster: "eu.proxy.netbird.io",
+ Enabled: true,
+ Private: true,
+ Mode: service.ModeHTTP,
+ AccessGroups: []string{"grp-admins"},
+ Targets: []*service.Target{
+ {
+ TargetId: "eu.proxy.netbird.io",
+ TargetType: service.TargetTypeCluster,
+ Protocol: "http",
+ Host: "127.0.0.1",
+ Port: 8080,
+ Enabled: true,
+ },
+ },
+ },
+ },
+ }
+}
+
+func hasPrivateAccessPolicy(account *Account, serviceID string) bool {
+ prefix := "private-access-" + serviceID + "-"
+ for _, p := range account.Policies {
+ if p != nil && len(p.ID) > len(prefix) && p.ID[:len(prefix)] == prefix {
+ return true
+ }
+ }
+ return false
+}
diff --git a/management/server/types/networkmap_components.go b/management/server/types/networkmap_components.go
index b8572fc7b..1182eaf40 100644
--- a/management/server/types/networkmap_components.go
+++ b/management/server/types/networkmap_components.go
@@ -628,9 +628,14 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool
rules := []*RouteFirewallRule{&rule}
- if includeIPv6 && r.IsDynamic() {
+ isDefaultV4 := r.Network.Addr().Is4() && r.Network.Bits() == 0
+ if includeIPv6 && (r.IsDynamic() || isDefaultV4) {
ruleV6 := rule
ruleV6.SourceRanges = []string{"::/0"}
+ if isDefaultV4 {
+ ruleV6.Destination = "::/0"
+ ruleV6.RouteID = r.ID + "-v6-default"
+ }
rules = append(rules, &ruleV6)
}
diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go
index bcfb6fdf9..3785a7399 100644
--- a/management/server/types/networkmap_components_correctness_test.go
+++ b/management/server/types/networkmap_components_correctness_test.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net"
"net/netip"
+ "slices"
"testing"
"time"
@@ -1029,6 +1030,48 @@ func TestComponents_RouteDefaultPermit(t *testing.T) {
assert.True(t, hasDefaultPermit, "route without ACG should have default permit rule with 0.0.0.0/0 source")
}
+// TestComponents_ExitNodeDefaultPermitIPv6 verifies that a default exit node route
+// (0.0.0.0/0) without AccessControlGroups also emits an IPv6 default permit rule
+// (::/0 source and destination) for peers that support IPv6, mirroring the route
+// the client installs. Without it, IPv6 traffic is routed to the exit node but
+// dropped at the forward chain.
+func TestComponents_ExitNodeDefaultPermitIPv6(t *testing.T) {
+ account, validatedPeers := scalableTestAccount(20, 2)
+
+ routingPeerID := "peer-5"
+ routingPeer := account.Peers[routingPeerID]
+ routingPeer.IPv6 = netip.MustParseAddr("fd00::5")
+ routingPeer.Meta.Capabilities = append(routingPeer.Meta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay)
+
+ account.Routes["route-exit"] = &route.Route{
+ ID: "route-exit", Network: netip.MustParsePrefix("0.0.0.0/0"),
+ PeerID: routingPeerID, Peer: routingPeer.Key,
+ Enabled: true, Groups: []string{"group-all"}, PeerGroups: []string{"group-0"},
+ AccessControlGroups: []string{},
+ AccountID: "test-account",
+ }
+
+ nm := componentsNetworkMap(account, routingPeerID, validatedPeers)
+ require.NotNil(t, nm)
+
+ hasV4 := false
+ hasV6 := false
+ for _, rfr := range nm.RoutesFirewallRules {
+ switch rfr.Destination {
+ case "0.0.0.0/0":
+ if slices.Contains(rfr.SourceRanges, "0.0.0.0/0") {
+ hasV4 = true
+ }
+ case "::/0":
+ if slices.Contains(rfr.SourceRanges, "::/0") {
+ hasV6 = true
+ }
+ }
+ }
+ assert.True(t, hasV4, "exit node route should have an IPv4 default permit rule (0.0.0.0/0)")
+ assert.True(t, hasV6, "exit node route should have an IPv6 default permit rule (::/0)")
+}
+
// ──────────────────────────────────────────────────────────────────────────────
// 15. MULTIPLE ROUTERS PER NETWORK
// ──────────────────────────────────────────────────────────────────────────────
diff --git a/management/server/user.go b/management/server/user.go
index 892d982e7..7cd955000 100644
--- a/management/server/user.go
+++ b/management/server/user.go
@@ -31,7 +31,7 @@ import (
// createServiceUser creates a new service user under the given account.
func (am *DefaultAccountManager) createServiceUser(ctx context.Context, accountID string, initiatorUserID string, role types.UserRole, serviceUserName string, nonDeletable bool, autoGroups []string) (*types.UserInfo, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -86,7 +86,7 @@ func (am *DefaultAccountManager) inviteNewUser(ctx context.Context, accountID, u
return nil, err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Users, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Users, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -307,7 +307,7 @@ func (am *DefaultAccountManager) DeleteUser(ctx context.Context, accountID, init
return err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -357,7 +357,7 @@ func (am *DefaultAccountManager) InviteUser(ctx context.Context, accountID strin
return status.Errorf(status.PreconditionFailed, "IdP manager must be enabled to send user invites")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -401,7 +401,7 @@ func (am *DefaultAccountManager) CreatePAT(ctx context.Context, accountID string
return nil, status.Errorf(status.InvalidArgument, "expiration has to be between %d and %d", account.PATMinExpireDays, account.PATMaxExpireDays)
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -445,7 +445,7 @@ func (am *DefaultAccountManager) CreatePAT(ctx context.Context, accountID string
// DeletePAT deletes a specific PAT from a user
func (am *DefaultAccountManager) DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -488,7 +488,7 @@ func (am *DefaultAccountManager) DeletePAT(ctx context.Context, accountID string
// GetPAT returns a specific PAT from a user
func (am *DefaultAccountManager) GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -519,7 +519,7 @@ func (am *DefaultAccountManager) GetPAT(ctx context.Context, accountID string, i
// GetAllPATs returns all PATs for a user
func (am *DefaultAccountManager) GetAllPATs(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) ([]*types.PersonalAccessToken, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -576,7 +576,7 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
return nil, nil //nolint:nilnil
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create) // TODO: split by Create and Update
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create) // TODO: split by Create and Update
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -610,6 +610,11 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID,
return nil, err
}
initiatorUser = result
+ role, ok := nbcontext.RoleFromContext(ctx)
+ if !ok {
+ return nil, status.Errorf(status.Internal, "failed to get user role from context")
+ }
+ initiatorUser.Role = types.UserRole(role)
}
var globalErr error
@@ -755,19 +760,6 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact
return false, nil, nil, nil, status.Errorf(status.InvalidArgument, "provided user update is nil")
}
- if initiatorUserId != activity.SystemInitiator {
- freshInitiator, err := transaction.GetUserByUserID(ctx, store.LockingStrengthUpdate, initiatorUserId)
- if err != nil {
- return false, nil, nil, nil, fmt.Errorf("failed to re-read initiator user in transaction: %w", err)
- }
-
- // Ensure the initiator still has admin privileges
- if initiatorUser.HasAdminPower() && !freshInitiator.HasAdminPower() {
- return false, nil, nil, nil, status.Errorf(status.PermissionDenied, "initiator role was changed during request processing")
- }
- initiatorUser = freshInitiator
- }
-
oldUser, isNewUser, err := getUserOrCreateIfNotExists(ctx, transaction, accountID, update, addIfNotExists)
if err != nil {
return false, nil, nil, nil, err
@@ -906,19 +898,23 @@ func validateUserUpdate(groupsMap map[string]*types.Group, initiatorUser, oldUse
return nil
}
+ if !initiatorUser.HasAdminPower() {
+ return status.Errorf(status.PermissionDenied, "only admins and owners can update users")
+ }
+
if initiatorUser.HasAdminPower() && initiatorUser.Id == update.Id && oldUser.Blocked != update.Blocked {
return status.Errorf(status.PermissionDenied, "admins can't block or unblock themselves")
}
if initiatorUser.HasAdminPower() && initiatorUser.Id == update.Id && update.Role != initiatorUser.Role {
return status.Errorf(status.PermissionDenied, "admins can't change their role")
}
- if initiatorUser.Role == types.UserRoleAdmin && oldUser.Role == types.UserRoleOwner && update.Role != oldUser.Role {
+ if initiatorUser.Role != types.UserRoleOwner && oldUser.Role == types.UserRoleOwner && update.Role != oldUser.Role {
return status.Errorf(status.PermissionDenied, "only owners can remove owner role from their user")
}
- if initiatorUser.Role == types.UserRoleAdmin && oldUser.Role == types.UserRoleOwner && update.IsBlocked() && !oldUser.IsBlocked() {
+ if oldUser.Role == types.UserRoleOwner && update.IsBlocked() && !oldUser.IsBlocked() {
return status.Errorf(status.PermissionDenied, "unable to block owner user")
}
- if initiatorUser.Role == types.UserRoleAdmin && update.Role == types.UserRoleOwner && update.Role != oldUser.Role {
+ if initiatorUser.Role != types.UserRoleOwner && update.Role == types.UserRoleOwner && update.Role != oldUser.Role {
return status.Errorf(status.PermissionDenied, "only owners can add owner role to other users")
}
if oldUser.IsServiceUser && update.Role == types.UserRoleOwner {
@@ -984,7 +980,7 @@ func (am *DefaultAccountManager) GetOrCreateAccountByUser(ctx context.Context, u
// GetUsersFromAccount performs a batched request for users from IDP by account ID apply filter on what data to return
// based on provided user role.
func (am *DefaultAccountManager) GetUsersFromAccount(ctx context.Context, accountID, initiatorUserID string) (map[string]*types.UserInfo, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1201,7 +1197,7 @@ func (am *DefaultAccountManager) deleteUserFromIDP(ctx context.Context, targetUs
// If an error occurs while deleting the user, the function skips it and continues deleting other users.
// Errors are collected and returned at the end.
func (am *DefaultAccountManager) DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -1399,7 +1395,8 @@ func (am *DefaultAccountManager) GetCurrentUserInfo(ctx context.Context, userAut
return nil, status.NewPermissionDeniedError()
}
- if err := am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false); err != nil {
+ ctx, err = am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false)
+ if err != nil {
return nil, err
}
@@ -1428,7 +1425,7 @@ func (am *DefaultAccountManager) GetCurrentUserInfo(ctx context.Context, userAut
// ApproveUser approves a user that is pending approval
func (am *DefaultAccountManager) ApproveUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error) {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1469,7 +1466,7 @@ func (am *DefaultAccountManager) ApproveUser(ctx context.Context, accountID, ini
// RejectUser rejects a user that is pending approval by deleting them
func (am *DefaultAccountManager) RejectUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error {
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
@@ -1515,7 +1512,7 @@ func (am *DefaultAccountManager) CreateUserInvite(ctx context.Context, accountID
return nil, err
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1633,7 +1630,7 @@ func (am *DefaultAccountManager) ListUserInvites(ctx context.Context, accountID,
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1747,7 +1744,7 @@ func (am *DefaultAccountManager) RegenerateUserInvite(ctx context.Context, accou
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
if err != nil {
return nil, status.NewPermissionValidationError(err)
}
@@ -1809,7 +1806,7 @@ func (am *DefaultAccountManager) DeleteUserInvite(ctx context.Context, accountID
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
}
- allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
+ allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
if err != nil {
return status.NewPermissionValidationError(err)
}
diff --git a/management/server/user_test.go b/management/server/user_test.go
index c77ea53d1..2a2d7857d 100644
--- a/management/server/user_test.go
+++ b/management/server/user_test.go
@@ -2129,66 +2129,3 @@ func TestUser_Operations_WithEmbeddedIDP(t *testing.T) {
t.Logf("Duplicate email error: %v", err)
})
}
-
-func TestProcessUserUpdate_RejectsStaleInitiatorRole(t *testing.T) {
- s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
- require.NoError(t, err)
- t.Cleanup(cleanup)
-
- account := newAccountWithId(context.Background(), "account1", "owner1", "", "", "", false)
-
- adminID := "admin1"
- account.Users[adminID] = types.NewAdminUser(adminID)
-
- targetID := "target1"
- account.Users[targetID] = types.NewRegularUser(targetID, "", "")
-
- require.NoError(t, s.SaveAccount(context.Background(), account))
-
- demotedAdmin, err := s.GetUserByUserID(context.Background(), store.LockingStrengthNone, adminID)
- require.NoError(t, err)
- demotedAdmin.Role = types.UserRoleUser
- require.NoError(t, s.SaveUser(context.Background(), demotedAdmin))
-
- staleInitiator := &types.User{
- Id: adminID,
- AccountID: account.Id,
- Role: types.UserRoleAdmin,
- }
-
- permissionsManager := permissions.NewManager(s)
- am := DefaultAccountManager{
- Store: s,
- eventStore: &activity.InMemoryEventStore{},
- permissionsManager: permissionsManager,
- }
-
- settings, err := s.GetAccountSettings(context.Background(), store.LockingStrengthNone, account.Id)
- require.NoError(t, err)
-
- groups, err := s.GetAccountGroups(context.Background(), store.LockingStrengthNone, account.Id)
- require.NoError(t, err)
- groupsMap := make(map[string]*types.Group, len(groups))
- for _, g := range groups {
- groupsMap[g.ID] = g
- }
-
- update := &types.User{
- Id: targetID,
- Role: types.UserRoleAdmin,
- }
-
- err = s.ExecuteInTransaction(context.Background(), func(tx store.Store) error {
- _, _, _, _, txErr := am.processUserUpdate(
- context.Background(), tx, groupsMap, account.Id, adminID, staleInitiator, update, false, settings,
- )
- return txErr
- })
-
- require.Error(t, err, "processUserUpdate should reject stale initiator whose role was demoted")
- assert.Contains(t, err.Error(), "initiator role was changed during request processing")
-
- targetUser, err := s.GetUserByUserID(context.Background(), store.LockingStrengthNone, targetID)
- require.NoError(t, err)
- assert.Equal(t, types.UserRoleUser, targetUser.Role)
-}
diff --git a/management/server/users/manager.go b/management/server/users/manager.go
index e07f28706..1a05b1a7c 100644
--- a/management/server/users/manager.go
+++ b/management/server/users/manager.go
@@ -10,6 +10,7 @@ import (
type Manager interface {
GetUser(ctx context.Context, userID string) (*types.User, error)
+ GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error)
}
type managerImpl struct {
@@ -29,6 +30,31 @@ func (m *managerImpl) GetUser(ctx context.Context, userID string) (*types.User,
return m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
}
+// GetUserWithGroups returns the user and the *types.Group records for the user's AutoGroups, in the same order as
+// AutoGroups. Group ids that don't resolve to a stored group are skipped from the returned slice (the parallel id list is
+// derivable from the returned User). Wraps two store calls today; can be optimised to a single JOIN later if needed.
+// Any store error returns (nil, nil, err) so callers never receive a valid user alongside a non-nil error.
+func (m *managerImpl) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) {
+ user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
+ if err != nil {
+ return nil, nil, err
+ }
+ if len(user.AutoGroups) == 0 {
+ return user, nil, nil
+ }
+ groupsMap, err := m.store.GetGroupsByIDs(ctx, store.LockingStrengthNone, user.AccountID, user.AutoGroups)
+ if err != nil {
+ return nil, nil, err
+ }
+ groups := make([]*types.Group, 0, len(user.AutoGroups))
+ for _, id := range user.AutoGroups {
+ if g, ok := groupsMap[id]; ok && g != nil {
+ groups = append(groups, g)
+ }
+ }
+ return user, groups, nil
+}
+
func NewManagerMock() Manager {
return &managerMock{}
}
@@ -47,3 +73,11 @@ func (m *managerMock) GetUser(ctx context.Context, userID string) (*types.User,
return nil, errors.New("user not found")
}
}
+
+func (m *managerMock) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) {
+ user, err := m.GetUser(ctx, userID)
+ if err != nil {
+ return nil, nil, err
+ }
+ return user, nil, nil
+}
diff --git a/proxy/auth/auth.go b/proxy/auth/auth.go
index ca9c260b7..78f0097d5 100644
--- a/proxy/auth/auth.go
+++ b/proxy/auth/auth.go
@@ -45,10 +45,14 @@ func ResolveProto(forwardedProto string, conn *tls.ConnectionState) string {
}
}
-// ValidateSessionJWT validates a session JWT and returns the user ID and method.
-func ValidateSessionJWT(tokenString, domain string, publicKey ed25519.PublicKey) (userID, method string, err error) {
+// ValidateSessionJWT validates a session JWT and returns the user ID, the
+// user's email (when carried), the authentication method, any embedded
+// group memberships, and the parallel group display names. email,
+// groups, and groupNames may be empty for tokens minted before those
+// claims were introduced. groupNames pairs positionally with groups.
+func ValidateSessionJWT(tokenString, domain string, publicKey ed25519.PublicKey) (userID, email, method string, groups, groupNames []string, err error) {
if publicKey == nil {
- return "", "", fmt.Errorf("no public key configured for domain")
+ return "", "", "", nil, nil, fmt.Errorf("no public key configured for domain")
}
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
@@ -58,20 +62,46 @@ func ValidateSessionJWT(tokenString, domain string, publicKey ed25519.PublicKey)
return publicKey, nil
}, jwt.WithAudience(domain), jwt.WithIssuer(SessionJWTIssuer))
if err != nil {
- return "", "", fmt.Errorf("parse token: %w", err)
+ return "", "", "", nil, nil, fmt.Errorf("parse token: %w", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
- return "", "", fmt.Errorf("invalid token claims")
+ return "", "", "", nil, nil, fmt.Errorf("invalid token claims")
}
sub, _ := claims.GetSubject()
if sub == "" {
- return "", "", fmt.Errorf("missing subject claim")
+ return "", "", "", nil, nil, fmt.Errorf("missing subject claim")
}
methodClaim, _ := claims["method"].(string)
+ emailClaim, _ := claims["email"].(string)
+ groups = extractGroupsClaim(claims["groups"])
+ groupNames = extractGroupsClaim(claims["group_names"])
- return sub, methodClaim, nil
+ return sub, emailClaim, methodClaim, groups, groupNames, nil
+}
+
+// extractGroupsClaim decodes the "groups" claim into a string slice. The JWT
+// library decodes JSON arrays as []interface{}, so we coerce element-wise
+// and skip non-string entries silently.
+func extractGroupsClaim(claim interface{}) []string {
+ raw, ok := claim.([]interface{})
+ if !ok {
+ return nil
+ }
+ if len(raw) == 0 {
+ return nil
+ }
+ groups := make([]string, 0, len(raw))
+ for _, v := range raw {
+ if s, ok := v.(string); ok && s != "" {
+ groups = append(groups, s)
+ }
+ }
+ if len(groups) == 0 {
+ return nil
+ }
+ return groups
}
diff --git a/proxy/cmd/proxy/cmd/debug.go b/proxy/cmd/proxy/cmd/debug.go
index 49afc7638..360c7a516 100644
--- a/proxy/cmd/proxy/cmd/debug.go
+++ b/proxy/cmd/proxy/cmd/debug.go
@@ -109,6 +109,22 @@ var debugStopCmd = &cobra.Command{
SilenceUsage: true,
}
+var debugPerfCmd = &cobra.Command{
+ Use: "perf ",
+ Short: "Live-retune the tunnel buffer pool cap on all running clients",
+ Args: cobra.ExactArgs(1),
+ RunE: runDebugPerfSet,
+ SilenceUsage: true,
+}
+
+var debugRuntimeCmd = &cobra.Command{
+ Use: "runtime",
+ Short: "Show runtime stats (heap, goroutines, RSS)",
+ Args: cobra.NoArgs,
+ RunE: runDebugRuntime,
+ SilenceUsage: true,
+}
+
var debugCaptureCmd = &cobra.Command{
Use: "capture [filter expression]",
Short: "Capture packets on a client's WireGuard interface",
@@ -159,6 +175,8 @@ func init() {
debugCmd.AddCommand(debugLogCmd)
debugCmd.AddCommand(debugStartCmd)
debugCmd.AddCommand(debugStopCmd)
+ debugCmd.AddCommand(debugPerfCmd)
+ debugCmd.AddCommand(debugRuntimeCmd)
debugCmd.AddCommand(debugCaptureCmd)
rootCmd.AddCommand(debugCmd)
@@ -220,6 +238,18 @@ func runDebugStop(cmd *cobra.Command, args []string) error {
return getDebugClient(cmd).StopClient(cmd.Context(), args[0])
}
+func runDebugPerfSet(cmd *cobra.Command, args []string) error {
+ n, err := strconv.ParseUint(args[0], 10, 32)
+ if err != nil {
+ return fmt.Errorf("invalid value %q: %w", args[0], err)
+ }
+ return getDebugClient(cmd).PerfSet(cmd.Context(), uint32(n))
+}
+
+func runDebugRuntime(cmd *cobra.Command, _ []string) error {
+ return getDebugClient(cmd).Runtime(cmd.Context())
+}
+
func runDebugCapture(cmd *cobra.Command, args []string) error {
duration, _ := cmd.Flags().GetDuration("duration")
forcePcap, _ := cmd.Flags().GetBool("pcap")
diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go
index ec8980ad9..ad8e1b7c0 100644
--- a/proxy/cmd/proxy/cmd/root.go
+++ b/proxy/cmd/proxy/cmd/root.go
@@ -15,11 +15,22 @@ import (
"github.com/netbirdio/netbird/shared/management/domain"
+ "github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/proxy"
nbacme "github.com/netbirdio/netbird/proxy/internal/acme"
"github.com/netbirdio/netbird/util"
)
+const (
+ // envPreallocatedBuffers caps the per-tunnel buffer pool. Zero (unset)
+ // keeps the upstream uncapped default.
+ envPreallocatedBuffers = "NB_PROXY_PREALLOCATED_BUFFERS"
+ // envMaxBatchSize overrides the per-tunnel batch size, which controls
+ // how many buffers each receive/TUN worker eagerly allocates. Zero
+ // (unset) keeps the platform default.
+ envMaxBatchSize = "NB_PROXY_MAX_BATCH_SIZE"
+)
+
const DefaultManagementURL = "https://api.netbird.io:443"
// envProxyToken is the environment variable name for the proxy access token.
@@ -63,6 +74,7 @@ var (
preSharedKey string
supportsCustomPorts bool
requireSubdomain bool
+ private bool
geoDataDir string
crowdsecAPIURL string
crowdsecAPIKey string
@@ -105,6 +117,8 @@ func init() {
rootCmd.Flags().StringVar(&preSharedKey, "preshared-key", envStringOrDefault("NB_PROXY_PRESHARED_KEY", ""), "Define a pre-shared key for the tunnel between proxy and peers")
rootCmd.Flags().BoolVar(&supportsCustomPorts, "supports-custom-ports", envBoolOrDefault("NB_PROXY_SUPPORTS_CUSTOM_PORTS", true), "Whether the proxy can bind arbitrary ports for UDP/TCP passthrough")
rootCmd.Flags().BoolVar(&requireSubdomain, "require-subdomain", envBoolOrDefault("NB_PROXY_REQUIRE_SUBDOMAIN", false), "Require a subdomain label in front of the cluster domain")
+ rootCmd.Flags().BoolVar(&private, "private", envBoolOrDefault("NB_PROXY_PRIVATE", false), "Enable private services accessible with NetBird-Only authentication mode.")
+ _ = rootCmd.Flags().MarkHidden("private")
rootCmd.Flags().DurationVar(&maxDialTimeout, "max-dial-timeout", envDurationOrDefault("NB_PROXY_MAX_DIAL_TIMEOUT", 0), "Cap per-service backend dial timeout (0 = no cap)")
rootCmd.Flags().DurationVar(&maxSessionIdleTimeout, "max-session-idle-timeout", envDurationOrDefault("NB_PROXY_MAX_SESSION_IDLE_TIMEOUT", 0), "Cap per-service session idle timeout (0 = no cap)")
rootCmd.Flags().StringVar(&geoDataDir, "geo-data-dir", envStringOrDefault("NB_PROXY_GEO_DATA_DIR", "/var/lib/netbird/geolocation"), "Directory for the GeoLite2 MMDB file (auto-downloaded if missing)")
@@ -145,6 +159,45 @@ func runServer(cmd *cobra.Command, args []string) error {
logger.Infof("configured log level: %s", level)
+ var wgPool, wgBatch uint64
+ var perf embed.Performance
+ if raw := os.Getenv(envPreallocatedBuffers); raw != "" {
+ n, err := strconv.ParseUint(raw, 10, 32)
+ if err != nil {
+ return fmt.Errorf("invalid %s %q: %w", envPreallocatedBuffers, raw, err)
+ }
+ wgPool = n
+ v := uint32(n)
+ perf.PreallocatedBuffersPerPool = &v
+ logger.Infof("tunnel preallocated buffers per pool: %d", n)
+ }
+ if raw := os.Getenv(envMaxBatchSize); raw != "" {
+ n, err := strconv.ParseUint(raw, 10, 32)
+ if err != nil {
+ return fmt.Errorf("invalid %s %q: %w", envMaxBatchSize, raw, err)
+ }
+ wgBatch = n
+ v := uint32(n)
+ perf.MaxBatchSize = &v
+ logger.Infof("tunnel max batch size override: %d", n)
+ }
+ if wgPool > 0 {
+ // Each bind recv goroutine (IPv4 + IPv6 + ICE relay) plus
+ // RoutineReadFromTUN eagerly reserves `batch` message buffers for
+ // the lifetime of the Device. A pool cap below that floor blocks
+ // the receive pipeline at startup.
+ batch := wgBatch
+ if batch == 0 {
+ batch = 128
+ }
+ const recvGoroutines = 4
+ floor := batch * recvGoroutines
+ if wgPool < floor {
+ logger.Warnf("%s=%d is below the eager-allocation floor (~%d for batch=%d); startup may deadlock",
+ envPreallocatedBuffers, wgPool, floor, batch)
+ }
+ }
+
switch forwardedProto {
case "auto", "http", "https":
default:
@@ -161,7 +214,11 @@ func runServer(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid --trusted-proxies: %w", err)
}
- srv := proxy.Server{
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
+ defer stop()
+
+ srv := proxy.New(ctx, proxy.Config{
+ ListenAddr: addr,
Logger: logger,
Version: Version,
ManagementAddress: mgmtAddr,
@@ -178,25 +235,25 @@ func runServer(cmd *cobra.Command, args []string) error {
ACMEChallengeType: acmeChallengeType,
DebugEndpointEnabled: debugEndpoint,
DebugEndpointAddress: debugEndpointAddr,
- HealthAddress: healthAddr,
+ HealthAddr: healthAddr,
ForwardedProto: forwardedProto,
TrustedProxies: parsedTrustedProxies,
CertLockMethod: nbacme.CertLockMethod(certLockMethod),
WildcardCertDir: wildcardCertDir,
WireguardPort: wgPort,
+ Performance: perf,
ProxyProtocol: proxyProtocol,
PreSharedKey: preSharedKey,
SupportsCustomPorts: supportsCustomPorts,
RequireSubdomain: requireSubdomain,
+ Private: private,
MaxDialTimeout: maxDialTimeout,
MaxSessionIdleTimeout: maxSessionIdleTimeout,
+ MappingBatchWatchdog: envDurationOrDefault("NB_PROXY_MAPPING_BATCH_WATCHDOG", 0),
GeoDataDir: geoDataDir,
CrowdSecAPIURL: crowdsecAPIURL,
CrowdSecAPIKey: crowdsecAPIKey,
- }
-
- ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
- defer stop()
+ })
return srv.ListenAndServe(ctx, addr)
}
diff --git a/proxy/handle_mapping_stream_test.go b/proxy/handle_mapping_stream_test.go
index cb16c0814..67c399e44 100644
--- a/proxy/handle_mapping_stream_test.go
+++ b/proxy/handle_mapping_stream_test.go
@@ -4,6 +4,7 @@ import (
"context"
"io"
"testing"
+ "time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
@@ -59,7 +60,7 @@ func TestHandleMappingStream_SyncCompleteFlag(t *testing.T) {
}
syncDone := false
- err := s.handleMappingStream(context.Background(), stream, &syncDone)
+ err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
assert.NoError(t, err)
assert.True(t, syncDone, "initial sync should be marked done when flag is set")
}
@@ -79,7 +80,7 @@ func TestHandleMappingStream_NoSyncFlagDoesNotMarkDone(t *testing.T) {
}
syncDone := false
- err := s.handleMappingStream(context.Background(), stream, &syncDone)
+ err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
assert.NoError(t, err)
assert.False(t, syncDone, "initial sync should not be marked done without flag")
}
@@ -97,7 +98,7 @@ func TestHandleMappingStream_NilHealthChecker(t *testing.T) {
}
syncDone := false
- err := s.handleMappingStream(context.Background(), stream, &syncDone)
+ err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
assert.NoError(t, err)
assert.True(t, syncDone, "sync done flag should be set even without health checker")
}
diff --git a/proxy/inbound.go b/proxy/inbound.go
new file mode 100644
index 000000000..d729ba9ae
--- /dev/null
+++ b/proxy/inbound.go
@@ -0,0 +1,567 @@
+package proxy
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "io"
+ stdlog "log"
+ "net"
+ "net/http"
+ "net/netip"
+ "strconv"
+ "sync"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/embed"
+ "github.com/netbirdio/netbird/proxy/internal/auth"
+ "github.com/netbirdio/netbird/proxy/internal/debug"
+ nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp"
+ "github.com/netbirdio/netbird/proxy/internal/types"
+)
+
+// httpInboundReadHeaderTimeout matches the host-listener read header timeout
+// so per-account http.Servers don't leak idle connections.
+const httpInboundReadHeaderTimeout = 30 * time.Second
+
+// httpInboundIdleTimeout caps idle keep-alive on per-account inbound HTTP
+// servers; matches the host listener.
+const httpInboundIdleTimeout = 90 * time.Second
+
+// inboundShutdownTimeout caps how long a per-account http.Server gets to
+// drain in-flight requests during teardown.
+const inboundShutdownTimeout = 5 * time.Second
+
+// privateInboundPortHTTPS is the WG-side TLS port. Each account's
+// embedded netstack binds independently, so a fixed port is fine.
+const privateInboundPortHTTPS = 443
+
+// privateInboundPortHTTP is the WG-side plain-HTTP port.
+const privateInboundPortHTTP = 80
+
+// inboundManager wires per-account inbound listeners into the proxy
+// pipeline when --private is enabled. When disabled the manager
+// is nil and every method on *Server that touches it short-circuits.
+type inboundManager struct {
+ logger *log.Logger
+ handler http.Handler
+ tlsConfig *tls.Config
+ // muxLock guards entries and pendingRoutes.
+ muxLock sync.Mutex
+ entries map[types.AccountID]*inboundEntry
+ pendingRoutes map[types.AccountID][]pendingInboundRoute
+}
+
+// inboundEntry owns the listeners, router and HTTP servers for a single
+// account's embedded netstack. errorLogWriters retain the logrus pipe
+// writers backing each http.Server's ErrorLog so tearDown can close
+// them — otherwise the pipe + its scanner goroutine leak per account.
+type inboundEntry struct {
+ router *nbtcp.Router
+ tlsListener net.Listener
+ plainListener net.Listener
+ httpsServer *http.Server
+ httpServer *http.Server
+ errorLogWriters []*io.PipeWriter
+ cancel context.CancelFunc
+ wg sync.WaitGroup
+}
+
+// pendingInboundRoute holds a route that arrived before the account's
+// listener finished starting.
+type pendingInboundRoute struct {
+ host nbtcp.SNIHost
+ route nbtcp.Route
+}
+
+// newInboundManager constructs a manager bound to the proxy's HTTP
+// handler chain and TLS config.
+func newInboundManager(logger *log.Logger, handler http.Handler, tlsConfig *tls.Config) *inboundManager {
+ return &inboundManager{
+ logger: logger,
+ handler: handler,
+ tlsConfig: tlsConfig,
+ entries: make(map[types.AccountID]*inboundEntry),
+ pendingRoutes: make(map[types.AccountID][]pendingInboundRoute),
+ }
+}
+
+// onClientReady is registered with NetBird.SetClientLifecycle so the
+// listener pair comes up exactly when the embedded client reports ready.
+// The returned value is opaque to the roundtrip package; it is handed
+// back verbatim to onClientStop on teardown.
+func (m *inboundManager) onClientReady(ctx context.Context, accountID types.AccountID, client *embed.Client) any {
+ if m == nil {
+ return nil
+ }
+ entry, err := m.bringUp(ctx, accountID, client)
+ if err != nil {
+ m.logger.WithField("account_id", accountID).WithError(err).Warn("failed to start per-account inbound listener; continuing without inbound")
+ return nil
+ }
+
+ m.flushPending(accountID, entry)
+
+ m.logger.WithFields(log.Fields{
+ "account_id": accountID,
+ "https": entry.tlsListener.Addr().String(),
+ "http": entry.plainListener.Addr().String(),
+ }).Info("per-account inbound listeners up")
+ return entry
+}
+
+// onClientStop tears down a per-account listener bundle. State is the
+// opaque value previously returned by onClientReady.
+func (m *inboundManager) onClientStop(accountID types.AccountID, state any) {
+ if m == nil {
+ return
+ }
+ entry, ok := state.(*inboundEntry)
+ if !ok || entry == nil {
+ return
+ }
+ m.tearDown(accountID, entry)
+}
+
+// bringUp opens both listeners on the account's netstack, builds the
+// router, and starts the parallel HTTP servers.
+func (m *inboundManager) bringUp(ctx context.Context, accountID types.AccountID, client *embed.Client) (*inboundEntry, error) {
+ tlsListener, err := client.ListenTCP(fmt.Sprintf(":%d", privateInboundPortHTTPS))
+ if err != nil {
+ return nil, fmt.Errorf("listen tls on netstack: %w", err)
+ }
+ plainListener, err := client.ListenTCP(fmt.Sprintf(":%d", privateInboundPortHTTP))
+ if err != nil {
+ _ = tlsListener.Close()
+ return nil, fmt.Errorf("listen plain on netstack: %w", err)
+ }
+
+ router := nbtcp.NewRouter(m.logger, accountDialResolver(accountID, client), tlsListener.Addr(), nbtcp.WithPlainHTTP(plainListener.Addr()))
+
+ scopedHandler := withTunnelLookup(m.handler, accountTunnelLookup(client))
+
+ // markOverlayOrigin stamps every connection accepted by an inbound
+ // listener with a context value middlewares can read to skip
+ // geo/CrowdSec checks (the source address is always inside the
+ // NetBird CGNAT range and won't match either dataset).
+ markOverlayOrigin := func(ctx context.Context, _ net.Conn) context.Context {
+ return types.WithOverlayOrigin(ctx)
+ }
+
+ httpsErrLog, httpsErrW := newInboundErrorLog(m.logger, "https", accountID)
+ httpErrLog, httpErrW := newInboundErrorLog(m.logger, "http", accountID)
+
+ httpsServer := &http.Server{
+ Handler: scopedHandler,
+ TLSConfig: m.tlsConfig,
+ ReadHeaderTimeout: httpInboundReadHeaderTimeout,
+ IdleTimeout: httpInboundIdleTimeout,
+ ErrorLog: httpsErrLog,
+ ConnContext: markOverlayOrigin,
+ }
+ httpServer := &http.Server{
+ Handler: scopedHandler,
+ ReadHeaderTimeout: httpInboundReadHeaderTimeout,
+ IdleTimeout: httpInboundIdleTimeout,
+ ErrorLog: httpErrLog,
+ ConnContext: markOverlayOrigin,
+ }
+
+ runCtx, cancel := context.WithCancel(ctx)
+ entry := &inboundEntry{
+ router: router,
+ tlsListener: tlsListener,
+ plainListener: plainListener,
+ httpsServer: httpsServer,
+ httpServer: httpServer,
+ errorLogWriters: []*io.PipeWriter{httpsErrW, httpErrW},
+ cancel: cancel,
+ }
+
+ entry.wg.Add(1)
+ go func() {
+ defer entry.wg.Done()
+ if err := router.Serve(runCtx, tlsListener); err != nil {
+ m.logger.WithField("account_id", accountID).Debugf("per-account router stopped: %v", err)
+ }
+ }()
+
+ entry.wg.Add(1)
+ go func() {
+ defer entry.wg.Done()
+ if err := httpsServer.ServeTLS(router.HTTPListener(), "", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ m.logger.WithField("account_id", accountID).Debugf("per-account https server stopped: %v", err)
+ }
+ }()
+
+ entry.wg.Add(1)
+ go func() {
+ defer entry.wg.Done()
+ if err := httpServer.Serve(router.HTTPListenerPlain()); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ m.logger.WithField("account_id", accountID).Debugf("per-account http server stopped: %v", err)
+ }
+ }()
+
+ entry.wg.Add(1)
+ go func() {
+ defer entry.wg.Done()
+ feedRouterFromListener(runCtx, plainListener, router, m.logger, accountID)
+ }()
+
+ m.muxLock.Lock()
+ m.entries[accountID] = entry
+ m.muxLock.Unlock()
+
+ return entry, nil
+}
+
+// tearDown shuts every goroutine down and closes the netstack listeners.
+func (m *inboundManager) tearDown(accountID types.AccountID, entry *inboundEntry) {
+ m.muxLock.Lock()
+ if m.entries[accountID] == entry {
+ delete(m.entries, accountID)
+ delete(m.pendingRoutes, accountID)
+ }
+ m.muxLock.Unlock()
+
+ entry.cancel()
+
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), inboundShutdownTimeout)
+ defer cancel()
+
+ if err := entry.httpsServer.Shutdown(shutdownCtx); err != nil {
+ m.logger.Debugf("per-account https shutdown: %v", err)
+ }
+ if err := entry.httpServer.Shutdown(shutdownCtx); err != nil {
+ m.logger.Debugf("per-account http shutdown: %v", err)
+ }
+ if err := entry.tlsListener.Close(); err != nil {
+ m.logger.Debugf("close per-account tls listener: %v", err)
+ }
+ if err := entry.plainListener.Close(); err != nil {
+ m.logger.Debugf("close per-account plain listener: %v", err)
+ }
+ entry.wg.Wait()
+ // Close the ErrorLog pipes only after the http.Servers have fully
+ // stopped so any straggling stdlib write doesn't race with the
+ // close. Each writer also tears down the logrus scanner goroutine.
+ for _, w := range entry.errorLogWriters {
+ if err := w.Close(); err != nil {
+ m.logger.Debugf("close per-account inbound error log writer: %v", err)
+ }
+ }
+}
+
+// AddRoute records an SNI/host route on the account's per-account router.
+// Routes registered before the listener is up are queued and replayed
+// once startup completes.
+func (m *inboundManager) AddRoute(accountID types.AccountID, host nbtcp.SNIHost, route nbtcp.Route) {
+ if m == nil {
+ return
+ }
+ m.muxLock.Lock()
+ entry, ok := m.entries[accountID]
+ if !ok {
+ m.queuePendingLocked(accountID, host, route)
+ m.muxLock.Unlock()
+ return
+ }
+ router := entry.router
+ m.muxLock.Unlock()
+
+ router.AddRoute(host, route)
+}
+
+// RemoveRoute drops a previously registered route. Safe to call when the
+// listener is not yet up; queued copies are pruned in that case.
+func (m *inboundManager) RemoveRoute(accountID types.AccountID, host nbtcp.SNIHost, svcID types.ServiceID) {
+ if m == nil {
+ return
+ }
+ m.muxLock.Lock()
+ m.dropPendingLocked(accountID, host, svcID)
+ entry, ok := m.entries[accountID]
+ if !ok {
+ m.muxLock.Unlock()
+ return
+ }
+ router := entry.router
+ m.muxLock.Unlock()
+
+ router.RemoveRoute(host, svcID)
+}
+
+// queuePendingLocked stores or upserts a pending route. Caller holds muxLock.
+func (m *inboundManager) queuePendingLocked(accountID types.AccountID, host nbtcp.SNIHost, route nbtcp.Route) {
+ queued := m.pendingRoutes[accountID]
+ for i, pr := range queued {
+ if pr.host == host && pr.route.ServiceID == route.ServiceID {
+ queued[i] = pendingInboundRoute{host: host, route: route}
+ m.pendingRoutes[accountID] = queued
+ return
+ }
+ }
+ m.pendingRoutes[accountID] = append(queued, pendingInboundRoute{host: host, route: route})
+}
+
+// dropPendingLocked removes any queued route matching host/svcID.
+// Caller holds muxLock.
+func (m *inboundManager) dropPendingLocked(accountID types.AccountID, host nbtcp.SNIHost, svcID types.ServiceID) {
+ queued, ok := m.pendingRoutes[accountID]
+ if !ok {
+ return
+ }
+ filtered := queued[:0]
+ for _, pr := range queued {
+ if pr.host == host && pr.route.ServiceID == svcID {
+ continue
+ }
+ filtered = append(filtered, pr)
+ }
+ if len(filtered) == 0 {
+ delete(m.pendingRoutes, accountID)
+ return
+ }
+ m.pendingRoutes[accountID] = filtered
+}
+
+// flushPending applies all queued routes to a freshly-up router.
+func (m *inboundManager) flushPending(accountID types.AccountID, entry *inboundEntry) {
+ m.muxLock.Lock()
+ queued := m.pendingRoutes[accountID]
+ delete(m.pendingRoutes, accountID)
+ m.muxLock.Unlock()
+
+ for _, pr := range queued {
+ entry.router.AddRoute(pr.host, pr.route)
+ }
+}
+
+// HasInbound reports whether the manager has a live listener for the account.
+// Used by tests.
+func (m *inboundManager) HasInbound(accountID types.AccountID) bool {
+ if m == nil {
+ return false
+ }
+ m.muxLock.Lock()
+ defer m.muxLock.Unlock()
+ _, ok := m.entries[accountID]
+ return ok
+}
+
+// PendingRouteCount reports the number of queued routes for the account.
+// Used by tests.
+func (m *inboundManager) PendingRouteCount(accountID types.AccountID) int {
+ if m == nil {
+ return 0
+ }
+ m.muxLock.Lock()
+ defer m.muxLock.Unlock()
+ return len(m.pendingRoutes[accountID])
+}
+
+// InboundListenerInfo describes the bound addresses of a single
+// per-account inbound listener. Both addresses live on the embedded
+// netstack of the account's WireGuard client and share the same tunnel IP.
+type InboundListenerInfo struct {
+ TunnelIP string
+ HTTPSPort uint16
+ HTTPPort uint16
+}
+
+// ListenerInfo returns the inbound listener addresses for the given
+// account, or ok=false when the account has no live listener. Used by
+// the status-update RPC and the debug HTTP handler to surface inbound
+// reachability to operators.
+func (m *inboundManager) ListenerInfo(accountID types.AccountID) (InboundListenerInfo, bool) {
+ if m == nil {
+ return InboundListenerInfo{}, false
+ }
+ m.muxLock.Lock()
+ defer m.muxLock.Unlock()
+ entry, ok := m.entries[accountID]
+ if !ok || entry == nil {
+ return InboundListenerInfo{}, false
+ }
+ return listenerInfoFromEntry(entry), true
+}
+
+// Snapshot returns the inbound listener state for every account that has
+// a live listener at call time. Empty when --private is off or
+// no accounts have come up yet.
+func (m *inboundManager) Snapshot() map[types.AccountID]InboundListenerInfo {
+ if m == nil {
+ return nil
+ }
+ m.muxLock.Lock()
+ defer m.muxLock.Unlock()
+ if len(m.entries) == 0 {
+ return nil
+ }
+ out := make(map[types.AccountID]InboundListenerInfo, len(m.entries))
+ for id, entry := range m.entries {
+ if entry == nil {
+ continue
+ }
+ out[id] = listenerInfoFromEntry(entry)
+ }
+ return out
+}
+
+// listenerInfoFromEntry extracts the tunnel IP and ports from a live
+// per-account entry. Both listeners are bound on the same netstack so
+// their host components match; we still pull the TLS host as the
+// authoritative source.
+func listenerInfoFromEntry(entry *inboundEntry) InboundListenerInfo {
+ info := InboundListenerInfo{HTTPSPort: privateInboundPortHTTPS, HTTPPort: privateInboundPortHTTP}
+ if entry.tlsListener != nil {
+ host, port := splitHostPort(entry.tlsListener.Addr())
+ info.TunnelIP = host
+ if port != 0 {
+ info.HTTPSPort = port
+ }
+ }
+ if entry.plainListener != nil {
+ host, port := splitHostPort(entry.plainListener.Addr())
+ if info.TunnelIP == "" {
+ info.TunnelIP = host
+ }
+ if port != 0 {
+ info.HTTPPort = port
+ }
+ }
+ return info
+}
+
+// splitHostPort extracts host and port from a net.Addr, returning the
+// zero values when the address is missing or malformed.
+func splitHostPort(addr net.Addr) (string, uint16) {
+ if addr == nil {
+ return "", 0
+ }
+ host, portStr, err := net.SplitHostPort(addr.String())
+ if err != nil {
+ return "", 0
+ }
+ if portStr == "" {
+ return host, 0
+ }
+ port, err := strconv.ParseUint(portStr, 10, 16)
+ if err != nil {
+ return host, 0
+ }
+ return host, uint16(port)
+}
+
+// feedRouterFromListener accepts on the plain-HTTP netstack listener and
+// hands every connection to the account's router. The router peeks the
+// first byte and dispatches to the plain-HTTP channel for non-TLS
+// streams or the TLS channel for ClientHellos that arrive on :80.
+func feedRouterFromListener(ctx context.Context, ln net.Listener, router *nbtcp.Router, logger *log.Logger, accountID types.AccountID) {
+ go func() {
+ <-ctx.Done()
+ _ = ln.Close()
+ }()
+
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
+ return
+ }
+ logger.WithField("account_id", accountID).Debugf("plain inbound accept: %v", err)
+ continue
+ }
+ router.HandleConn(ctx, conn)
+ }
+}
+
+// accountDialResolver returns a DialResolver bound to a single account's
+// embedded client. The router only ever serves traffic for that account
+// so the supplied accountID is ignored at dial time.
+func accountDialResolver(_ types.AccountID, client *embed.Client) nbtcp.DialResolver {
+ return func(_ types.AccountID) (types.DialContextFunc, error) {
+ return client.DialContext, nil
+ }
+}
+
+// accountTunnelLookup returns a TunnelLookupFunc backed by the embedded
+// client's peerstore for a single account. Phase 3 uses the result to
+// short-circuit ValidateTunnelPeer when the source IP is not in the
+// account's roster and to seed the cached identity for known peers.
+func accountTunnelLookup(client *embed.Client) auth.TunnelLookupFunc {
+ if client == nil {
+ return nil
+ }
+ return func(ip netip.Addr) (auth.PeerIdentity, bool) {
+ pubKey, fqdn, ok := client.IdentityForIP(ip)
+ if !ok {
+ return auth.PeerIdentity{}, false
+ }
+ return auth.PeerIdentity{
+ PubKey: pubKey,
+ TunnelIP: ip,
+ FQDN: fqdn,
+ }, true
+ }
+}
+
+// withTunnelLookup returns an http.Handler that attaches the per-account
+// peerstore lookup to every request's context before delegating to next.
+// Calling on the host-level listener is a no-op because that path never
+// installs this wrapper, so the existing behaviour stays byte-for-byte
+// identical when --private is off or the request didn't arrive
+// on a per-account listener.
+func withTunnelLookup(next http.Handler, lookup auth.TunnelLookupFunc) http.Handler {
+ if lookup == nil {
+ return next
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := auth.WithTunnelLookup(r.Context(), lookup)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// inboundDebugAdapter adapts *inboundManager to the debug.InboundProvider
+// interface so the debug HTTP handler can render per-account inbound
+// listener state without importing the proxy package.
+type inboundDebugAdapter struct {
+ mgr *inboundManager
+}
+
+// InboundListeners returns a snapshot of the live per-account inbound
+// listeners formatted for the debug surface.
+func (a inboundDebugAdapter) InboundListeners() map[types.AccountID]debug.InboundListenerInfo {
+ if a.mgr == nil {
+ return nil
+ }
+ snap := a.mgr.Snapshot()
+ if len(snap) == 0 {
+ return nil
+ }
+ out := make(map[types.AccountID]debug.InboundListenerInfo, len(snap))
+ for id, info := range snap {
+ out[id] = debug.InboundListenerInfo{
+ TunnelIP: info.TunnelIP,
+ HTTPSPort: info.HTTPSPort,
+ HTTPPort: info.HTTPPort,
+ }
+ }
+ return out
+}
+
+// newInboundErrorLog routes a per-account http.Server's stdlib error
+// stream through logrus at warn level. The returned PipeWriter must be
+// closed by the caller (tearDown) once the http.Server has shut down —
+// otherwise the pipe and its scanner goroutine leak per account, see
+// logrus.Entry.WriterLevel.
+func newInboundErrorLog(logger *log.Logger, scheme string, accountID types.AccountID) (*stdlog.Logger, *io.PipeWriter) {
+ w := logger.WithFields(log.Fields{
+ "inbound-http": scheme,
+ "account_id": accountID,
+ }).WriterLevel(log.WarnLevel)
+ return stdlog.New(w, "", 0), w
+}
diff --git a/proxy/inbound_test.go b/proxy/inbound_test.go
new file mode 100644
index 000000000..584a04238
--- /dev/null
+++ b/proxy/inbound_test.go
@@ -0,0 +1,535 @@
+package proxy
+
+import (
+ "bufio"
+ "context"
+ "crypto/tls"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+
+ "github.com/netbirdio/netbird/proxy/internal/auth"
+ "github.com/netbirdio/netbird/proxy/internal/roundtrip"
+ nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp"
+ "github.com/netbirdio/netbird/proxy/internal/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// bufioReader wraps the connection in a buffered reader so http.ReadResponse
+// can parse the response line + headers off the wire.
+func bufioReader(conn net.Conn) *bufio.Reader {
+ return bufio.NewReader(conn)
+}
+
+// quietLogger returns a logger that emits nothing — keeps test output tidy.
+func quietLogger() *log.Logger {
+ logger := log.New()
+ logger.SetLevel(log.PanicLevel)
+ return logger
+}
+
+func TestInboundManager_RouteScopedToAccount(t *testing.T) {
+ mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil)
+
+ accountA := types.AccountID("acct-a")
+ accountB := types.AccountID("acct-b")
+
+ mgr.AddRoute(accountA, "shared.example", nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountA, ServiceID: "svc-a", Domain: "shared.example"})
+ mgr.AddRoute(accountB, "other.example", nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountB, ServiceID: "svc-b", Domain: "other.example"})
+
+ require.Equal(t, 1, mgr.PendingRouteCount(accountA), "account A should have one queued route")
+ require.Equal(t, 1, mgr.PendingRouteCount(accountB), "account B should have one queued route")
+
+ mgr.RemoveRoute(accountA, "shared.example", "svc-a")
+ mgr.RemoveRoute(accountB, "other.example", "svc-b")
+
+ assert.Equal(t, 0, mgr.PendingRouteCount(accountA), "queue should drain on remove")
+ assert.Equal(t, 0, mgr.PendingRouteCount(accountB), "queue should drain on remove")
+}
+
+func TestInboundManager_PendingThenFlush(t *testing.T) {
+ mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil)
+
+ accountID := types.AccountID("acct-1")
+ host := nbtcp.SNIHost("example.test")
+ route := nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountID, ServiceID: "svc-1", Domain: "example.test"}
+
+ mgr.AddRoute(accountID, host, route)
+ require.Equal(t, 1, mgr.PendingRouteCount(accountID), "pending count before listener is up")
+
+ // Simulate listener up by registering a fake entry, then flushing.
+ router := nbtcp.NewRouter(quietLogger(), nil, &fakeAddr{addr: "127.0.0.1:0"})
+ entry := &inboundEntry{router: router}
+ mgr.muxLock.Lock()
+ mgr.entries[accountID] = entry
+ mgr.muxLock.Unlock()
+
+ mgr.flushPending(accountID, entry)
+ assert.Equal(t, 0, mgr.PendingRouteCount(accountID), "queue should be empty after flush")
+}
+
+// fakeAddr is a stub net.Addr for tests that don't actually bind sockets.
+type fakeAddr struct {
+ addr string
+}
+
+func (a *fakeAddr) Network() string { return "tcp" }
+func (a *fakeAddr) String() string { return a.addr }
+
+// fakeMgmtClient implements roundtrip.managementClient for tests.
+type fakeMgmtClient struct{}
+
+func (fakeMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) {
+ return &proto.CreateProxyPeerResponse{Success: true}, nil
+}
+
+// TestServer_PrivateInbound_NotEnabled_NoManager confirms that with
+// --private off the inbound manager is nil and the standalone proxy
+// keeps its zero-overhead default path.
+func TestServer_PrivateInbound_NotEnabled_NoManager(t *testing.T) {
+ s := &Server{Logger: quietLogger(), Private: false}
+ s.initPrivateInbound(http.NotFoundHandler(), nil)
+ assert.Nil(t, s.inbound, "manager should remain nil when --private is off")
+}
+
+// TestServer_PrivateInbound_Enabled_WiresLifecycle confirms that
+// --private alone wires the manager into the NetBird transport, so
+// AddPeer / RemovePeer drive the lifecycle.
+func TestServer_PrivateInbound_Enabled_WiresLifecycle(t *testing.T) {
+ s := &Server{Logger: quietLogger(), Private: true}
+ // Construct a NetBird transport. We can't actually start the embedded
+ // client here (that needs a real management server), but we can
+ // confirm that the lifecycle callbacks are registered.
+ s.netbird = roundtrip.NewNetBird(t.Context(), "test", "test", roundtrip.ClientConfig{
+ MgmtAddr: "http://invalid.test",
+ }, quietLogger(), nil, fakeMgmtClient{})
+
+ s.initPrivateInbound(http.NotFoundHandler(), &tls.Config{}) //nolint:gosec
+ require.NotNil(t, s.inbound, "manager should be set when --private is on")
+ assert.NotNil(t, s.inbound.handler, "handler should be set on manager")
+ assert.NotNil(t, s.inbound.tlsConfig, "tls config should be set on manager")
+}
+
+// TestInboundManager_AddRouteAfterReady_RegistersDirectly verifies that
+// when the listener is already up, AddRoute writes straight to the
+// router without queueing.
+func TestInboundManager_AddRouteAfterReady_RegistersDirectly(t *testing.T) {
+ mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil)
+ accountID := types.AccountID("acct-1")
+ router := nbtcp.NewRouter(quietLogger(), nil, &fakeAddr{addr: "127.0.0.1:0"})
+
+ mgr.muxLock.Lock()
+ mgr.entries[accountID] = &inboundEntry{router: router}
+ mgr.muxLock.Unlock()
+
+ host := nbtcp.SNIHost("ready.example")
+ mgr.AddRoute(accountID, host, nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountID, ServiceID: "svc-ready", Domain: string(host)})
+ assert.Equal(t, 0, mgr.PendingRouteCount(accountID), "no pending entries when listener is up")
+}
+
+// TestPrivateCapability_DerivedFromPrivateOnly tests that the capability
+// bit reported upstream tracks --private exclusively. The previous
+// --private flag has been folded into --private.
+func TestPrivateCapability_DerivedFromPrivateOnly(t *testing.T) {
+ tests := []struct {
+ name string
+ private bool
+ expected bool
+ }{
+ {"off", false, false},
+ {"on", true, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := &Server{Private: tt.private}
+ assert.Equal(t, tt.expected, s.Private, "private capability bit should match --private")
+ })
+ }
+}
+
+// TestInboundManager_RouteScopedToAccountB_DoesNotMatchA verifies that a
+// service registered for account B is invisible to a router serving
+// account A. We exercise the path through real per-account routers.
+func TestInboundManager_RouteScopedToAccountB_DoesNotMatchA(t *testing.T) {
+ mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil)
+
+ accountA := types.AccountID("acct-a")
+ accountB := types.AccountID("acct-b")
+ routerA := nbtcp.NewRouter(quietLogger(), nil, &fakeAddr{addr: "127.0.0.1:0"})
+ routerB := nbtcp.NewRouter(quietLogger(), nil, &fakeAddr{addr: "127.0.0.1:0"})
+
+ mgr.muxLock.Lock()
+ mgr.entries[accountA] = &inboundEntry{router: routerA}
+ mgr.entries[accountB] = &inboundEntry{router: routerB}
+ mgr.muxLock.Unlock()
+
+ host := nbtcp.SNIHost("shared.example")
+ mgr.AddRoute(accountB, host, nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountB, ServiceID: "svc-b", Domain: string(host)})
+
+ // Account A's router should have no routes; account B's should have one.
+ // We check via IsEmpty — true means no routes and no fallback.
+ assert.True(t, routerA.IsEmpty(), "account A router must not see account B's mappings")
+ assert.False(t, routerB.IsEmpty(), "account B router should hold its own mapping")
+}
+
+// TestInboundEntry_ShutdownIdempotent ensures that tearDown can run twice
+// without panicking — callers may invoke it from RemovePeer + StopAll.
+func TestInboundEntry_ShutdownIdempotent(t *testing.T) {
+ t.Skip("teardown requires real netstack listeners; covered by integration tests")
+}
+
+// TestRouter_PlainHTTP_ForwardedProtoIsHTTP exercises the full per-account
+// router pipeline against a loopback listener (proxy of a netstack
+// listener for test purposes): a plain HTTP request lands on the plain
+// http.Server and the inner handler observes a nil r.TLS, which is what
+// auth.ResolveProto translates to "http" in the real pipeline.
+func TestRouter_PlainHTTP_ForwardedProtoIsHTTP(t *testing.T) {
+ logger := quietLogger()
+
+ var captured atomic.Value
+ captured.Store("")
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.TLS == nil {
+ captured.Store("http")
+ } else {
+ captured.Store("https")
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok"))
+ })
+
+ hostListener, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "loopback listener bind must succeed")
+ defer hostListener.Close()
+
+ router := nbtcp.NewRouter(logger, nil, hostListener.Addr(), nbtcp.WithPlainHTTP(hostListener.Addr()))
+ httpServer := &http.Server{Handler: handler, ReadHeaderTimeout: time.Second}
+ defer func() { _ = httpServer.Close() }()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go func() { _ = httpServer.Serve(router.HTTPListenerPlain()) }()
+ go func() { _ = router.Serve(ctx, hostListener) }()
+
+ conn, err := net.DialTimeout("tcp", hostListener.Addr().String(), 2*time.Second)
+ require.NoError(t, err, "plain HTTP dial must succeed")
+ defer conn.Close()
+
+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"))
+ require.NoError(t, err, "write must succeed")
+
+ resp, err := http.ReadResponse(bufioReader(conn), nil)
+ require.NoError(t, err, "must read response")
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ assert.Equal(t, "http", captured.Load(), "ForwardedProto must be http on plain path")
+}
+
+// TestWithTunnelLookup_AttachesLookupToContext verifies that requests
+// flowing through the per-account handler wrapper carry the peerstore
+// lookup function. Phase 3's local-first deny path depends on this.
+func TestWithTunnelLookup_AttachesLookupToContext(t *testing.T) {
+ expected := auth.PeerIdentity{TunnelIP: netip.MustParseAddr("100.64.0.10"), FQDN: "peer.netbird"}
+ lookup := auth.TunnelLookupFunc(func(_ netip.Addr) (auth.PeerIdentity, bool) {
+ return expected, true
+ })
+
+ var observed auth.TunnelLookupFunc
+ inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ observed = auth.TunnelLookupFromContext(r.Context())
+ })
+
+ handler := withTunnelLookup(inner, lookup)
+ r := httptest.NewRequest(http.MethodGet, "https://svc.example/", nil)
+ handler.ServeHTTP(httptest.NewRecorder(), r)
+
+ require.NotNil(t, observed, "wrapper must inject the lookup into the request context")
+ got, ok := observed(netip.MustParseAddr("100.64.0.10"))
+ assert.True(t, ok, "lookup must round-trip through context")
+ assert.Equal(t, expected.FQDN, got.FQDN, "lookup must return the same identity it was constructed with")
+}
+
+// TestWithTunnelLookup_NilLookupIsNoop confirms the wrapper is a pure
+// pass-through when no lookup is provided. Required for the host-level
+// listener path to keep its byte-for-byte previous behaviour.
+func TestWithTunnelLookup_NilLookupIsNoop(t *testing.T) {
+ var called bool
+ inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ called = true
+ assert.Nil(t, auth.TunnelLookupFromContext(r.Context()), "host-level path must not see a lookup function")
+ })
+
+ handler := withTunnelLookup(inner, nil)
+ r := httptest.NewRequest(http.MethodGet, "https://svc.example/", nil)
+ handler.ServeHTTP(httptest.NewRecorder(), r)
+ assert.True(t, called, "wrapper without lookup must still invoke next")
+}
+
+// fakeListener satisfies net.Listener for snapshot tests without binding
+// a real socket on the netstack.
+type fakeListener struct {
+ addr net.Addr
+}
+
+func (f *fakeListener) Accept() (net.Conn, error) { return nil, net.ErrClosed }
+func (f *fakeListener) Close() error { return nil }
+func (f *fakeListener) Addr() net.Addr { return f.addr }
+
+// TestInboundManager_ListenerInfo confirms ListenerInfo and Snapshot
+// surface the bound tunnel-IP and ports for live entries.
+func TestInboundManager_ListenerInfo(t *testing.T) {
+ mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil)
+ accountID := types.AccountID("acct-info")
+
+ tlsAddr := &net.TCPAddr{IP: net.ParseIP("100.64.0.5"), Port: privateInboundPortHTTPS}
+ plainAddr := &net.TCPAddr{IP: net.ParseIP("100.64.0.5"), Port: privateInboundPortHTTP}
+ mgr.muxLock.Lock()
+ mgr.entries[accountID] = &inboundEntry{
+ tlsListener: &fakeListener{addr: tlsAddr},
+ plainListener: &fakeListener{addr: plainAddr},
+ }
+ mgr.muxLock.Unlock()
+
+ info, ok := mgr.ListenerInfo(accountID)
+ require.True(t, ok, "ListenerInfo must report ok for live entry")
+ assert.Equal(t, "100.64.0.5", info.TunnelIP, "tunnel IP must come from listener address")
+ assert.Equal(t, uint16(privateInboundPortHTTPS), info.HTTPSPort, "TLS port must match bound port")
+ assert.Equal(t, uint16(privateInboundPortHTTP), info.HTTPPort, "HTTP port must match bound port")
+
+ snap := mgr.Snapshot()
+ require.Len(t, snap, 1, "snapshot must contain exactly one entry")
+ assert.Equal(t, info, snap[accountID], "snapshot entry must equal direct lookup")
+
+ _, ok = mgr.ListenerInfo(types.AccountID("missing"))
+ assert.False(t, ok, "ListenerInfo must report ok=false for unknown accounts")
+}
+
+// TestInboundManager_NilManagerSafe ensures the observability accessors
+// are safe to call when --private is off (nil manager).
+func TestInboundManager_NilManagerSafe(t *testing.T) {
+ var mgr *inboundManager
+ _, ok := mgr.ListenerInfo("anything")
+ assert.False(t, ok, "nil manager must return ok=false")
+ assert.Nil(t, mgr.Snapshot(), "nil manager must return nil snapshot")
+}
+
+// TestInboundManager_ConcurrentAddRemove pounds AddRoute / RemoveRoute
+// from multiple goroutines to expose any locking gaps.
+func TestInboundManager_ConcurrentAddRemove(t *testing.T) {
+ mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil)
+ accountID := types.AccountID("acct-1")
+ const workers = 32
+ const iterations = 50
+
+ var wg sync.WaitGroup
+ wg.Add(workers)
+ for i := 0; i < workers; i++ {
+ go func(idx int) {
+ defer wg.Done()
+ host := nbtcp.SNIHost("example.test")
+ svc := types.ServiceID("svc")
+ route := nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountID, ServiceID: svc, Domain: "example.test"}
+ for j := 0; j < iterations; j++ {
+ mgr.AddRoute(accountID, host, route)
+ mgr.RemoveRoute(accountID, host, svc)
+ }
+ }(i)
+ }
+ done := make(chan struct{})
+ go func() {
+ wg.Wait()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(10 * time.Second):
+ t.Fatal("concurrent add/remove timed out")
+ }
+}
+
+// TestFeedRouterFromListener_DeliversConnectionToHandler validates the
+// per-account inbound chain end-to-end with a loopback listener
+// substituted for the embedded netstack: a TCP connection arriving at
+// the plain listener flows through feedRouterFromListener, the router's
+// peek-and-dispatch, the wrapped HTTP server, and reaches the user
+// handler. If the embedded netstack is delivering connections at all,
+// this is the path they take. Failures localise to wiring bugs in the
+// proxy, not the netstack.
+func TestFeedRouterFromListener_DeliversConnectionToHandler(t *testing.T) {
+ logger := quietLogger()
+
+ hits := make(chan string, 1)
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits <- r.Host
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("served"))
+ })
+
+ plainLn, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "plain loopback bind must succeed")
+ t.Cleanup(func() { _ = plainLn.Close() })
+
+ router := nbtcp.NewRouter(logger, nil, &fakeAddr{addr: "127.0.0.1:0"}, nbtcp.WithPlainHTTP(plainLn.Addr()))
+
+ httpServer := &http.Server{Handler: handler, ReadHeaderTimeout: time.Second}
+ t.Cleanup(func() { _ = httpServer.Close() })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+
+ go func() { _ = httpServer.Serve(router.HTTPListenerPlain()) }()
+ go feedRouterFromListener(ctx, plainLn, router, logger, types.AccountID("acct-1"))
+
+ conn, err := net.DialTimeout("tcp", plainLn.Addr().String(), 2*time.Second)
+ require.NoError(t, err, "must connect to the plain listener")
+ t.Cleanup(func() { _ = conn.Close() })
+
+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: app.example\r\nConnection: close\r\n\r\n"))
+ require.NoError(t, err, "request write must succeed")
+
+ resp, err := http.ReadResponse(bufioReader(conn), nil)
+ require.NoError(t, err, "must read response from server")
+ t.Cleanup(func() { _ = resp.Body.Close() })
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode, "handler must be reached")
+
+ select {
+ case host := <-hits:
+ assert.Equal(t, "app.example", host, "handler must observe the request Host")
+ case <-time.After(2 * time.Second):
+ t.Fatal("handler was not invoked — connection did not flow through router → http server")
+ }
+}
+
+// TestFeedRouterFromListener_DispatchesTLSToTLSChannel verifies that a
+// TLS ClientHello arriving on the plain listener is detected by the
+// router peek and re-dispatched to the TLS channel — the cross-channel
+// fallback the inbound stack relies on for HTTPS-on-:80 testing.
+func TestFeedRouterFromListener_DispatchesTLSToTLSChannel(t *testing.T) {
+ logger := quietLogger()
+
+ hits := make(chan string, 1)
+ tlsHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits <- r.Host
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("served-tls"))
+ })
+
+ plainLn, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "plain loopback bind must succeed")
+ t.Cleanup(func() { _ = plainLn.Close() })
+
+ tlsLn, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "tls loopback bind must succeed")
+ t.Cleanup(func() { _ = tlsLn.Close() })
+
+ router := nbtcp.NewRouter(logger, nil, tlsLn.Addr(), nbtcp.WithPlainHTTP(plainLn.Addr()))
+
+ tlsConfig := selfSignedTLSConfig(t)
+ httpsServer := &http.Server{
+ Handler: tlsHandler,
+ TLSConfig: tlsConfig,
+ ReadHeaderTimeout: time.Second,
+ }
+ t.Cleanup(func() { _ = httpsServer.Close() })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+
+ go func() { _ = httpsServer.ServeTLS(router.HTTPListener(), "", "") }()
+ go feedRouterFromListener(ctx, plainLn, router, logger, types.AccountID("acct-tls"))
+
+ tlsConn, err := tls.Dial("tcp", plainLn.Addr().String(), &tls.Config{InsecureSkipVerify: true}) //nolint:gosec
+ require.NoError(t, err, "TLS dial against the plain listener must succeed (cross-channel)")
+ t.Cleanup(func() { _ = tlsConn.Close() })
+
+ req, err := http.NewRequest(http.MethodGet, "https://app.example/", nil)
+ require.NoError(t, err)
+ require.NoError(t, req.Write(tlsConn), "TLS request write must succeed")
+
+ resp, err := http.ReadResponse(bufioReader(tlsConn), req)
+ require.NoError(t, err, "must read TLS response")
+ t.Cleanup(func() { _ = resp.Body.Close() })
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode, "TLS handler must be reached")
+
+ select {
+ case host := <-hits:
+ assert.Equal(t, "app.example", host, "TLS handler must observe the request Host")
+ case <-time.After(2 * time.Second):
+ t.Fatal("TLS handler was not invoked — peek/dispatch path is broken")
+ }
+}
+
+func selfSignedTLSConfig(t *testing.T) *tls.Config {
+ t.Helper()
+ cert, err := tls.X509KeyPair(testCertPEM, testKeyPEM)
+ require.NoError(t, err, "load static self-signed cert")
+ return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12} //nolint:gosec
+}
+
+// TestNewInboundErrorLog_WriterIsCloseable guards the close path on the
+// logrus PipeWriter that backs each per-account http.Server's ErrorLog.
+// logrus.Entry.WriterLevel returns an *io.PipeWriter that owns a pipe +
+// scanner goroutine; the caller must Close() it on teardown or the
+// resources leak per account. The contract is verified two ways:
+//
+// - the constructor returns a non-nil writer the caller can keep,
+// - writing to the writer after Close() fails with io.ErrClosedPipe,
+// which is the only externally observable sign that Close was wired.
+//
+// A leaking refactor (forgetting to thread the writer to tearDown, or
+// dropping the Close call) would still pass this test individually but
+// fail an integration goleak check; this unit test is the cheap first
+// line of defence.
+func TestNewInboundErrorLog_WriterIsCloseable(t *testing.T) {
+ logger := quietLogger()
+ stdLog, writer := newInboundErrorLog(logger, "https", types.AccountID("acct-1"))
+
+ require.NotNil(t, stdLog, "newInboundErrorLog must return a non-nil *log.Logger")
+ require.NotNil(t, writer, "newInboundErrorLog must return the underlying PipeWriter so tearDown can Close it")
+
+ // First Close succeeds.
+ require.NoError(t, writer.Close(), "PipeWriter.Close should succeed the first time")
+
+ // After Close, the writer must refuse new writes — that's the only
+ // behavioural signal that the pipe (and its scanner goroutine) has
+ // shut down.
+ _, err := writer.Write([]byte("post-close write\n"))
+ require.ErrorIs(t, err, io.ErrClosedPipe,
+ "writes after Close must surface io.ErrClosedPipe so callers know the writer is gone")
+}
+
+// testCertPEM / testKeyPEM are a minimal RSA self-signed cert for
+// 127.0.0.1 — only used by tests that need a working TLS handshake.
+var testCertPEM = []byte(`-----BEGIN CERTIFICATE-----
+MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
+DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
+EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
+7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
+5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
+BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
+NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
+Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
+6MF9+Yw1Yy0t
+-----END CERTIFICATE-----`)
+var testKeyPEM = []byte(`-----BEGIN EC PRIVATE KEY-----
+MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49
+AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q
+EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==
+-----END EC PRIVATE KEY-----`)
diff --git a/proxy/internal/auth/identity.go b/proxy/internal/auth/identity.go
new file mode 100644
index 000000000..c49e0caa9
--- /dev/null
+++ b/proxy/internal/auth/identity.go
@@ -0,0 +1,47 @@
+package auth
+
+import (
+ "context"
+ "net/netip"
+)
+
+// PeerIdentity describes the locally-known facts about a peer reachable on
+// the proxy's per-account WireGuard listener. Phase 3 fills PubKey, TunnelIP
+// and FQDN from the embedded client's peerstore. UserID, Email and Groups
+// stay zero in V1 — full identity still travels through ValidateTunnelPeer.
+// Phase V2 will populate them once RemotePeerConfig carries user identity.
+type PeerIdentity struct {
+ PubKey string
+ TunnelIP netip.Addr
+ FQDN string
+
+ // V2 fields (zero in V1).
+ UserID string
+ Email string
+ Groups []string
+}
+
+// TunnelLookupFunc resolves a tunnel IP to a peer identity using locally
+// available peerstore data. ok=false means the IP is not in the calling
+// account's roster.
+type TunnelLookupFunc func(ip netip.Addr) (PeerIdentity, bool)
+
+type tunnelLookupContextKey struct{}
+
+// WithTunnelLookup attaches a per-account peerstore lookup function to
+// the request context. The auth middleware calls this lookup before
+// hitting management's ValidateTunnelPeer to short-circuit unknown IPs
+// and to skip the RPC for already-cached identities.
+func WithTunnelLookup(ctx context.Context, lookup TunnelLookupFunc) context.Context {
+ if lookup == nil {
+ return ctx
+ }
+ return context.WithValue(ctx, tunnelLookupContextKey{}, lookup)
+}
+
+// TunnelLookupFromContext returns the peerstore lookup attached to ctx,
+// or nil when the request did not arrive on a per-account listener.
+func TunnelLookupFromContext(ctx context.Context) TunnelLookupFunc {
+ v, _ := ctx.Value(tunnelLookupContextKey{}).(TunnelLookupFunc)
+ return v
+}
diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go
index 3b383f8b4..72630b085 100644
--- a/proxy/internal/auth/middleware.go
+++ b/proxy/internal/auth/middleware.go
@@ -36,6 +36,7 @@ type authenticator interface {
// SessionValidator validates session tokens and checks user access permissions.
type SessionValidator interface {
ValidateSession(ctx context.Context, in *proto.ValidateSessionRequest, opts ...grpc.CallOption) (*proto.ValidateSessionResponse, error)
+ ValidateTunnelPeer(ctx context.Context, in *proto.ValidateTunnelPeerRequest, opts ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error)
}
// Scheme defines an authentication mechanism for a domain.
@@ -56,12 +57,21 @@ type DomainConfig struct {
AccountID types.AccountID
ServiceID types.ServiceID
IPRestrictions *restrict.Filter
+ // Private routes the domain through ValidateTunnelPeer; failure → 403.
+ Private bool
}
type validationResult struct {
UserID string
+ UserEmail string
Valid bool
DeniedReason string
+ Groups []string
+ // GroupNames carries the human-readable display names for Groups,
+ // ordered identically (positional pairing). May be shorter than
+ // Groups for tokens minted before names were embedded; the consumer
+ // falls back to ids for missing positions.
+ GroupNames []string
}
// Middleware applies per-domain authentication and IP restriction checks.
@@ -71,6 +81,7 @@ type Middleware struct {
logger *log.Logger
sessionValidator SessionValidator
geo restrict.GeoResolver
+ tunnelCache *tunnelValidationCache
}
// NewMiddleware creates a new authentication middleware. The sessionValidator is
@@ -84,6 +95,7 @@ func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator, geo re
logger: logger,
sessionValidator: sessionValidator,
geo: geo,
+ tunnelCache: newTunnelValidationCache(),
}
}
@@ -111,6 +123,15 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
return
}
+ // Private services bypass operator schemes and gate on tunnel peer.
+ if config.Private {
+ if mw.forwardWithTunnelPeer(w, r, host, config, next) {
+ return
+ }
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+
// Domains with no authentication schemes pass through after IP checks.
if len(config.Schemes) == 0 {
next.ServeHTTP(w, r)
@@ -129,10 +150,54 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
return
}
+ if mw.forwardWithTunnelPeer(w, r, host, config, next) {
+ return
+ }
+
+ if mw.blockOIDCOnPlainHTTP(w, r, config) {
+ return
+ }
+
mw.authenticateWithSchemes(w, r, host, config)
})
}
+// requestIsPlainHTTP reports whether the request arrived without TLS.
+// Used to gate cookie-on-plain warnings and the OIDC plain-HTTP block.
+func requestIsPlainHTTP(r *http.Request) bool {
+ return r.TLS == nil
+}
+
+// hasOIDCScheme reports whether any of the configured schemes requires
+// TLS to round-trip safely with an external IdP.
+func hasOIDCScheme(schemes []Scheme) bool {
+ for _, s := range schemes {
+ if s.Type() == auth.MethodOIDC {
+ return true
+ }
+ }
+ return false
+}
+
+// blockOIDCOnPlainHTTP fails fast when an OIDC-configured domain is hit
+// over plain HTTP. Most IdPs reject http:// redirect URIs, so surfacing
+// the misconfiguration here yields a clearer error than the IdP's
+// "invalid redirect_uri" round-trip.
+func (mw *Middleware) blockOIDCOnPlainHTTP(w http.ResponseWriter, r *http.Request, config DomainConfig) bool {
+ if !requestIsPlainHTTP(r) {
+ return false
+ }
+ if !hasOIDCScheme(config.Schemes) {
+ return false
+ }
+ mw.logger.WithFields(log.Fields{
+ "host": r.Host,
+ "remote": r.RemoteAddr,
+ }).Warn("OIDC scheme reached on plain HTTP path; rejecting with 400 — use port 443")
+ http.Error(w, "OIDC requires TLS — use port 443", http.StatusBadRequest)
+ return true
+}
+
func (mw *Middleware) getDomainConfig(host string) (DomainConfig, bool) {
mw.domainsMux.RLock()
defer mw.domainsMux.RUnlock()
@@ -162,7 +227,17 @@ func (mw *Middleware) checkIPRestrictions(w http.ResponseWriter, r *http.Request
return false
}
- verdict := config.IPRestrictions.Check(clientIP, mw.geo)
+ var verdict restrict.Verdict
+ if types.IsOverlayOrigin(r.Context()) {
+ // Geo/CrowdSec checks don't apply over the WireGuard overlay:
+ // the source address is always inside the NetBird CGNAT range,
+ // which is never in a GeoIP database or a CrowdSec decision
+ // list. Enforcing them here would either no-op (best case) or
+ // fail-closed when the geo database is missing.
+ verdict = config.IPRestrictions.CheckCIDR(clientIP)
+ } else {
+ verdict = config.IPRestrictions.Check(clientIP, mw.geo)
+ }
if verdict == restrict.Allow {
return true
}
@@ -246,18 +321,119 @@ func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Re
if err != nil {
return false
}
- userID, method, err := auth.ValidateSessionJWT(cookie.Value, host, config.SessionPublicKey)
+ userID, email, method, groups, groupNames, err := auth.ValidateSessionJWT(cookie.Value, host, config.SessionPublicKey)
if err != nil {
return false
}
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetUserID(userID)
+ cd.SetUserEmail(email)
+ cd.SetUserGroups(groups)
+ cd.SetUserGroupNames(groupNames)
cd.SetAuthMethod(method)
}
next.ServeHTTP(w, r)
return true
}
+// forwardWithTunnelPeer is the OIDC fast-path for requests originating on the
+// netbird mesh. When the source IP belongs to a private/CGNAT range the proxy
+// asks management to resolve it to a peer/user and to gate by the service's
+// distribution_groups. On success the proxy installs the freshly minted JWT
+// as a session cookie, sets UserID + Method=oidc on the captured data, and
+// forwards directly — operators see the same access-log shape as if the user
+// had completed an OIDC redirect. Any failure (private-range mismatch,
+// management unreachable, peer unknown, user not in group) returns false so
+// the caller falls back to the existing OIDC scheme dispatch.
+//
+// The fast-path is gated on TunnelLookupFromContext(r.Context()) being
+// present — that context value is attached only by the per-account
+// inbound (overlay) listener. The host listener never sets it, so a
+// public client whose source IP happens to fall inside an RFC1918 / ULA
+// / CGNAT range can't impersonate a mesh peer by colliding with a
+// tunnel-IP. Once we know the request arrived over WireGuard the
+// per-account peerstore lookup is consulted: a miss denies fast (no
+// management round-trip), a hit gates the cached ValidateTunnelPeer RPC
+// that mints the session JWT.
+func (mw *Middleware) forwardWithTunnelPeer(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool {
+ if mw.sessionValidator == nil {
+ return false
+ }
+ clientIP := mw.resolveClientIP(r)
+ if !clientIP.IsValid() {
+ return false
+ }
+
+ // Anti-spoof: only honour the tunnel-peer fast-path on requests that
+ // were stamped by an overlay listener. Without that marker an
+ // attacker could send a request from a colliding RFC1918 / CGNAT
+ // source on the public listener and bypass operator auth.
+ lookup := TunnelLookupFromContext(r.Context())
+ if lookup == nil {
+ return false
+ }
+ if !isTunnelSourceIP(clientIP) {
+ return false
+ }
+ if _, ok := lookup(clientIP); !ok {
+ mw.logger.WithFields(log.Fields{
+ "host": host,
+ "remote": clientIP,
+ }).Debug("local peerstore: tunnel IP not in account roster; denying without RPC")
+ return false
+ }
+
+ resp, _, err := mw.tunnelCache.fetch(r.Context(), tunnelCacheKey{
+ accountID: config.AccountID,
+ tunnelIP: clientIP,
+ domain: host,
+ }, mw.validateTunnelPeer)
+ if err != nil {
+ mw.logger.WithError(err).Debug("ValidateTunnelPeer failed; falling back to OIDC")
+ return false
+ }
+ if !resp.GetValid() || resp.GetSessionToken() == "" {
+ return false
+ }
+
+ setSessionCookie(w, resp.GetSessionToken(), config.SessionExpiration)
+ if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
+ cd.SetOrigin(proxy.OriginAuth)
+ cd.SetUserID(resp.GetUserId())
+ cd.SetUserEmail(resp.GetUserEmail())
+ cd.SetUserGroups(resp.GetPeerGroupIds())
+ cd.SetUserGroupNames(resp.GetPeerGroupNames())
+ cd.SetAuthMethod(auth.MethodOIDC.String())
+ }
+ next.ServeHTTP(w, r)
+ return true
+}
+
+// validateTunnelPeer adapts the SessionValidator interface to the cache's
+// validateTunnelPeerFn signature.
+func (mw *Middleware) validateTunnelPeer(ctx context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ return mw.sessionValidator.ValidateTunnelPeer(ctx, req)
+}
+
+// cgnatPrefix covers RFC 6598 100.64.0.0/10, the CGNAT block NetBird
+// allocates tunnel addresses from by default. IsPrivate() doesn't include
+// it, so we check it explicitly.
+var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10")
+
+// isTunnelSourceIP reports whether ip falls within an address range typical
+// of NetBird tunnels: RFC1918 private space, IPv6 ULA, or CGNAT 100.64/10
+// (NetBird's default range). Loopback and link-local are excluded — the
+// fast-path is meant for peer-to-peer mesh traffic, not localhost.
+func isTunnelSourceIP(ip netip.Addr) bool {
+ if !ip.IsValid() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
+ return false
+ }
+ if ip.IsPrivate() {
+ return true
+ }
+ return cgnatPrefix.Contains(ip)
+}
+
// forwardWithHeaderAuth checks for a Header auth scheme. If the header validates,
// the request is forwarded directly (no redirect), which is important for API clients.
func (mw *Middleware) forwardWithHeaderAuth(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool {
@@ -286,7 +462,7 @@ func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, ho
result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, auth.MethodHeader)
if err != nil {
- setHeaderCapturedData(r.Context(), "")
+ setHeaderCapturedData(r.Context(), "", "", nil, nil)
status := http.StatusBadRequest
msg := "invalid session token"
if errors.Is(err, errValidationUnavailable) {
@@ -298,7 +474,7 @@ func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, ho
}
if !result.Valid {
- setHeaderCapturedData(r.Context(), result.UserID)
+ setHeaderCapturedData(r.Context(), result.UserID, result.UserEmail, result.Groups, result.GroupNames)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return true
}
@@ -306,6 +482,9 @@ func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, ho
setSessionCookie(w, token, config.SessionExpiration)
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetUserID(result.UserID)
+ cd.SetUserEmail(result.UserEmail)
+ cd.SetUserGroups(result.Groups)
+ cd.SetUserGroupNames(result.GroupNames)
cd.SetAuthMethod(auth.MethodHeader.String())
}
@@ -315,7 +494,7 @@ func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, ho
func (mw *Middleware) handleHeaderAuthError(w http.ResponseWriter, r *http.Request, err error) bool {
if errors.Is(err, ErrHeaderAuthFailed) {
- setHeaderCapturedData(r.Context(), "")
+ setHeaderCapturedData(r.Context(), "", "", nil, nil)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return true
}
@@ -327,7 +506,7 @@ func (mw *Middleware) handleHeaderAuthError(w http.ResponseWriter, r *http.Reque
return true
}
-func setHeaderCapturedData(ctx context.Context, userID string) {
+func setHeaderCapturedData(ctx context.Context, userID, userEmail string, groups, groupNames []string) {
cd := proxy.CapturedDataFromContext(ctx)
if cd == nil {
return
@@ -335,6 +514,9 @@ func setHeaderCapturedData(ctx context.Context, userID string) {
cd.SetOrigin(proxy.OriginAuth)
cd.SetAuthMethod(auth.MethodHeader.String())
cd.SetUserID(userID)
+ cd.SetUserEmail(userEmail)
+ cd.SetUserGroups(groups)
+ cd.SetUserGroupNames(groupNames)
}
// authenticateWithSchemes tries each configured auth scheme in order.
@@ -405,6 +587,9 @@ func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Re
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetOrigin(proxy.OriginAuth)
cd.SetUserID(result.UserID)
+ cd.SetUserEmail(result.UserEmail)
+ cd.SetUserGroups(result.Groups)
+ cd.SetUserGroupNames(result.GroupNames)
cd.SetAuthMethod(scheme.Type().String())
requestID = cd.GetRequestID()
}
@@ -419,6 +604,9 @@ func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Re
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetOrigin(proxy.OriginAuth)
cd.SetUserID(result.UserID)
+ cd.SetUserEmail(result.UserEmail)
+ cd.SetUserGroups(result.Groups)
+ cd.SetUserGroupNames(result.GroupNames)
cd.SetAuthMethod(scheme.Type().String())
}
redirectURL := stripSessionTokenParam(r.URL)
@@ -454,12 +642,9 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool {
return false
}
-// AddDomain registers authentication schemes for the given domain.
-// If schemes are provided, a valid session public key is required to sign/verify
-// session JWTs. Returns an error if the key is missing or invalid.
-// Callers must not serve the domain if this returns an error, to avoid
-// exposing an unauthenticated service.
-func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter) error {
+// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required.
+// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list.
+func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool) error {
if len(schemes) == 0 {
mw.domainsMux.Lock()
defer mw.domainsMux.Unlock()
@@ -467,6 +652,7 @@ func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 st
AccountID: accountID,
ServiceID: serviceID,
IPRestrictions: ipRestrictions,
+ Private: private,
}
return nil
}
@@ -488,6 +674,7 @@ func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 st
AccountID: accountID,
ServiceID: serviceID,
IPRestrictions: ipRestrictions,
+ Private: private,
}
return nil
}
@@ -518,18 +705,25 @@ func (mw *Middleware) validateSessionToken(ctx context.Context, host, token stri
}).Debug("Session validation denied")
return &validationResult{
UserID: resp.UserId,
+ UserEmail: resp.GetUserEmail(),
Valid: false,
DeniedReason: resp.DeniedReason,
}, nil
}
- return &validationResult{UserID: resp.UserId, Valid: true}, nil
+ return &validationResult{
+ UserID: resp.UserId,
+ UserEmail: resp.GetUserEmail(),
+ Valid: true,
+ Groups: resp.GetPeerGroupIds(),
+ GroupNames: resp.GetPeerGroupNames(),
+ }, nil
}
- userID, _, err := auth.ValidateSessionJWT(token, host, publicKey)
+ userID, email, _, groups, groupNames, err := auth.ValidateSessionJWT(token, host, publicKey)
if err != nil {
return nil, err
}
- return &validationResult{UserID: userID, Valid: true}, nil
+ return &validationResult{UserID: userID, UserEmail: email, Valid: true, Groups: groups, GroupNames: groupNames}, nil
}
// stripSessionTokenParam returns the request URI with the session_token query
diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go
index 2c93d7912..c0ec5c94c 100644
--- a/proxy/internal/auth/middleware_test.go
+++ b/proxy/internal/auth/middleware_test.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/ed25519"
"crypto/rand"
+ "crypto/tls"
"encoding/base64"
"errors"
"net/http"
@@ -23,6 +24,7 @@ import (
"github.com/netbirdio/netbird/proxy/auth"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/proxy/internal/restrict"
+ "github.com/netbirdio/netbird/proxy/internal/types"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -62,7 +64,7 @@ func TestAddDomain_ValidKey(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil)
+ err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)
require.NoError(t, err)
mw.domainsMux.RLock()
@@ -79,7 +81,7 @@ func TestAddDomain_EmptyKey(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil)
+ err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid session public key size")
@@ -93,7 +95,7 @@ func TestAddDomain_InvalidBase64(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil)
+ err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "decode session public key")
@@ -108,7 +110,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
shortKey := base64.StdEncoding.EncodeToString([]byte("tooshort"))
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil)
+ err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid session public key size")
@@ -121,7 +123,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
- err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil)
+ err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false)
require.NoError(t, err, "domains with no auth schemes should not require a key")
mw.domainsMux.RLock()
@@ -137,8 +139,8 @@ func TestAddDomain_OverwritesPreviousConfig(t *testing.T) {
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil))
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil, false))
mw.domainsMux.RLock()
config := mw.domains["example.com"]
@@ -154,7 +156,7 @@ func TestRemoveDomain(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
mw.RemoveDomain("example.com")
@@ -178,7 +180,7 @@ func TestProtect_UnknownDomainPassesThrough(t *testing.T) {
func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
- require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -195,7 +197,7 @@ func TestProtect_UnauthenticatedRequestIsBlocked(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -216,7 +218,7 @@ func TestProtect_HostWithPortIsMatched(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -237,9 +239,9 @@ func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
- token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "example.com", auth.MethodPIN, time.Hour)
+ token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
require.NoError(t, err)
capturedData := proxy.NewCapturedData("")
@@ -262,15 +264,48 @@ func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) {
assert.Equal(t, "authenticated", rec.Body.String())
}
+// TestProtect_SessionCookieGroupsPropagate verifies the cookie path lifts the
+// JWT's groups claim into CapturedData so policy-aware middlewares can
+// authorise without an extra management round-trip.
+func TestProtect_SessionCookieGroupsPropagate(t *testing.T) {
+ mw := NewMiddleware(log.StandardLogger(), nil, nil)
+ kp := generateTestKeyPair(t)
+
+ scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
+
+ groups := []string{"engineering", "sre"}
+ token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, groups, nil, time.Hour)
+ require.NoError(t, err)
+
+ capturedData := proxy.NewCapturedData("")
+ handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ cd := proxy.CapturedDataFromContext(r.Context())
+ require.NotNil(t, cd, "captured data must be present in request context")
+ assert.Equal(t, "test-user", cd.GetUserID())
+ assert.Equal(t, groups, cd.GetUserGroups(), "JWT groups claim must propagate to CapturedData")
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData))
+ req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token})
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusOK, rec.Code, "request with valid groups-bearing cookie must succeed")
+ assert.Equal(t, groups, capturedData.GetUserGroups(), "CapturedData groups must be retained after handler completes")
+}
+
func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
// Sign a token that expired 1 second ago.
- token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "example.com", auth.MethodPIN, -time.Second)
+ token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, -time.Second)
require.NoError(t, err)
var backendCalled bool
@@ -293,10 +328,10 @@ func TestProtect_WrongDomainCookieIsRejected(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
// Token signed for a different domain audience.
- token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "other.com", auth.MethodPIN, time.Hour)
+ token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "other.com", auth.MethodPIN, nil, nil, time.Hour)
require.NoError(t, err)
var backendCalled bool
@@ -320,10 +355,10 @@ func TestProtect_WrongKeyCookieIsRejected(t *testing.T) {
kp2 := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
// Token signed with a different private key.
- token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "example.com", auth.MethodPIN, time.Hour)
+ token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
require.NoError(t, err)
var backendCalled bool
@@ -345,7 +380,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
- token, err := sessionkey.SignToken(kp.PrivateKey, "pin-user", "example.com", auth.MethodPIN, time.Hour)
+ token, err := sessionkey.SignToken(kp.PrivateKey, "pin-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
require.NoError(t, err)
scheme := &stubScheme{
@@ -357,7 +392,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) {
return "", "pin", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -410,7 +445,7 @@ func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) {
return "", "pin", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -427,7 +462,7 @@ func TestProtect_MultipleSchemes(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
- token, err := sessionkey.SignToken(kp.PrivateKey, "password-user", "example.com", auth.MethodPassword, time.Hour)
+ token, err := sessionkey.SignToken(kp.PrivateKey, "password-user", "", "example.com", auth.MethodPassword, nil, nil, time.Hour)
require.NoError(t, err)
// First scheme (PIN) always fails, second scheme (password) succeeds.
@@ -446,7 +481,7 @@ func TestProtect_MultipleSchemes(t *testing.T) {
return "", "password", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -476,7 +511,7 @@ func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) {
return "invalid-jwt-token", "", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -500,7 +535,7 @@ func TestAddDomain_RandomBytes32NotEd25519(t *testing.T) {
key := base64.StdEncoding.EncodeToString(randomBytes)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil)
+ err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil, false)
require.NoError(t, err, "any 32-byte key should be accepted at registration time")
}
@@ -509,10 +544,10 @@ func TestAddDomain_InvalidKeyDoesNotCorruptExistingConfig(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
// Attempt to overwrite with an invalid key.
- err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil)
+ err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil, false)
require.Error(t, err)
// The original valid config should still be intact.
@@ -536,7 +571,7 @@ func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) {
return "", "pin", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -563,7 +598,7 @@ func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) {
return "", "password", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -590,7 +625,7 @@ func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) {
return "", "pin", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -678,7 +713,7 @@ func TestCheckIPRestrictions_UnparseableAddress(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
- restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}))
+ restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -714,7 +749,7 @@ func TestCheckIPRestrictions_UsesCapturedDataClientIP(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
- restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}))
+ restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -755,7 +790,7 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
- restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}))
+ restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -770,6 +805,69 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
assert.Equal(t, http.StatusForbidden, rr.Code, "country restrictions with nil geo must deny")
}
+// TestCheckIPRestrictions_OverlayOriginSkipsCountryRules covers the
+// inbound (WG) listener path: requests stamped with WithOverlayOrigin
+// must skip country lookups, even when no geo database is configured.
+// Without this short-circuit the inbound flow would fail-closed for
+// every overlay request whenever country rules are configured.
+func TestCheckIPRestrictions_OverlayOriginSkipsCountryRules(t *testing.T) {
+ mw := NewMiddleware(log.StandardLogger(), nil, nil)
+
+ err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
+ restrict.ParseFilter(restrict.FilterConfig{
+ AllowedCIDRs: []string{"100.64.0.0/10"},
+ AllowedCountries: []string{"US"},
+ }), false)
+ require.NoError(t, err)
+
+ handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req.RemoteAddr = "100.64.5.6:5000"
+ req.Host = "example.com"
+ req = req.WithContext(types.WithOverlayOrigin(req.Context()))
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusOK, rr.Code,
+ "overlay-origin requests must not be denied by country rules they would fail without geo data")
+
+ // Sanity check: the same filter without the overlay flag denies (no geo,
+ // country allowlist active → DenyGeoUnavailable).
+ req2 := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req2.RemoteAddr = "100.64.5.6:5000"
+ req2.Host = "example.com"
+ rr2 := httptest.NewRecorder()
+ handler.ServeHTTP(rr2, req2)
+ assert.Equal(t, http.StatusForbidden, rr2.Code,
+ "WAN-origin requests must still hit the full Check path and be denied without geo data")
+}
+
+// TestCheckIPRestrictions_OverlayOriginRespectsCIDR confirms CIDR
+// rules still apply on the overlay path so operators retain a way to
+// scope private services to specific peer subnets.
+func TestCheckIPRestrictions_OverlayOriginRespectsCIDR(t *testing.T) {
+ mw := NewMiddleware(log.StandardLogger(), nil, nil)
+
+ err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
+ restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}}), false)
+ require.NoError(t, err)
+
+ handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req.RemoteAddr = "100.65.5.6:5000" // outside 100.64.0.0/16
+ req.Host = "example.com"
+ req = req.WithContext(types.WithOverlayOrigin(req.Context()))
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ assert.Equal(t, http.StatusForbidden, rr.Code,
+ "CIDR rules must still apply on the overlay path")
+}
+
func TestProtect_OIDCOnlyRedirectsDirectly(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
@@ -781,11 +879,12 @@ func TestProtect_OIDCOnlyRedirectsDirectly(t *testing.T) {
return "", oidcURL, nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
- req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil)
+ req.TLS = &tls.ConnectionState{}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -809,11 +908,12 @@ func TestProtect_OIDCWithOtherMethodShowsLoginPage(t *testing.T) {
return "", "pin", nil
},
}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
- req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil)
+ req.TLS = &tls.ConnectionState{}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -834,7 +934,7 @@ func (m *mockAuthenticator) Authenticate(ctx context.Context, in *proto.Authenti
// returns a signed session token when the expected header value is provided.
func newHeaderSchemeWithToken(t *testing.T, kp *sessionkey.KeyPair, headerName, expectedValue string) Header {
t.Helper()
- token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "example.com", auth.MethodHeader, time.Hour)
+ token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "", "example.com", auth.MethodHeader, nil, nil, time.Hour)
require.NoError(t, err)
mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
@@ -852,7 +952,7 @@ func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
kp := generateTestKeyPair(t)
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
- require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
var backendCalled bool
capturedData := proxy.NewCapturedData("")
@@ -895,7 +995,7 @@ func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) {
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
// Also add a PIN scheme so we can verify fallthrough behavior.
pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
- require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -915,7 +1015,7 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
return &proto.AuthenticateResponse{Success: false}, nil
}}
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
- require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -938,7 +1038,7 @@ func TestProtect_HeaderAuth_InfraErrorReturns502(t *testing.T) {
return nil, errors.New("gRPC unavailable")
}}
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
- require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -955,7 +1055,7 @@ func TestProtect_HeaderAuth_SubsequentRequestUsesSessionCookie(t *testing.T) {
kp := generateTestKeyPair(t)
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
- require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -1006,7 +1106,7 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
ha := req.GetHeaderAuth()
if ha != nil && accepted[ha.GetHeaderValue()] {
- token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "example.com", auth.MethodHeader, time.Hour)
+ token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "", "example.com", auth.MethodHeader, nil, nil, time.Hour)
require.NoError(t, err)
return &proto.AuthenticateResponse{Success: true, SessionToken: token}, nil
}
@@ -1015,7 +1115,7 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
// Single Header scheme (as if one entry existed), but the mock checks both values.
hdr := NewHeader(mock, "svc1", "acc1", "Authorization")
- require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil))
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
var backendCalled bool
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -1059,3 +1159,161 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
assert.False(t, backendCalled, "unknown token should be rejected")
})
}
+
+// TestProtect_OIDCOnPlainHTTP_BlockedWith400 verifies that when an OIDC
+// scheme is configured and the request arrived without TLS, the middleware
+// short-circuits with a 400 instead of dispatching to the IdP redirect.
+func TestProtect_OIDCOnPlainHTTP_BlockedWith400(t *testing.T) {
+ mw := NewMiddleware(log.StandardLogger(), nil, nil)
+ kp := generateTestKeyPair(t)
+
+ scheme := &stubScheme{
+ method: auth.MethodOIDC,
+ authFn: func(_ *http.Request) (string, string, error) {
+ return "", "https://idp.example.com/authorize", nil
+ },
+ }
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
+
+ handler := mw.Protect(newPassthroughHandler())
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusBadRequest, rec.Code, "OIDC over plain HTTP should be rejected")
+ assert.Contains(t, rec.Body.String(), "OIDC requires TLS", "response body should explain the rejection")
+}
+
+// TestProtect_OIDCOverTLS_NotBlocked confirms the same configuration works
+// over TLS — the block only fires on plain HTTP.
+func TestProtect_OIDCOverTLS_NotBlocked(t *testing.T) {
+ mw := NewMiddleware(log.StandardLogger(), nil, nil)
+ kp := generateTestKeyPair(t)
+
+ scheme := &stubScheme{
+ method: auth.MethodOIDC,
+ authFn: func(_ *http.Request) (string, string, error) {
+ return "", "https://idp.example.com/authorize", nil
+ },
+ }
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
+
+ handler := mw.Protect(newPassthroughHandler())
+
+ req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil)
+ req.TLS = &tls.ConnectionState{}
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusFound, rec.Code, "OIDC over TLS should redirect to IdP")
+}
+
+// TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked confirms that the OIDC
+// block only fires when an OIDC scheme is configured. PIN-only domains
+// pass through normally on plain HTTP.
+func TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked(t *testing.T) {
+ mw := NewMiddleware(log.StandardLogger(), nil, nil)
+ kp := generateTestKeyPair(t)
+
+ scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
+
+ handler := mw.Protect(newPassthroughHandler())
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "PIN-only domain should serve the login page on plain HTTP")
+}
+
+// stubTunnelValidator records ValidateTunnelPeer calls so a test can
+// assert whether the fast-path reached management.
+type stubTunnelValidator struct {
+ called bool
+ resp *proto.ValidateTunnelPeerResponse
+}
+
+func (s *stubTunnelValidator) ValidateSession(context.Context, *proto.ValidateSessionRequest, ...grpc.CallOption) (*proto.ValidateSessionResponse, error) {
+ return nil, errors.New("not used in this test")
+}
+
+func (s *stubTunnelValidator) ValidateTunnelPeer(context.Context, *proto.ValidateTunnelPeerRequest, ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) {
+ s.called = true
+ return s.resp, nil
+}
+
+// TestProtect_TunnelPeerFastPath_RequiresInboundMarker guards the
+// anti-spoof gate: a request with an RFC1918 source IP arriving on the
+// public listener (no TunnelLookupFromContext attached) must not be
+// allowed to take the tunnel-peer fast-path. Without this gate a public
+// client whose source IP happens to fall inside an RFC1918 range could
+// bypass the configured auth scheme by colliding with a known tunnel
+// IP.
+func TestProtect_TunnelPeerFastPath_RequiresInboundMarker(t *testing.T) {
+ validator := &stubTunnelValidator{
+ resp: &proto.ValidateTunnelPeerResponse{
+ Valid: true,
+ SessionToken: "should-not-be-used",
+ UserId: "user-1",
+ },
+ }
+ mw := NewMiddleware(log.StandardLogger(), validator, nil)
+ kp := generateTestKeyPair(t)
+
+ scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
+
+ handler := mw.Protect(newPassthroughHandler())
+
+ // Request from an RFC1918 source IP on the public listener — no
+ // TunnelLookupFromContext attached. The fast-path must reject this
+ // and fall through to the PIN scheme (which renders 401 on plain
+ // HTTP for a non-authenticated request).
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req.RemoteAddr = "100.64.0.5:5000"
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.False(t, validator.called,
+ "ValidateTunnelPeer must not be invoked when the request lacks the inbound TunnelLookup marker")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code,
+ "without the inbound marker the request must fall through to the operator auth scheme")
+}
+
+// TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker verifies
+// the positive side: a request marked as overlay-origin (carrying the
+// TunnelLookup context value) and matching a tunnel-IP range does take
+// the fast-path and reach management.
+func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) {
+ validator := &stubTunnelValidator{
+ resp: &proto.ValidateTunnelPeerResponse{
+ Valid: true,
+ SessionToken: "tunnel-session-token",
+ UserId: "user-1",
+ },
+ }
+ mw := NewMiddleware(log.StandardLogger(), validator, nil)
+ kp := generateTestKeyPair(t)
+
+ scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
+ require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
+
+ handler := mw.Protect(newPassthroughHandler())
+
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
+ req.RemoteAddr = "100.64.0.5:5000"
+ req = req.WithContext(WithTunnelLookup(req.Context(), lookup))
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.True(t, validator.called,
+ "ValidateTunnelPeer must run when the request carries the inbound TunnelLookup marker")
+ assert.Equal(t, http.StatusOK, rec.Code,
+ "a successful tunnel-peer validation must forward to the next handler")
+}
diff --git a/proxy/internal/auth/tunnel_cache.go b/proxy/internal/auth/tunnel_cache.go
new file mode 100644
index 000000000..10b671d82
--- /dev/null
+++ b/proxy/internal/auth/tunnel_cache.go
@@ -0,0 +1,171 @@
+package auth
+
+import (
+ "context"
+ "net/netip"
+ "sync"
+ "time"
+
+ "golang.org/x/sync/singleflight"
+
+ "github.com/netbirdio/netbird/proxy/internal/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// tunnelCacheTTL caps how long a positive ValidateTunnelPeer result is
+// reused before re-fetching from management. 5 minutes balances freshness
+// against management load on busy mesh networks.
+const tunnelCacheTTL = 300 * time.Second
+
+// tunnelCachePerAccount caps the number of cached identities per account.
+// Bounded eviction avoids memory growth in pathological cases (huge peer
+// roster, brief request bursts) while staying generous for normal use.
+const tunnelCachePerAccount = 1024
+
+// tunnelCacheKey identifies a cached entry by tunnel IP and originating
+// account. Domain is part of the value, not the key, because the
+// management response is per (account, IP) — domain only gates whether a
+// re-fetch is needed if the operator is accessing a different service.
+type tunnelCacheKey struct {
+ accountID types.AccountID
+ tunnelIP netip.Addr
+ domain string
+}
+
+// tunnelCacheEntry stores a positive validation response with the time it
+// was minted. Entries past tunnelCacheTTL are treated as misses.
+type tunnelCacheEntry struct {
+ resp *proto.ValidateTunnelPeerResponse
+ cachedAt time.Time
+}
+
+// tunnelValidationCache memoizes ValidateTunnelPeer responses keyed by
+// (accountID, tunnelIP, domain). Only successful, valid responses are
+// cached — denials skip the cache so policy changes apply immediately.
+// Single-flight de-duplicates concurrent fetches for the same key so a
+// burst of cold requests collapses into a single RPC.
+type tunnelValidationCache struct {
+ mu sync.Mutex
+ entries map[types.AccountID]*accountBucket
+ flight singleflight.Group
+ ttl time.Duration
+ maxSize int
+ now func() time.Time
+}
+
+// accountBucket holds the cached entries for a single account, with a
+// FIFO eviction queue used when the bucket exceeds maxSize.
+type accountBucket struct {
+ items map[tunnelCacheKey]tunnelCacheEntry
+ order []tunnelCacheKey
+}
+
+// newTunnelValidationCache constructs a cache with default TTL and bounds.
+func newTunnelValidationCache() *tunnelValidationCache {
+ return &tunnelValidationCache{
+ entries: make(map[types.AccountID]*accountBucket),
+ ttl: tunnelCacheTTL,
+ maxSize: tunnelCachePerAccount,
+ now: time.Now,
+ }
+}
+
+// get returns a cached response for the key, or nil when missing or
+// expired. Expired entries are evicted lazily on read.
+func (c *tunnelValidationCache) get(key tunnelCacheKey) *proto.ValidateTunnelPeerResponse {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ bucket, ok := c.entries[key.accountID]
+ if !ok {
+ return nil
+ }
+ entry, ok := bucket.items[key]
+ if !ok {
+ return nil
+ }
+ if c.now().Sub(entry.cachedAt) > c.ttl {
+ delete(bucket.items, key)
+ bucket.order = removeKey(bucket.order, key)
+ return nil
+ }
+ return entry.resp
+}
+
+// put records a positive response under the key. Evicts the oldest entry
+// in the account's bucket when the bound is exceeded.
+func (c *tunnelValidationCache) put(key tunnelCacheKey, resp *proto.ValidateTunnelPeerResponse) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ bucket, ok := c.entries[key.accountID]
+ if !ok {
+ bucket = &accountBucket{items: make(map[tunnelCacheKey]tunnelCacheEntry)}
+ c.entries[key.accountID] = bucket
+ }
+ if _, exists := bucket.items[key]; !exists {
+ bucket.order = append(bucket.order, key)
+ }
+ bucket.items[key] = tunnelCacheEntry{resp: resp, cachedAt: c.now()}
+
+ for len(bucket.order) > c.maxSize {
+ oldest := bucket.order[0]
+ bucket.order = bucket.order[1:]
+ delete(bucket.items, oldest)
+ }
+}
+
+// removeKey drops the first occurrence of needle from order. The cache
+// uses small slices so a linear scan is cheaper than a map+slice combo.
+func removeKey(order []tunnelCacheKey, needle tunnelCacheKey) []tunnelCacheKey {
+ for i, k := range order {
+ if k == needle {
+ return append(order[:i], order[i+1:]...)
+ }
+ }
+ return order
+}
+
+// flightKey turns a cache key into a single-flight string. AccountID and
+// IP isolation by themselves are insufficient because different domains
+// for the same peer/account may have different group access.
+func flightKey(key tunnelCacheKey) string {
+ return string(key.accountID) + "|" + key.tunnelIP.String() + "|" + key.domain
+}
+
+// validateTunnelPeerFn is the RPC entry point the cache wraps. It matches
+// the SessionValidator.ValidateTunnelPeer signature without exposing the
+// gRPC option variadic, since callers don't need it on the cache hot path.
+type validateTunnelPeerFn func(ctx context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error)
+
+// fetch returns a cached response when present, otherwise calls validate
+// under single-flight and caches the result. Denied responses pass
+// through but are not cached so policy changes apply immediately.
+func (c *tunnelValidationCache) fetch(ctx context.Context, key tunnelCacheKey, validate validateTunnelPeerFn) (*proto.ValidateTunnelPeerResponse, bool, error) {
+ if resp := c.get(key); resp != nil {
+ return resp, true, nil
+ }
+
+ flight := flightKey(key)
+ res, err, _ := c.flight.Do(flight, func() (any, error) {
+ if cached := c.get(key); cached != nil {
+ return cached, nil
+ }
+ resp, err := validate(ctx, &proto.ValidateTunnelPeerRequest{
+ TunnelIp: key.tunnelIP.String(),
+ Domain: key.domain,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if resp.GetValid() && resp.GetSessionToken() != "" {
+ c.put(key, resp)
+ }
+ return resp, nil
+ })
+ if err != nil {
+ return nil, false, err
+ }
+ resp, _ := res.(*proto.ValidateTunnelPeerResponse)
+ return resp, false, nil
+}
diff --git a/proxy/internal/auth/tunnel_cache_test.go b/proxy/internal/auth/tunnel_cache_test.go
new file mode 100644
index 000000000..1a63dc107
--- /dev/null
+++ b/proxy/internal/auth/tunnel_cache_test.go
@@ -0,0 +1,171 @@
+package auth
+
+import (
+ "context"
+ "net/netip"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/proxy/internal/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+func newTestKey(account types.AccountID, ip string, domain string) tunnelCacheKey {
+ return tunnelCacheKey{
+ accountID: account,
+ tunnelIP: netip.MustParseAddr(ip),
+ domain: domain,
+ }
+}
+
+func TestTunnelCache_HitSkipsRPC(t *testing.T) {
+ cache := newTunnelValidationCache()
+ key := newTestKey("acct-1", "100.64.0.10", "svc.example")
+
+ var calls int32
+ validate := func(_ context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ atomic.AddInt32(&calls, 1)
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}, nil
+ }
+
+ resp, fromCache, err := cache.fetch(context.Background(), key, validate)
+ require.NoError(t, err)
+ require.NotNil(t, resp, "first fetch returns RPC response")
+ assert.False(t, fromCache, "first fetch must not be cached")
+
+ resp2, fromCache2, err := cache.fetch(context.Background(), key, validate)
+ require.NoError(t, err)
+ require.NotNil(t, resp2, "second fetch returns cached response")
+ assert.True(t, fromCache2, "second fetch must be served from cache")
+ assert.Equal(t, "user-1", resp2.GetUserId(), "cached response should preserve user identity")
+ assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "validate should run exactly once with one cache hit")
+}
+
+func TestTunnelCache_ExpiredEntryRefetches(t *testing.T) {
+ cache := newTunnelValidationCache()
+ clock := time.Now()
+ cache.now = func() time.Time { return clock }
+
+ key := newTestKey("acct-1", "100.64.0.10", "svc.example")
+ var calls int32
+ validate := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ atomic.AddInt32(&calls, 1)
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok"}, nil
+ }
+
+ _, _, err := cache.fetch(context.Background(), key, validate)
+ require.NoError(t, err)
+ assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "first fetch issues one RPC")
+
+ clock = clock.Add(tunnelCacheTTL + time.Second)
+
+ _, fromCache, err := cache.fetch(context.Background(), key, validate)
+ require.NoError(t, err)
+ assert.False(t, fromCache, "expired entry must miss the cache")
+ assert.Equal(t, int32(2), atomic.LoadInt32(&calls), "expired entry forces a re-fetch")
+}
+
+func TestTunnelCache_DeniedResponseNotCached(t *testing.T) {
+ cache := newTunnelValidationCache()
+ key := newTestKey("acct-1", "100.64.0.10", "svc.example")
+
+ var calls int32
+ validate := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ atomic.AddInt32(&calls, 1)
+ return &proto.ValidateTunnelPeerResponse{Valid: false, DeniedReason: "not_in_group"}, nil
+ }
+
+ for i := 0; i < 3; i++ {
+ _, _, err := cache.fetch(context.Background(), key, validate)
+ require.NoError(t, err, "fetch must not error on denied response")
+ }
+ assert.Equal(t, int32(3), atomic.LoadInt32(&calls), "denied responses bypass the cache so policy changes apply immediately")
+}
+
+func TestTunnelCache_ConcurrentColdHitsCoalesce(t *testing.T) {
+ cache := newTunnelValidationCache()
+ key := newTestKey("acct-1", "100.64.0.10", "svc.example")
+
+ gate := make(chan struct{})
+ var calls int32
+ validate := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ atomic.AddInt32(&calls, 1)
+ <-gate
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok"}, nil
+ }
+
+ const workers = 16
+ var wg sync.WaitGroup
+ wg.Add(workers)
+ results := make([]bool, workers)
+ for i := 0; i < workers; i++ {
+ go func(idx int) {
+ defer wg.Done()
+ resp, _, err := cache.fetch(context.Background(), key, validate)
+ results[idx] = err == nil && resp.GetValid()
+ }(i)
+ }
+
+ time.Sleep(20 * time.Millisecond)
+ close(gate)
+ wg.Wait()
+
+ for i, ok := range results {
+ assert.Truef(t, ok, "worker %d should observe a successful response", i)
+ }
+ assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "single-flight must collapse concurrent cold fetches into one RPC")
+}
+
+func TestTunnelCache_PerAccountIsolation(t *testing.T) {
+ cache := newTunnelValidationCache()
+ keyA := newTestKey("acct-a", "100.64.0.10", "svc.example")
+ keyB := newTestKey("acct-b", "100.64.0.10", "svc.example")
+
+ var callsA, callsB int32
+ validateA := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ atomic.AddInt32(&callsA, 1)
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok-a", UserId: "user-a"}, nil
+ }
+ validateB := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ atomic.AddInt32(&callsB, 1)
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok-b", UserId: "user-b"}, nil
+ }
+
+ respA, _, err := cache.fetch(context.Background(), keyA, validateA)
+ require.NoError(t, err)
+ respB, _, err := cache.fetch(context.Background(), keyB, validateB)
+ require.NoError(t, err)
+
+ assert.Equal(t, "user-a", respA.GetUserId(), "account A response should belong to user-a")
+ assert.Equal(t, "user-b", respB.GetUserId(), "account B response must not be served from account A's cache")
+ assert.Equal(t, int32(1), atomic.LoadInt32(&callsA), "validateA called exactly once")
+ assert.Equal(t, int32(1), atomic.LoadInt32(&callsB), "validateB called exactly once")
+}
+
+func TestTunnelCache_BoundedSizeEvictsOldest(t *testing.T) {
+ cache := newTunnelValidationCache()
+ cache.maxSize = 2
+
+ validate := func(_ context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) {
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok-" + req.GetTunnelIp()}, nil
+ }
+
+ keys := []tunnelCacheKey{
+ newTestKey("acct-1", "100.64.0.10", "svc"),
+ newTestKey("acct-1", "100.64.0.11", "svc"),
+ newTestKey("acct-1", "100.64.0.12", "svc"),
+ }
+ for _, k := range keys {
+ _, _, err := cache.fetch(context.Background(), k, validate)
+ require.NoError(t, err)
+ }
+
+ assert.Nil(t, cache.get(keys[0]), "oldest key should be evicted past maxSize")
+ assert.NotNil(t, cache.get(keys[1]), "second-newest must remain cached")
+ assert.NotNil(t, cache.get(keys[2]), "newest must remain cached")
+}
diff --git a/proxy/internal/auth/tunnel_lookup_test.go b/proxy/internal/auth/tunnel_lookup_test.go
new file mode 100644
index 000000000..808aa8b41
--- /dev/null
+++ b/proxy/internal/auth/tunnel_lookup_test.go
@@ -0,0 +1,355 @@
+package auth
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "sync/atomic"
+ "testing"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+
+ "github.com/netbirdio/netbird/proxy/internal/proxy"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// stubSessionValidator records ValidateTunnelPeer calls and returns the
+// pre-canned response. Counts let tests assert RPC traffic.
+type stubSessionValidator struct {
+ respFn func(req *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse
+ respErr error
+ tunnelCalls atomic.Int32
+}
+
+func (s *stubSessionValidator) ValidateSession(_ context.Context, _ *proto.ValidateSessionRequest, _ ...grpc.CallOption) (*proto.ValidateSessionResponse, error) {
+ return &proto.ValidateSessionResponse{Valid: false}, nil
+}
+
+func (s *stubSessionValidator) ValidateTunnelPeer(_ context.Context, in *proto.ValidateTunnelPeerRequest, _ ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) {
+ s.tunnelCalls.Add(1)
+ if s.respErr != nil {
+ return nil, s.respErr
+ }
+ if s.respFn != nil {
+ return s.respFn(in), nil
+ }
+ return &proto.ValidateTunnelPeerResponse{Valid: false}, nil
+}
+
+func newTunnelMiddleware(t *testing.T, validator SessionValidator) *Middleware {
+ t.Helper()
+ mw := NewMiddleware(log.New(), validator, nil)
+ require.NoError(t, mw.AddDomain("svc.example", nil, "", 0, "acct-1", "svc-1", nil, false))
+ return mw
+}
+
+func newTunnelRequest(remoteAddr string) (*httptest.ResponseRecorder, *http.Request) {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "https://svc.example/", nil)
+ r.Host = "svc.example"
+ r.RemoteAddr = remoteAddr
+ return w, r
+}
+
+// TestForwardWithTunnelPeer_LocalLookupUnknownIPDeniesFast verifies the
+// short-circuit: a tunnel IP not in the account's roster never reaches
+// management's ValidateTunnelPeer.
+func TestForwardWithTunnelPeer_LocalLookupUnknownIPDeniesFast(t *testing.T) {
+ validator := &stubSessionValidator{}
+ mw := newTunnelMiddleware(t, validator)
+
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, false
+ })
+
+ w, r := newTunnelRequest("100.64.0.99:55555")
+ r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
+
+ called := false
+ next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
+
+ config, _ := mw.getDomainConfig("svc.example")
+ handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
+
+ assert.False(t, handled, "unknown peer must fall through, not forward")
+ assert.False(t, called, "next handler must not run for unknown peer")
+ assert.Equal(t, int32(0), validator.tunnelCalls.Load(), "ValidateTunnelPeer must be skipped on local-lookup miss")
+}
+
+// TestForwardWithTunnelPeer_GroupsPropagateToCapturedData verifies the proxy
+// surfaces the calling peer's group memberships from ValidateTunnelPeerResponse
+// onto CapturedData so policy-aware middlewares can authorise without an
+// extra management round-trip.
+func TestForwardWithTunnelPeer_GroupsPropagateToCapturedData(t *testing.T) {
+ groups := []string{"engineering", "sre"}
+ validator := &stubSessionValidator{
+ respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: true,
+ SessionToken: "tok",
+ UserId: "user-1",
+ PeerGroupIds: groups,
+ }
+ },
+ }
+ mw := newTunnelMiddleware(t, validator)
+
+ w, r := newTunnelRequest("100.64.0.10:55555")
+ cd := proxy.NewCapturedData("")
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+ r = r.WithContext(proxy.WithCapturedData(WithTunnelLookup(r.Context(), lookup), cd))
+
+ called := false
+ next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
+
+ config, _ := mw.getDomainConfig("svc.example")
+ handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
+
+ require.True(t, handled, "valid tunnel-peer response must forward")
+ require.True(t, called, "next handler must run")
+ assert.Equal(t, "user-1", cd.GetUserID(), "user id must propagate from tunnel-peer response")
+ assert.Equal(t, groups, cd.GetUserGroups(), "peer group IDs must propagate from tunnel-peer response")
+}
+
+// TestForwardWithTunnelPeer_LocalLookupKnownPeerStillRPCs verifies that a
+// known tunnel IP still triggers ValidateTunnelPeer for the user-identity
+// tail (UserID + group access). Phase 3 only short-circuits the deny path.
+func TestForwardWithTunnelPeer_LocalLookupKnownPeerStillRPCs(t *testing.T) {
+ validator := &stubSessionValidator{
+ respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}
+ },
+ }
+ mw := newTunnelMiddleware(t, validator)
+
+ knownIP := netip.MustParseAddr("100.64.0.10")
+ lookup := TunnelLookupFunc(func(ip netip.Addr) (PeerIdentity, bool) {
+ if ip == knownIP {
+ return PeerIdentity{PubKey: "pk", TunnelIP: ip, FQDN: "peer.netbird.cloud"}, true
+ }
+ return PeerIdentity{}, false
+ })
+
+ w, r := newTunnelRequest(knownIP.String() + ":55555")
+ r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
+
+ called := false
+ next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
+
+ config, _ := mw.getDomainConfig("svc.example")
+ handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
+
+ assert.True(t, handled, "known peer with valid RPC response must forward")
+ assert.True(t, called, "next handler must run on success")
+ assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "RPC must run for the user-identity tail when local lookup confirms the peer")
+}
+
+// TestForwardWithTunnelPeer_NoLookupRefusesFastPath guards the
+// anti-spoof gate: requests that didn't arrive on the per-account
+// inbound listener (no TunnelLookup attached) must never reach
+// management's ValidateTunnelPeer, even when the source IP looks like
+// a tunnel address. A colliding RFC1918 / CGNAT source on the public
+// listener would otherwise impersonate a mesh peer.
+func TestForwardWithTunnelPeer_NoLookupRefusesFastPath(t *testing.T) {
+ validator := &stubSessionValidator{
+ respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}
+ },
+ }
+ mw := newTunnelMiddleware(t, validator)
+
+ w, r := newTunnelRequest("100.64.0.10:55555")
+ called := false
+ next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
+
+ config, _ := mw.getDomainConfig("svc.example")
+ handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
+
+ assert.False(t, handled, "fast-path must refuse without the inbound marker")
+ assert.False(t, called, "next handler must not run")
+ assert.Equal(t, int32(0), validator.tunnelCalls.Load(), "ValidateTunnelPeer must not be invoked without the inbound marker")
+}
+
+// TestForwardWithTunnelPeer_RPCErrorFallsThrough validates that an RPC
+// failure still falls through to the next scheme (no false positive).
+func TestForwardWithTunnelPeer_RPCErrorFallsThrough(t *testing.T) {
+ validator := &stubSessionValidator{respErr: errors.New("management down")}
+ mw := newTunnelMiddleware(t, validator)
+
+ knownIP := netip.MustParseAddr("100.64.0.10")
+ lookup := TunnelLookupFunc(func(ip netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{TunnelIP: ip}, true
+ })
+
+ w, r := newTunnelRequest(knownIP.String() + ":55555")
+ r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
+
+ config, _ := mw.getDomainConfig("svc.example")
+ handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+
+ assert.False(t, handled, "RPC error must let the caller try other schemes")
+ assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "RPC was attempted exactly once")
+}
+
+// TestForwardWithTunnelPeer_CacheReusesPositiveResponse confirms the
+// (account, IP, domain) cache prevents repeated RPCs for the same peer.
+func TestForwardWithTunnelPeer_CacheReusesPositiveResponse(t *testing.T) {
+ validator := &stubSessionValidator{
+ respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}
+ },
+ }
+ mw := newTunnelMiddleware(t, validator)
+
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+
+ for i := 0; i < 4; i++ {
+ w, r := newTunnelRequest("100.64.0.10:55555")
+ r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
+ next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
+ config, _ := mw.getDomainConfig("svc.example")
+ handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next)
+ require.True(t, handled, "iteration %d should forward", i)
+ }
+
+ assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "subsequent forwards must hit the cache, not management")
+}
+
+// TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey ensures cache keys
+// honour account scoping — same tunnel IP on different accounts must not
+// collide.
+func TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey(t *testing.T) {
+ validator := &stubSessionValidator{
+ respFn: func(req *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user"}
+ },
+ }
+ mw := NewMiddleware(log.New(), validator, nil)
+
+ require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false))
+ require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false))
+
+ // The fast-path requires the inbound-listener marker on the context.
+ // The peerstore lookup itself is account-agnostic at this level
+ // (one TunnelLookupFunc per account is attached by inbound.go); a
+ // trivial "always hit" lookup is enough to exercise the cache-key
+ // branch this test covers.
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+
+ for _, host := range []string{"svc-a.example", "svc-b.example"} {
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "https://"+host+"/", nil)
+ r.Host = host
+ r.RemoteAddr = "100.64.0.10:55555"
+ r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
+ config, _ := mw.getDomainConfig(host)
+ handled := mw.forwardWithTunnelPeer(w, r, host, config, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ require.True(t, handled, "host %s should forward", host)
+ }
+
+ assert.Equal(t, int32(2), validator.tunnelCalls.Load(), "cache must not collide across accounts even when tunnel IPs match")
+}
+
+// TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache
+// guarantees that the deny-fast path leaves the cache untouched, so a
+// subsequent request from the same IP after the peerstore catches up
+// goes through the normal RPC flow.
+func TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache(t *testing.T) {
+ validator := &stubSessionValidator{
+ respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
+ return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok"}
+ },
+ }
+ mw := newTunnelMiddleware(t, validator)
+
+ knownIP := netip.MustParseAddr("100.64.0.10")
+ known := false
+ lookup := TunnelLookupFunc(func(ip netip.Addr) (PeerIdentity, bool) {
+ if known && ip == knownIP {
+ return PeerIdentity{TunnelIP: ip}, true
+ }
+ return PeerIdentity{}, false
+ })
+
+ doRequest := func() bool {
+ w, r := newTunnelRequest(knownIP.String() + ":55555")
+ r = r.WithContext(WithTunnelLookup(r.Context(), lookup))
+ config, _ := mw.getDomainConfig("svc.example")
+ return mw.forwardWithTunnelPeer(w, r, "svc.example", config, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ }
+
+ require.False(t, doRequest(), "first request must short-circuit")
+ require.Equal(t, int32(0), validator.tunnelCalls.Load(), "short-circuit must not populate the cache")
+
+ known = true
+ require.True(t, doRequest(), "second request with peer in roster must forward via RPC")
+ assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "RPC runs once after peerstore catches up")
+}
+
+func TestPrivateService_FailsClosedOnTunnelPeerFailure(t *testing.T) {
+ mw := NewMiddleware(log.New(), nil, nil)
+ require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
+
+ called := false
+ handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "https://private.svc/", nil)
+ req.Host = "private.svc"
+ req.RemoteAddr = "100.64.0.10:55555"
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.False(t, called)
+}
+
+func TestPrivateService_ForwardsOnTunnelPeerSuccess(t *testing.T) {
+ validator := &stubSessionValidator{
+ respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse {
+ return &proto.ValidateTunnelPeerResponse{
+ Valid: true,
+ SessionToken: "tok",
+ UserId: "user-1",
+ }
+ },
+ }
+ mw := NewMiddleware(log.New(), validator, nil)
+ require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
+
+ called := false
+ handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ // Per-account inbound listener attaches WithTunnelLookup; without it
+ // forwardWithTunnelPeer refuses to take the fast-path. Mirror the
+ // real flow so this test exercises the post-gating success branch.
+ lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) {
+ return PeerIdentity{}, true
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "https://private.svc/", nil)
+ req.Host = "private.svc"
+ req.RemoteAddr = "100.64.0.10:55555"
+ req = req.WithContext(WithTunnelLookup(req.Context(), lookup))
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.True(t, called)
+}
diff --git a/proxy/internal/debug/client.go b/proxy/internal/debug/client.go
index 09c25afb2..77772637c 100644
--- a/proxy/internal/debug/client.go
+++ b/proxy/internal/debug/client.go
@@ -11,7 +11,6 @@ import (
"net/url"
"strings"
"time"
-
)
// StatusFilters contains filter options for status queries.
@@ -160,6 +159,49 @@ func (c *Client) printClients(data map[string]any) {
for _, item := range clients {
c.printClientRow(item)
}
+
+ c.printInboundListeners(clients)
+}
+
+func (c *Client) printInboundListeners(clients []any) {
+ type row struct {
+ accountID string
+ tunnelIP string
+ httpsPort int
+ httpPort int
+ }
+ var rows []row
+ for _, item := range clients {
+ client, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ inbound, ok := client["inbound_listener"].(map[string]any)
+ if !ok {
+ continue
+ }
+ tunnelIP, _ := inbound["tunnel_ip"].(string)
+ httpsPort, _ := inbound["https_port"].(float64)
+ httpPort, _ := inbound["http_port"].(float64)
+ accountID, _ := client["account_id"].(string)
+ rows = append(rows, row{
+ accountID: accountID,
+ tunnelIP: tunnelIP,
+ httpsPort: int(httpsPort),
+ httpPort: int(httpPort),
+ })
+ }
+ if len(rows) == 0 {
+ return
+ }
+
+ _, _ = fmt.Fprintln(c.out)
+ _, _ = fmt.Fprintln(c.out, "Inbound listeners (per-account):")
+ _, _ = fmt.Fprintf(c.out, " %-38s %-20s %-7s %s\n", "ACCOUNT ID", "TUNNEL IP", "HTTPS", "HTTP")
+ _, _ = fmt.Fprintln(c.out, " "+strings.Repeat("-", 78))
+ for _, r := range rows {
+ _, _ = fmt.Fprintf(c.out, " %-38s %-20s %-7d %d\n", r.accountID, r.tunnelIP, r.httpsPort, r.httpPort)
+ }
}
func (c *Client) printClientRow(item any) {
@@ -219,7 +261,14 @@ func (c *Client) ClientStatus(ctx context.Context, accountID string, filters Sta
}
func (c *Client) printClientStatus(data map[string]any) {
- _, _ = fmt.Fprintf(c.out, "Account: %v\n\n", data["account_id"])
+ _, _ = fmt.Fprintf(c.out, "Account: %v\n", data["account_id"])
+ if inbound, ok := data["inbound_listener"].(map[string]any); ok {
+ tunnelIP, _ := inbound["tunnel_ip"].(string)
+ httpsPort, _ := inbound["https_port"].(float64)
+ httpPort, _ := inbound["http_port"].(float64)
+ _, _ = fmt.Fprintf(c.out, "Inbound listener: %s (https=%d, http=%d)\n", tunnelIP, int(httpsPort), int(httpPort))
+ }
+ _, _ = fmt.Fprintln(c.out)
if status, ok := data["status"].(string); ok {
_, _ = fmt.Fprint(c.out, status)
}
@@ -284,6 +333,63 @@ func (c *Client) printLogLevelResult(data map[string]any) {
}
}
+// PerfSet live-retunes the tunnel buffer pool cap on all running embedded
+// clients. Batch size is not live-tunable; configure it at proxy startup.
+func (c *Client) PerfSet(ctx context.Context, value uint32) error {
+ path := fmt.Sprintf("/debug/perf?value=%d", value)
+ return c.fetchAndPrint(ctx, path, c.printPerfSet)
+}
+
+func (c *Client) printPerfSet(data map[string]any) {
+ if errMsg, ok := data["error"].(string); ok && errMsg != "" {
+ c.printError(data)
+ return
+ }
+ val, _ := data["value"].(float64)
+ applied, _ := data["applied"].(float64)
+ _, _ = fmt.Fprintf(c.out, "Pool cap set to: %d\n", uint32(val))
+ _, _ = fmt.Fprintf(c.out, "Applied to %d live clients\n", int(applied))
+ if failed, ok := data["failed"].(map[string]any); ok && len(failed) > 0 {
+ _, _ = fmt.Fprintln(c.out, "Failed:")
+ for k, v := range failed {
+ _, _ = fmt.Fprintf(c.out, " %s: %v\n", k, v)
+ }
+ }
+}
+
+// Runtime fetches runtime stats (heap, goroutines, RSS).
+func (c *Client) Runtime(ctx context.Context) error {
+ return c.fetchAndPrint(ctx, "/debug/runtime", c.printRuntime)
+}
+
+func (c *Client) printRuntime(data map[string]any) {
+ i := func(k string) uint64 {
+ v, _ := data[k].(float64)
+ return uint64(v)
+ }
+ mb := func(n uint64) string { return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) }
+
+ _, _ = fmt.Fprintf(c.out, "Uptime: %v\n", data["uptime"])
+ _, _ = fmt.Fprintf(c.out, "Go: %v on %d CPU (GOMAXPROCS=%d)\n", data["go_version"], uint32(i("num_cpu")), uint32(i("gomaxprocs")))
+ _, _ = fmt.Fprintf(c.out, "Goroutines: %d\n", i("goroutines"))
+ _, _ = fmt.Fprintf(c.out, "Live objects: %d\n", i("live_objects"))
+ _, _ = fmt.Fprintf(c.out, "GC: %d cycles, %v pause total\n", i("num_gc"), time.Duration(i("pause_total_ns")))
+ _, _ = fmt.Fprintln(c.out, "Heap:")
+ _, _ = fmt.Fprintf(c.out, " alloc: %s\n", mb(i("heap_alloc")))
+ _, _ = fmt.Fprintf(c.out, " in-use: %s\n", mb(i("heap_inuse")))
+ _, _ = fmt.Fprintf(c.out, " idle: %s\n", mb(i("heap_idle")))
+ _, _ = fmt.Fprintf(c.out, " released: %s\n", mb(i("heap_released")))
+ _, _ = fmt.Fprintf(c.out, " sys: %s\n", mb(i("heap_sys")))
+ _, _ = fmt.Fprintf(c.out, "Total sys: %s\n", mb(i("sys")))
+ if _, ok := data["vm_rss"]; ok {
+ _, _ = fmt.Fprintln(c.out, "Process:")
+ _, _ = fmt.Fprintf(c.out, " VmRSS: %s\n", mb(i("vm_rss")))
+ _, _ = fmt.Fprintf(c.out, " VmSize: %s\n", mb(i("vm_size")))
+ _, _ = fmt.Fprintf(c.out, " VmData: %s\n", mb(i("vm_data")))
+ }
+ _, _ = fmt.Fprintf(c.out, "Clients: %d (%d started)\n", i("clients"), i("started"))
+}
+
// StartClient starts a specific client.
func (c *Client) StartClient(ctx context.Context, accountID string) error {
path := "/debug/clients/" + url.PathEscape(accountID) + "/start"
diff --git a/proxy/internal/debug/handler.go b/proxy/internal/debug/handler.go
index 23ca4adbb..6300228d7 100644
--- a/proxy/internal/debug/handler.go
+++ b/proxy/internal/debug/handler.go
@@ -11,6 +11,8 @@ import (
"maps"
"net"
"net/http"
+ "os"
+ "runtime"
"slices"
"strconv"
"strings"
@@ -59,6 +61,24 @@ func sortedAccountIDs(m map[types.AccountID]roundtrip.ClientDebugInfo) []types.A
type clientProvider interface {
GetClient(accountID types.AccountID) (*nbembed.Client, bool)
ListClientsForDebug() map[types.AccountID]roundtrip.ClientDebugInfo
+ ListClientsForStartup() map[types.AccountID]*nbembed.Client
+}
+
+// InboundListenerInfo describes a per-account inbound listener as
+// surfaced through the debug HTTP handler. Mirrors the proto sub-message
+// emitted with SendStatusUpdate so dashboards and CLI tooling see the
+// same shape.
+type InboundListenerInfo struct {
+ TunnelIP string `json:"tunnel_ip"`
+ HTTPSPort uint16 `json:"https_port"`
+ HTTPPort uint16 `json:"http_port"`
+}
+
+// InboundProvider exposes per-account inbound listener state. Optional;
+// when nil the debug endpoint omits the inbound section entirely so the
+// existing JSON shape stays additive.
+type InboundProvider interface {
+ InboundListeners() map[types.AccountID]InboundListenerInfo
}
// healthChecker provides health probe state.
@@ -80,6 +100,7 @@ type Handler struct {
provider clientProvider
health healthChecker
certStatus certStatus
+ inbound InboundProvider
logger *log.Logger
startTime time.Time
templates *template.Template
@@ -108,6 +129,13 @@ func (h *Handler) SetCertStatus(cs certStatus) {
h.certStatus = cs
}
+// SetInboundProvider wires per-account inbound listener observability.
+// Pass nil (or skip the call) to keep the inbound section out of debug
+// responses on proxies that don't run --private.
+func (h *Handler) SetInboundProvider(p InboundProvider) {
+ h.inbound = p
+}
+
func (h *Handler) loadTemplates() error {
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
@@ -140,6 +168,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.handleListClients(w, r, wantJSON)
case "/debug/health":
h.handleHealth(w, r, wantJSON)
+ case "/debug/perf":
+ h.handlePerf(w, r)
+ case "/debug/runtime":
+ h.handleRuntime(w, r)
default:
if h.handleClientRoutes(w, r, path, wantJSON) {
return
@@ -233,10 +265,10 @@ func (h *Handler) handleIndex(w http.ResponseWriter, _ *http.Request, wantJSON b
}
if wantJSON {
- clientsJSON := make([]map[string]interface{}, 0, len(clients))
+ clientsJSON := make([]map[string]any, 0, len(clients))
for _, id := range sortedIDs {
info := clients[id]
- clientsJSON = append(clientsJSON, map[string]interface{}{
+ clientsJSON = append(clientsJSON, map[string]any{
"account_id": info.AccountID,
"service_count": info.ServiceCount,
"service_keys": info.ServiceKeys,
@@ -245,7 +277,7 @@ func (h *Handler) handleIndex(w http.ResponseWriter, _ *http.Request, wantJSON b
"age": time.Since(info.CreatedAt).Round(time.Second).String(),
})
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"version": version.NetbirdVersion(),
"uptime": time.Since(h.startTime).Round(time.Second).String(),
"client_count": len(clients),
@@ -323,23 +355,35 @@ func (h *Handler) handleListClients(w http.ResponseWriter, _ *http.Request, want
sortedIDs := sortedAccountIDs(clients)
if wantJSON {
- clientsJSON := make([]map[string]interface{}, 0, len(clients))
+ var inboundAll map[types.AccountID]InboundListenerInfo
+ if h.inbound != nil {
+ inboundAll = h.inbound.InboundListeners()
+ }
+ clientsJSON := make([]map[string]any, 0, len(clients))
for _, id := range sortedIDs {
info := clients[id]
- clientsJSON = append(clientsJSON, map[string]interface{}{
+ row := map[string]any{
"account_id": info.AccountID,
"service_count": info.ServiceCount,
"service_keys": info.ServiceKeys,
"has_client": info.HasClient,
"created_at": info.CreatedAt,
"age": time.Since(info.CreatedAt).Round(time.Second).String(),
- })
+ }
+ if inb, ok := inboundAll[id]; ok {
+ row["inbound_listener"] = inb
+ }
+ clientsJSON = append(clientsJSON, row)
}
- h.writeJSON(w, map[string]interface{}{
+ resp := map[string]any{
"uptime": time.Since(h.startTime).Round(time.Second).String(),
"client_count": len(clients),
"clients": clientsJSON,
- })
+ }
+ if len(inboundAll) > 0 {
+ resp["inbound_listener_count"] = len(inboundAll)
+ }
+ h.writeJSON(w, resp)
return
}
@@ -421,10 +465,14 @@ func (h *Handler) handleClientStatus(w http.ResponseWriter, r *http.Request, acc
})
if wantJSON {
- h.writeJSON(w, map[string]interface{}{
+ resp := map[string]any{
"account_id": accountID,
"status": overview.FullDetailSummary(),
- })
+ }
+ if info, ok := h.inboundInfoFor(accountID); ok {
+ resp["inbound_listener"] = info
+ }
+ h.writeJSON(w, resp)
return
}
@@ -437,6 +485,18 @@ func (h *Handler) handleClientStatus(w http.ResponseWriter, r *http.Request, acc
h.renderTemplate(w, "clientDetail", data)
}
+// inboundInfoFor returns the inbound listener info for an account, or
+// ok=false when no inbound provider is wired or the account has no live
+// listener.
+func (h *Handler) inboundInfoFor(accountID types.AccountID) (InboundListenerInfo, bool) {
+ if h.inbound == nil {
+ return InboundListenerInfo{}, false
+ }
+ all := h.inbound.InboundListeners()
+ info, ok := all[accountID]
+ return info, ok
+}
+
func (h *Handler) handleClientSyncResponse(w http.ResponseWriter, _ *http.Request, accountID types.AccountID, wantJSON bool) {
client, ok := h.provider.GetClient(accountID)
if !ok {
@@ -504,20 +564,20 @@ func (h *Handler) handleClientTools(w http.ResponseWriter, _ *http.Request, acco
func (h *Handler) handlePingTCP(w http.ResponseWriter, r *http.Request, accountID types.AccountID) {
client, ok := h.provider.GetClient(accountID)
if !ok {
- h.writeJSON(w, map[string]interface{}{"error": "client not found"})
+ h.writeJSON(w, map[string]any{"error": "client not found"})
return
}
host := r.URL.Query().Get("host")
portStr := r.URL.Query().Get("port")
if host == "" || portStr == "" {
- h.writeJSON(w, map[string]interface{}{"error": "host and port parameters required"})
+ h.writeJSON(w, map[string]any{"error": "host and port parameters required"})
return
}
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
- h.writeJSON(w, map[string]interface{}{"error": "invalid port"})
+ h.writeJSON(w, map[string]any{"error": "invalid port"})
return
}
@@ -541,7 +601,7 @@ func (h *Handler) handlePingTCP(w http.ResponseWriter, r *http.Request, accountI
conn, err := client.Dial(ctx, network, address)
if err != nil {
- h.writeJSON(w, map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": false,
"host": host,
"port": port,
@@ -556,39 +616,38 @@ func (h *Handler) handlePingTCP(w http.ResponseWriter, r *http.Request, accountI
}
latency := time.Since(start)
- resp := map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": true,
"host": host,
"port": port,
"remote": remote,
"latency_ms": latency.Milliseconds(),
"latency": formatDuration(latency),
- }
- h.writeJSON(w, resp)
+ })
}
func (h *Handler) handleLogLevel(w http.ResponseWriter, r *http.Request, accountID types.AccountID) {
client, ok := h.provider.GetClient(accountID)
if !ok {
- h.writeJSON(w, map[string]interface{}{"error": "client not found"})
+ h.writeJSON(w, map[string]any{"error": "client not found"})
return
}
level := r.URL.Query().Get("level")
if level == "" {
- h.writeJSON(w, map[string]interface{}{"error": "level parameter required (trace, debug, info, warn, error)"})
+ h.writeJSON(w, map[string]any{"error": "level parameter required (trace, debug, info, warn, error)"})
return
}
if err := client.SetLogLevel(level); err != nil {
- h.writeJSON(w, map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": false,
"error": err.Error(),
})
return
}
- h.writeJSON(w, map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": true,
"level": level,
})
@@ -599,7 +658,7 @@ const clientActionTimeout = 30 * time.Second
func (h *Handler) handleClientStart(w http.ResponseWriter, r *http.Request, accountID types.AccountID) {
client, ok := h.provider.GetClient(accountID)
if !ok {
- h.writeJSON(w, map[string]interface{}{"error": "client not found"})
+ h.writeJSON(w, map[string]any{"error": "client not found"})
return
}
@@ -607,14 +666,14 @@ func (h *Handler) handleClientStart(w http.ResponseWriter, r *http.Request, acco
defer cancel()
if err := client.Start(ctx); err != nil {
- h.writeJSON(w, map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": false,
"error": err.Error(),
})
return
}
- h.writeJSON(w, map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": true,
"message": "client started",
})
@@ -623,7 +682,7 @@ func (h *Handler) handleClientStart(w http.ResponseWriter, r *http.Request, acco
func (h *Handler) handleClientStop(w http.ResponseWriter, r *http.Request, accountID types.AccountID) {
client, ok := h.provider.GetClient(accountID)
if !ok {
- h.writeJSON(w, map[string]interface{}{"error": "client not found"})
+ h.writeJSON(w, map[string]any{"error": "client not found"})
return
}
@@ -631,19 +690,125 @@ func (h *Handler) handleClientStop(w http.ResponseWriter, r *http.Request, accou
defer cancel()
if err := client.Stop(ctx); err != nil {
- h.writeJSON(w, map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": false,
"error": err.Error(),
})
return
}
- h.writeJSON(w, map[string]interface{}{
+ h.writeJSON(w, map[string]any{
"success": true,
"message": "client stopped",
})
}
+func (h *Handler) handlePerf(w http.ResponseWriter, r *http.Request) {
+ raw := r.URL.Query().Get("value")
+ if raw == "" {
+ http.Error(w, "value parameter is required", http.StatusBadRequest)
+ return
+ }
+ n, err := strconv.ParseUint(raw, 10, 32)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("invalid value %q: %v", raw, err), http.StatusBadRequest)
+ return
+ }
+
+ capN := uint32(n)
+ applied := 0
+ failed := map[string]string{}
+ for accountID, client := range h.provider.ListClientsForStartup() {
+ if err := client.SetPerformance(nbembed.Performance{PreallocatedBuffersPerPool: &capN}); err != nil {
+ failed[string(accountID)] = err.Error()
+ continue
+ }
+ applied++
+ }
+
+ resp := map[string]any{
+ "success": true,
+ "value": capN,
+ "applied": applied,
+ }
+ if len(failed) > 0 {
+ resp["failed"] = failed
+ }
+ h.writeJSON(w, resp)
+}
+
+// handleRuntime returns cheap runtime and process stats. Safe to hit on a
+// running proxy; does not read pprof profiles.
+func (h *Handler) handleRuntime(w http.ResponseWriter, _ *http.Request) {
+ var m runtime.MemStats
+ runtime.ReadMemStats(&m)
+
+ clients := h.provider.ListClientsForDebug()
+ started := 0
+ for _, c := range clients {
+ if c.HasClient {
+ started++
+ }
+ }
+
+ resp := map[string]any{
+ "uptime": time.Since(h.startTime).Round(time.Second).String(),
+ "goroutines": runtime.NumGoroutine(),
+ "num_cpu": runtime.NumCPU(),
+ "gomaxprocs": runtime.GOMAXPROCS(0),
+ "go_version": runtime.Version(),
+ "heap_alloc": m.HeapAlloc,
+ "heap_inuse": m.HeapInuse,
+ "heap_idle": m.HeapIdle,
+ "heap_released": m.HeapReleased,
+ "heap_sys": m.HeapSys,
+ "sys": m.Sys,
+ "live_objects": m.Mallocs - m.Frees,
+ "num_gc": m.NumGC,
+ "pause_total_ns": m.PauseTotalNs,
+ "clients": len(clients),
+ "started": started,
+ }
+
+ if proc := readProcStatus(); proc != nil {
+ resp["vm_rss"] = proc["VmRSS"]
+ resp["vm_size"] = proc["VmSize"]
+ resp["vm_data"] = proc["VmData"]
+ }
+
+ h.writeJSON(w, resp)
+}
+
+// readProcStatus parses /proc/self/status on Linux and returns size fields
+// in bytes. Returns nil on non-Linux or read failure.
+func readProcStatus() map[string]uint64 {
+ raw, err := os.ReadFile("/proc/self/status")
+ if err != nil {
+ return nil
+ }
+ out := map[string]uint64{}
+ for _, line := range strings.Split(string(raw), "\n") {
+ k, v, ok := strings.Cut(line, ":")
+ if !ok {
+ continue
+ }
+ if k != "VmRSS" && k != "VmSize" && k != "VmData" {
+ continue
+ }
+ fields := strings.Fields(v)
+ if len(fields) < 1 {
+ continue
+ }
+ n, err := strconv.ParseUint(fields[0], 10, 64)
+ if err != nil {
+ continue
+ }
+ // Values are reported in kB.
+ out[k] = n * 1024
+ }
+ return out
+}
+
const maxCaptureDuration = 30 * time.Minute
// handleCapture streams a pcap or text packet capture for the given client.
@@ -772,7 +937,7 @@ func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request, wantJSON
h.writeJSON(w, resp)
}
-func (h *Handler) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
+func (h *Handler) renderTemplate(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := h.getTemplates()
if tmpl == nil {
@@ -785,7 +950,7 @@ func (h *Handler) renderTemplate(w http.ResponseWriter, name string, data interf
}
}
-func (h *Handler) writeJSON(w http.ResponseWriter, v interface{}) {
+func (h *Handler) writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
diff --git a/proxy/internal/metrics/metrics.go b/proxy/internal/metrics/metrics.go
index 573485625..41a6b0dd4 100644
--- a/proxy/internal/metrics/metrics.go
+++ b/proxy/internal/metrics/metrics.go
@@ -25,6 +25,11 @@ type Metrics struct {
backendDuration metric.Int64Histogram
certificateIssueDuration metric.Int64Histogram
+ // Management sync metrics.
+ snapshotSyncDuration metric.Int64Histogram
+ snapshotBatchDuration metric.Int64Histogram
+ addPeerDuration metric.Int64Histogram
+
// L4 service-level metrics.
l4Services metric.Int64UpDownCounter
@@ -54,6 +59,9 @@ func New(ctx context.Context, meter metric.Meter) (*Metrics, error) {
if err := m.initHTTPMetrics(meter); err != nil {
return nil, err
}
+ if err := m.initSyncMetrics(meter); err != nil {
+ return nil, err
+ }
if err := m.initL4Metrics(meter); err != nil {
return nil, err
}
@@ -126,6 +134,59 @@ func (m *Metrics) initHTTPMetrics(meter metric.Meter) error {
return err
}
+func (m *Metrics) initSyncMetrics(meter metric.Meter) error {
+ var err error
+
+ m.snapshotSyncDuration, err = meter.Int64Histogram(
+ "proxy.sync.snapshot.duration.ms",
+ metric.WithUnit("milliseconds"),
+ metric.WithDescription("Duration from management connect until the initial snapshot sync is complete"),
+ metric.WithExplicitBucketBoundaries(100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000, 120000, 300000),
+ )
+ if err != nil {
+ return err
+ }
+
+ m.snapshotBatchDuration, err = meter.Int64Histogram(
+ "proxy.sync.batch.duration.ms",
+ metric.WithUnit("milliseconds"),
+ metric.WithDescription("Duration to process a single mapping batch during initial snapshot sync"),
+ metric.WithExplicitBucketBoundaries(100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000, 120000, 300000),
+ )
+ if err != nil {
+ return err
+ }
+
+ m.addPeerDuration, err = meter.Int64Histogram(
+ "proxy.peer.add.duration.ms",
+ metric.WithUnit("milliseconds"),
+ metric.WithDescription("Duration to add a peer for an account (keygen + gRPC CreateProxyPeer + embed.New)"),
+ metric.WithExplicitBucketBoundaries(10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000),
+ )
+ return err
+}
+
+// RecordSnapshotSyncDuration records the total time from connect to sync-complete.
+func (m *Metrics) RecordSnapshotSyncDuration(d time.Duration) {
+ m.snapshotSyncDuration.Record(m.ctx, d.Milliseconds())
+}
+
+// RecordSnapshotBatchDuration records the time to process one mapping batch during initial sync.
+func (m *Metrics) RecordSnapshotBatchDuration(d time.Duration) {
+ m.snapshotBatchDuration.Record(m.ctx, d.Milliseconds())
+}
+
+// RecordAddPeerDuration records the time to create a new peer for an account.
+func (m *Metrics) RecordAddPeerDuration(d time.Duration, err error) {
+ result := "success"
+ if err != nil {
+ result = "error"
+ }
+ m.addPeerDuration.Record(m.ctx, d.Milliseconds(), metric.WithAttributes(
+ attribute.String("result", result),
+ ))
+}
+
func (m *Metrics) initL4Metrics(meter metric.Meter) error {
var err error
diff --git a/proxy/internal/proxy/context.go b/proxy/internal/proxy/context.go
index a888ad9ed..e05ec78aa 100644
--- a/proxy/internal/proxy/context.go
+++ b/proxy/internal/proxy/context.go
@@ -52,8 +52,15 @@ type CapturedData struct {
origin ResponseOrigin
clientIP netip.Addr
userID string
- authMethod string
- metadata map[string]string
+ userEmail string
+ userGroups []string
+ // userGroupNames pairs positionally with userGroups; populated from
+ // the JWT's group_names claim or from ValidateSession/Tunnel
+ // responses. Slice may be shorter than userGroups for tokens minted
+ // before names were resolvable.
+ userGroupNames []string
+ authMethod string
+ metadata map[string]string
}
// NewCapturedData creates a CapturedData with the given request ID.
@@ -138,6 +145,81 @@ func (c *CapturedData) GetUserID() string {
return c.userID
}
+// SetUserEmail records the authenticated user's email address. Used by
+// policy-aware middlewares to stamp identity onto upstream requests
+// (e.g. x-litellm-end-user-id) without a management round-trip.
+func (c *CapturedData) SetUserEmail(email string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.userEmail = email
+}
+
+// GetUserEmail returns the authenticated user's email address. Returns
+// the empty string when the auth path didn't carry an email (e.g.
+// non-OIDC schemes or legacy JWTs minted before the email claim).
+func (c *CapturedData) GetUserEmail() string {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.userEmail
+}
+
+// SetUserGroups records the authenticated user's group memberships so
+// downstream policy-aware middlewares can authorise the request without
+// an additional management round-trip. The auth middleware populates this
+// from ValidateSessionResponse / ValidateTunnelPeerResponse and from the
+// session JWT's groups claim on cookie-bearing requests.
+func (c *CapturedData) SetUserGroups(groups []string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if len(groups) == 0 {
+ c.userGroups = nil
+ return
+ }
+ c.userGroups = append(c.userGroups[:0], groups...)
+}
+
+// GetUserGroups returns a copy of the authenticated user's group
+// memberships.
+func (c *CapturedData) GetUserGroups() []string {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ if len(c.userGroups) == 0 {
+ return nil
+ }
+ out := make([]string, len(c.userGroups))
+ copy(out, c.userGroups)
+ return out
+}
+
+// SetUserGroupNames records the human-readable display names for the
+// user's groups, ordered identically to UserGroups (positional
+// pairing). Stamped onto upstream requests as X-NetBird-Groups so
+// downstream services can read names rather than opaque ids.
+func (c *CapturedData) SetUserGroupNames(names []string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if len(names) == 0 {
+ c.userGroupNames = nil
+ return
+ }
+ c.userGroupNames = append(c.userGroupNames[:0], names...)
+}
+
+// GetUserGroupNames returns a copy of the authenticated user's group
+// display names. Position i pairs with UserGroups[i]. May be shorter
+// than UserGroups for tokens minted before names were resolvable; the
+// consumer should fall back to ids for missing positions.
+func (c *CapturedData) GetUserGroupNames() []string {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ if len(c.userGroupNames) == 0 {
+ return nil
+ }
+ out := make([]string, len(c.userGroupNames))
+ copy(out, c.userGroupNames)
+ return out
+}
+
// SetAuthMethod sets the authentication method used.
func (c *CapturedData) SetAuthMethod(method string) {
c.mu.Lock()
diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go
index 246851d24..da0bf6552 100644
--- a/proxy/internal/proxy/reverseproxy.go
+++ b/proxy/internal/proxy/reverseproxy.go
@@ -66,6 +66,22 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
+ // Loop guard for private services: a peer that hosts the target
+ // dialing its own service URL would round-trip its own traffic
+ // through the proxy and back over WG to itself. Refuse the request
+ // with 421 (Misdirected Request) so the caller sees an explicit
+ // error instead of silently doubling tunnel traffic.
+ if p.isSelfTargetLoop(r, result.target.URL) {
+ if cd := CapturedDataFromContext(r.Context()); cd != nil {
+ cd.SetOrigin(OriginNoRoute)
+ }
+ requestID := getRequestID(r)
+ web.ServeErrorPage(w, r, http.StatusMisdirectedRequest, "Loop Detected",
+ "This peer is the target of the requested service. Reach the backend directly instead of dialing the public service URL from the same machine.",
+ requestID, web.ErrorStatus{Proxy: true, Destination: false})
+ return
+ }
+
ctx := r.Context()
// Set the account ID in the context for the roundtripper to use.
ctx = roundtrip.WithAccountID(ctx, result.accountID)
@@ -86,6 +102,9 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if pt.RequestTimeout > 0 {
ctx = types.WithDialTimeout(ctx, pt.RequestTimeout)
}
+ if pt.DirectUpstream {
+ ctx = roundtrip.WithDirectUpstream(ctx)
+ }
rewriteMatchedPath := result.matchedPath
if pt.PathRewrite == PathRewritePreserve {
@@ -104,6 +123,32 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rp.ServeHTTP(w, r.WithContext(ctx))
}
+// isSelfTargetLoop reports whether an overlay-origin request is about to
+// be forwarded back to the very peer that initiated it. The detection
+// is intentionally narrow: it only fires when the request arrived on
+// the per-account inbound (overlay) listener (so we're confident the
+// source address is the caller's tunnel IP), and only when the resolved
+// target host matches that tunnel IP. Catching this here returns 421 to
+// the caller instead of letting the proxy round-trip its own traffic
+// over WG twice.
+func (p *ReverseProxy) isSelfTargetLoop(r *http.Request, target *url.URL) bool {
+ if target == nil {
+ return false
+ }
+ if !types.IsOverlayOrigin(r.Context()) {
+ return false
+ }
+ srcIP := extractHostIP(r.RemoteAddr)
+ if !srcIP.IsValid() {
+ return false
+ }
+ targetIP, err := netip.ParseAddr(target.Hostname())
+ if err != nil {
+ return false
+ }
+ return srcIP.Unmap() == targetIP.Unmap()
+}
+
// rewriteFunc returns a Rewrite function for httputil.ReverseProxy that rewrites
// inbound requests to target the backend service while setting security-relevant
// forwarding headers and stripping proxy authentication credentials.
@@ -142,6 +187,8 @@ func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHost
r.Out.Header.Set(k, v)
}
+ stampNetBirdIdentity(r)
+
clientIP := extractHostIP(r.In.RemoteAddr)
if isTrustedAddr(clientIP, p.trustedProxies) {
@@ -426,3 +473,70 @@ func opErrorContains(err error, substr string) bool {
}
return false
}
+
+const (
+ // headerNetBirdUser carries the authenticated user's display identity
+ // (email when the peer is attached to a user, else peer name) onto
+ // upstream requests. Stripped from inbound requests before stamping
+ // so a client can't spoof identity by setting the header themselves.
+ headerNetBirdUser = "X-NetBird-User"
+ // headerNetBirdGroups carries the user's group display names as a
+ // comma-separated list. Falls back to group IDs at positions where a
+ // name wasn't available at session-mint time. Labels containing a
+ // comma or any non-printable byte are dropped at stamp time so the
+ // list is unambiguously splittable by consumers.
+ headerNetBirdGroups = "X-NetBird-Groups"
+)
+
+// isHeaderValueSafe reports whether v is a valid RFC 7230 field-value:
+// VCHAR (0x21-0x7E), SP (0x20), or HTAB (0x09). Empty values are
+// rejected; the caller decides whether to omit the header entirely.
+func isHeaderValueSafe(v string) bool {
+ if v == "" {
+ return false
+ }
+ for i := 0; i < len(v); i++ {
+ c := v[i]
+ if c == '\t' || (c >= 0x20 && c <= 0x7E) {
+ continue
+ }
+ return false
+ }
+ return true
+}
+
+// stampNetBirdIdentity injects authenticated identity onto outbound
+// requests as X-NetBird-User and X-NetBird-Groups. Always strips any
+// client-sent values first (anti-spoof). Skips when the request didn't
+// carry CapturedData (early-path errors, internal endpoints).
+func stampNetBirdIdentity(r *httputil.ProxyRequest) {
+ r.Out.Header.Del(headerNetBirdUser)
+ r.Out.Header.Del(headerNetBirdGroups)
+
+ cd := CapturedDataFromContext(r.In.Context())
+ if cd == nil {
+ return
+ }
+ if email := cd.GetUserEmail(); isHeaderValueSafe(email) {
+ r.Out.Header.Set(headerNetBirdUser, email)
+ }
+ groupIDs := cd.GetUserGroups()
+ if len(groupIDs) == 0 {
+ return
+ }
+ groupNames := cd.GetUserGroupNames()
+ labels := make([]string, 0, len(groupIDs))
+ for i, id := range groupIDs {
+ label := id
+ if i < len(groupNames) && groupNames[i] != "" {
+ label = groupNames[i]
+ }
+ if !isHeaderValueSafe(label) || strings.ContainsRune(label, ',') {
+ continue
+ }
+ labels = append(labels, label)
+ }
+ if len(labels) > 0 {
+ r.Out.Header.Set(headerNetBirdGroups, strings.Join(labels, ","))
+ }
+}
diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go
index c53307837..a8244fa56 100644
--- a/proxy/internal/proxy/reverseproxy_test.go
+++ b/proxy/internal/proxy/reverseproxy_test.go
@@ -20,6 +20,7 @@ import (
"github.com/netbirdio/netbird/proxy/auth"
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
+ "github.com/netbirdio/netbird/proxy/internal/types"
"github.com/netbirdio/netbird/proxy/web"
)
@@ -1067,3 +1068,342 @@ func TestClassifyProxyError(t *testing.T) {
})
}
}
+
+func TestStampNetBirdIdentity_NoCapturedData_StripsOnly(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+ pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
+ pr.In.Header.Set(headerNetBirdGroups, "admin")
+ pr.Out.Header = pr.In.Header.Clone()
+
+ rewrite(pr)
+
+ assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser),
+ "client-supplied X-NetBird-User must be stripped when no captured identity is present")
+ assert.Empty(t, pr.Out.Header.Get(headerNetBirdGroups),
+ "client-supplied X-NetBird-Groups must be stripped when no captured identity is present")
+}
+
+func TestStampNetBirdIdentity_StampsFromCapturedData(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+ pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
+ pr.Out.Header = pr.In.Header.Clone()
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserEmail("alice@netbird.io")
+ cd.SetUserGroups([]string{"grp-eng", "grp-ops"})
+ cd.SetUserGroupNames([]string{"engineering", "operations"})
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Equal(t, "alice@netbird.io", pr.Out.Header.Get(headerNetBirdUser),
+ "captured email must overwrite any spoofed value")
+ assert.Equal(t, "engineering,operations", pr.Out.Header.Get(headerNetBirdGroups),
+ "group display names must be CSV-joined in positional order")
+}
+
+// TestStampNetBirdIdentity_GroupsOnlyWhenEmailEmpty covers the
+// tunnel-peer-without-user case (machine agents, unattached proxy peers).
+// The proxy must still stamp the peer's groups so downstream services can
+// authorise, but X-NetBird-User stays unset — only its inbound stripping
+// must happen.
+func TestStampNetBirdIdentity_GroupsOnlyWhenEmailEmpty(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+ pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
+ pr.Out.Header = pr.In.Header.Clone()
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserGroups([]string{"grp-machines"})
+ cd.SetUserGroupNames([]string{"machines"})
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser),
+ "X-NetBird-User must remain unset when CapturedData carries no email")
+ assert.Equal(t, "machines", pr.Out.Header.Get(headerNetBirdGroups),
+ "groups must still be stamped for peers without a user identity")
+}
+
+// TestStampNetBirdIdentity_EmailOnlyWhenGroupsEmpty covers the symmetric
+// case: identity-resolved user without resolved group memberships.
+func TestStampNetBirdIdentity_EmailOnlyWhenGroupsEmpty(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+ pr.In.Header.Set(headerNetBirdGroups, "spoofed-admin")
+ pr.Out.Header = pr.In.Header.Clone()
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserEmail("carol@netbird.io")
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Equal(t, "carol@netbird.io", pr.Out.Header.Get(headerNetBirdUser),
+ "email must be stamped even when no groups are captured")
+ assert.Empty(t, pr.Out.Header.Get(headerNetBirdGroups),
+ "X-NetBird-Groups must remain unset when CapturedData carries no groups")
+}
+
+func TestStampNetBirdIdentity_FallsBackToGroupIDsWhenNameMissing(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserEmail("bob@netbird.io")
+ cd.SetUserGroups([]string{"grp-a", "grp-b", "grp-c"})
+ // "grp-b" gets an explicit empty-string display name (not just a
+ // shorter slice). Both gap shapes must fall back to the id.
+ cd.SetUserGroupNames([]string{"alpha", "", ""})
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Equal(t, "alpha,grp-b,grp-c", pr.Out.Header.Get(headerNetBirdGroups),
+ "empty-string and out-of-range name slots must both fall back to the group id")
+}
+
+// TestStampNetBirdIdentity_DropsLabelsWithComma covers the
+// comma-separator constraint: a group display name that itself contains
+// a comma is dropped from the header (rather than corrupting the list),
+// and the remaining labels are stamped.
+func TestStampNetBirdIdentity_DropsLabelsWithComma(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserEmail("alice@netbird.io")
+ cd.SetUserGroups([]string{"grp-a", "grp-b", "grp-c"})
+ cd.SetUserGroupNames([]string{"engineering", "EU, EMEA", "operations"})
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Equal(t, "engineering,operations", pr.Out.Header.Get(headerNetBirdGroups),
+ "group label with embedded comma must be dropped, remaining labels stamped")
+}
+
+// TestStampNetBirdIdentity_RejectsControlCharsInEmail covers the
+// header-injection defence: an email value containing CR/LF/control
+// chars is omitted entirely (not partially stamped) so the upstream
+// request stays well-formed and no header injection is possible.
+func TestStampNetBirdIdentity_RejectsControlCharsInEmail(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+ pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
+ pr.Out.Header = pr.In.Header.Clone()
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserEmail("alice@netbird.io\r\nX-Admin: yes")
+ cd.SetUserGroups([]string{"grp-a"})
+ cd.SetUserGroupNames([]string{"engineering"})
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser),
+ "email with CR/LF must be dropped, not partially stamped")
+ assert.Equal(t, "engineering", pr.Out.Header.Get(headerNetBirdGroups),
+ "groups remain stampable even when email is invalid")
+}
+
+// TestStampNetBirdIdentity_RejectsControlCharsInGroup covers the
+// per-label defence: a group name with a control char is silently
+// dropped, the rest are stamped.
+func TestStampNetBirdIdentity_RejectsControlCharsInGroup(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserEmail("alice@netbird.io")
+ cd.SetUserGroups([]string{"grp-a", "grp-b"})
+ cd.SetUserGroupNames([]string{"engineering\r\nsneaky", "operations"})
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Equal(t, "operations", pr.Out.Header.Get(headerNetBirdGroups),
+ "group label with control char must be dropped, valid ones kept")
+}
+
+// TestStampNetBirdIdentity_OmitsGroupsHeaderWhenAllInvalid covers the
+// edge case where every group label is rejected: the header must not be
+// set at all (rather than set to an empty string).
+func TestStampNetBirdIdentity_OmitsGroupsHeaderWhenAllInvalid(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+ pr.In.Header.Set(headerNetBirdGroups, "spoofed-admin")
+ pr.Out.Header = pr.In.Header.Clone()
+
+ cd := NewCapturedData("req-1")
+ cd.SetUserEmail("alice@netbird.io")
+ cd.SetUserGroups([]string{"grp-a", "grp-b"})
+ cd.SetUserGroupNames([]string{"with,comma", "with\nbreak"})
+
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ _, present := pr.Out.Header[http.CanonicalHeaderKey(headerNetBirdGroups)]
+ assert.False(t, present,
+ "X-NetBird-Groups must not be set when every group label is rejected")
+}
+
+// nopOKTransport returns 200 for every request without dialing — used
+// by the self-target-loop tests so the non-loop cases don't pay a real
+// TCP-dial timeout.
+type nopOKTransport struct{}
+
+func (nopOKTransport) RoundTrip(*http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: http.Header{}}, nil
+}
+
+// TestServeHTTP_SelfTargetLoopReturns421 covers the loop guard for
+// private services: when a peer dials a service whose only target is
+// the peer itself, the proxy must refuse with 421 (Misdirected
+// Request) rather than round-tripping the request back over WG to
+// the same peer.
+func TestServeHTTP_SelfTargetLoopReturns421(t *testing.T) {
+ rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil)
+ rp.AddMapping(Mapping{
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Host: "private.svc",
+ Paths: map[string]*PathTarget{
+ "/": {
+ URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"},
+ },
+ },
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "http://private.svc/", nil)
+ req.Host = "private.svc"
+ req.RemoteAddr = "100.64.0.5:55555"
+ req = req.WithContext(types.WithOverlayOrigin(req.Context()))
+ rec := httptest.NewRecorder()
+
+ rp.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusMisdirectedRequest, rec.Code,
+ "a peer dialing a service whose target is itself must get 421")
+}
+
+// TestServeHTTP_SelfTargetLoop_NonOverlayRequestPassesThrough verifies
+// the guard is scoped to overlay-origin requests. A public-listener
+// request that happens to share a source IP with the target host must
+// not be misinterpreted as a loop — the gating relies on the inbound
+// marker being attached only by the per-account overlay listener.
+func TestServeHTTP_SelfTargetLoop_NonOverlayRequestPassesThrough(t *testing.T) {
+ rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil)
+ rp.AddMapping(Mapping{
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Host: "public.svc",
+ Paths: map[string]*PathTarget{
+ "/": {
+ URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"},
+ },
+ },
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "http://public.svc/", nil)
+ req.Host = "public.svc"
+ req.RemoteAddr = "100.64.0.5:55555"
+ // No WithOverlayOrigin → the guard must not fire.
+ rec := httptest.NewRecorder()
+
+ rp.ServeHTTP(rec, req)
+
+ assert.NotEqual(t, http.StatusMisdirectedRequest, rec.Code,
+ "a non-overlay request with a colliding source IP must not be flagged as a loop")
+}
+
+// TestServeHTTP_SelfTargetLoop_OverlayDifferentIPPassesThrough confirms
+// that overlay-origin requests with a source IP that does *not* match
+// the target host are forwarded normally.
+func TestServeHTTP_SelfTargetLoop_OverlayDifferentIPPassesThrough(t *testing.T) {
+ rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil)
+ rp.AddMapping(Mapping{
+ ID: "svc-1",
+ AccountID: "acct-1",
+ Host: "private.svc",
+ Paths: map[string]*PathTarget{
+ "/": {
+ URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"},
+ },
+ },
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "http://private.svc/", nil)
+ req.Host = "private.svc"
+ req.RemoteAddr = "100.64.0.99:55555" // different from the target
+ req = req.WithContext(types.WithOverlayOrigin(req.Context()))
+ rec := httptest.NewRecorder()
+
+ rp.ServeHTTP(rec, req)
+
+ assert.NotEqual(t, http.StatusMisdirectedRequest, rec.Code,
+ "overlay request with a non-matching source IP must not be flagged as a loop")
+}
+
+// TestStampNetBirdIdentity_CapturedDataPresentButEmpty covers requests
+// that carry CapturedData with no identity fields populated (e.g. the
+// auth middleware ran but the request didn't authenticate). Both
+// headers must be cleared and neither stamped.
+func TestStampNetBirdIdentity_CapturedDataPresentButEmpty(t *testing.T) {
+ target, _ := url.Parse("http://backend.internal:8080")
+ p := &ReverseProxy{forwardedProto: "auto"}
+ rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil)
+
+ pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999")
+ pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io")
+ pr.In.Header.Set(headerNetBirdGroups, "spoofed-admin")
+ pr.Out.Header = pr.In.Header.Clone()
+
+ cd := NewCapturedData("req-1")
+ pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd))
+
+ rewrite(pr)
+
+ assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser),
+ "X-NetBird-User must be stripped when CapturedData has no email")
+ assert.Empty(t, pr.Out.Header.Get(headerNetBirdGroups),
+ "X-NetBird-Groups must be stripped when CapturedData has no groups")
+}
diff --git a/proxy/internal/proxy/servicemapping.go b/proxy/internal/proxy/servicemapping.go
index fe470cf01..46b4d2e8d 100644
--- a/proxy/internal/proxy/servicemapping.go
+++ b/proxy/internal/proxy/servicemapping.go
@@ -28,6 +28,10 @@ type PathTarget struct {
RequestTimeout time.Duration
PathRewrite PathRewriteMode
CustomHeaders map[string]string
+ // DirectUpstream selects the stdlib HTTP transport (host network stack)
+ // over the embedded NetBird WireGuard client when forwarding requests
+ // to this target. Default false → embedded client (existing behaviour).
+ DirectUpstream bool
}
// Mapping describes how a domain is routed by the HTTP reverse proxy.
diff --git a/proxy/internal/restrict/restrict.go b/proxy/internal/restrict/restrict.go
index f3e0fa695..67756b88b 100644
--- a/proxy/internal/restrict/restrict.go
+++ b/proxy/internal/restrict/restrict.go
@@ -191,6 +191,18 @@ func (f *Filter) IsObserveOnly(v Verdict) bool {
return v.IsCrowdSec() && f.CrowdSecMode == CrowdSecObserve
}
+// CheckCIDR runs only the CIDR allow/block evaluation. Use this when
+// country and CrowdSec checks don't apply — e.g. requests arriving
+// from the WireGuard overlay, whose source addresses live in the
+// CGNAT range and have no meaningful geolocation or IP-reputation
+// data.
+func (f *Filter) CheckCIDR(addr netip.Addr) Verdict {
+ if f == nil {
+ return Allow
+ }
+ return f.checkCIDR(addr.Unmap())
+}
+
// Check evaluates whether addr is permitted. CIDR rules are evaluated
// first because they are O(n) prefix comparisons. Country rules run
// only when CIDR checks pass and require a geo lookup. CrowdSec checks
diff --git a/proxy/internal/restrict/restrict_test.go b/proxy/internal/restrict/restrict_test.go
index abaa1afdc..16aa1e139 100644
--- a/proxy/internal/restrict/restrict_test.go
+++ b/proxy/internal/restrict/restrict_test.go
@@ -514,6 +514,34 @@ func TestFilter_CrowdSec_Observe_NilChecker(t *testing.T) {
assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.2.3.4"), nil))
}
+func TestFilter_CheckCIDR_AllowsWithoutCountryOrCrowdSec(t *testing.T) {
+ cs := &mockCrowdSec{ready: true, decisions: map[string]*CrowdSecDecision{
+ "100.64.5.6": {Type: DecisionBan},
+ }}
+ f := ParseFilter(FilterConfig{
+ AllowedCIDRs: []string{"100.64.0.0/10"},
+ AllowedCountries: []string{"US"},
+ CrowdSec: cs,
+ CrowdSecMode: CrowdSecEnforce,
+ })
+
+ // CheckCIDR skips country + CrowdSec evaluation: an address inside
+ // the allowed CIDR passes even when it would be denied by CrowdSec
+ // or by the country allowlist (CGNAT addresses have no geo data).
+ assert.Equal(t, Allow, f.CheckCIDR(netip.MustParseAddr("100.64.5.6")),
+ "CheckCIDR must not run CrowdSec lookups on overlay traffic")
+
+ // CIDR denials still fire.
+ assert.Equal(t, DenyCIDR, f.CheckCIDR(netip.MustParseAddr("198.51.100.1")),
+ "CheckCIDR must still reject addresses outside the allow list")
+}
+
+func TestFilter_CheckCIDR_NilFilter(t *testing.T) {
+ var f *Filter
+ assert.Equal(t, Allow, f.CheckCIDR(netip.MustParseAddr("100.64.5.6")),
+ "CheckCIDR on a nil filter must allow")
+}
+
func TestFilter_HasRestrictions_CrowdSec(t *testing.T) {
cs := &mockCrowdSec{ready: true}
f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecEnforce})
diff --git a/proxy/internal/roundtrip/multi.go b/proxy/internal/roundtrip/multi.go
new file mode 100644
index 000000000..567249437
--- /dev/null
+++ b/proxy/internal/roundtrip/multi.go
@@ -0,0 +1,112 @@
+package roundtrip
+
+import (
+ "crypto/tls"
+ "errors"
+ "net"
+ "net/http"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// MultiTransport dispatches each request to either the embedded NetBird
+// http.RoundTripper or a stdlib http.Transport based on a per-request
+// context flag set by the reverse-proxy rewrite step. When the flag is
+// absent (the default for every existing target), requests follow the
+// embedded NetBird path — current behaviour, preserved.
+//
+// The stdlib branch is used when a target was configured with
+// direct_upstream=true. It dials via the host's network stack, which is
+// what private (`netbird proxy`) deployments and centralised proxies
+// fronting host-reachable upstreams (public APIs, LAN services,
+// localhost sidecars) want.
+//
+// An embedded roundtripper is required. To run direct-only (no WG
+// branch at all), construct the MultiTransport via NewDirectOnly.
+type MultiTransport struct {
+ embedded http.RoundTripper
+ direct *http.Transport
+ insecure *http.Transport
+}
+
+// errNoEmbeddedTransport is returned when a request reaches the
+// embedded branch on a MultiTransport that wasn't given one. Surfaces
+// the misconfiguration to the caller instead of silently routing to
+// the direct branch, which would bypass the WG tunnel.
+var errNoEmbeddedTransport = errors.New("multitransport: embedded roundtripper not configured")
+
+// NewMultiTransport wires both branches. embedded is the existing NetBird
+// roundtripper and must not be nil — pass to NewDirectOnly for a
+// MultiTransport that only ever uses the direct branch. The direct
+// branches honour the same NB_PROXY_* tuning env vars as the embedded
+// transport (see loadTransportConfig) plus a dial-timeout wrapper that
+// respects types.WithDialTimeout.
+func NewMultiTransport(embedded http.RoundTripper, logger *log.Logger) *MultiTransport {
+ if logger == nil {
+ logger = log.StandardLogger()
+ }
+ cfg := loadTransportConfig(logger)
+ dialer := &net.Dialer{
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }
+ direct := &http.Transport{
+ DialContext: dialWithTimeout(dialer.DialContext),
+ ForceAttemptHTTP2: true,
+ MaxIdleConns: cfg.maxIdleConns,
+ MaxIdleConnsPerHost: cfg.maxIdleConnsPerHost,
+ MaxConnsPerHost: cfg.maxConnsPerHost,
+ IdleConnTimeout: cfg.idleConnTimeout,
+ TLSHandshakeTimeout: cfg.tlsHandshakeTimeout,
+ ExpectContinueTimeout: cfg.expectContinueTimeout,
+ ResponseHeaderTimeout: cfg.responseHeaderTimeout,
+ WriteBufferSize: cfg.writeBufferSize,
+ ReadBufferSize: cfg.readBufferSize,
+ DisableCompression: cfg.disableCompression,
+ }
+ insecure := direct.Clone()
+ insecure.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // matches the embedded NetBird transport's per-target opt-in
+
+ return &MultiTransport{
+ embedded: embedded,
+ direct: direct,
+ insecure: insecure,
+ }
+}
+
+// NewDirectOnly returns a MultiTransport with no embedded branch.
+// Every request goes through the direct branch regardless of the
+// per-request flag, so the embedded path can never be reached
+// silently — wiring code that needs WG must use NewMultiTransport.
+func NewDirectOnly(logger *log.Logger) *MultiTransport {
+ return NewMultiTransport(noEmbeddedRoundTripper{}, logger)
+}
+
+// noEmbeddedRoundTripper is the sentinel embedded transport for
+// direct-only MultiTransports. RoundTrip is never called in practice
+// because the direct branch matches every request, but if anything
+// ever did reach this path it would fail loudly instead of falling
+// back to direct.
+type noEmbeddedRoundTripper struct{}
+
+func (noEmbeddedRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
+ return nil, errNoEmbeddedTransport
+}
+
+// RoundTrip dispatches by reading the direct-upstream flag from the request
+// context. When set, the request is forwarded via the stdlib transport,
+// honouring the existing per-request skip-TLS-verify flag. Otherwise it
+// goes through the embedded NetBird roundtripper.
+func (m *MultiTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ if DirectUpstreamFromContext(req.Context()) {
+ if skipTLSVerifyFromContext(req.Context()) {
+ return m.insecure.RoundTrip(req)
+ }
+ return m.direct.RoundTrip(req)
+ }
+ if m.embedded == nil {
+ return nil, errNoEmbeddedTransport
+ }
+ return m.embedded.RoundTrip(req)
+}
diff --git a/proxy/internal/roundtrip/multi_test.go b/proxy/internal/roundtrip/multi_test.go
new file mode 100644
index 000000000..5c6cf1c97
--- /dev/null
+++ b/proxy/internal/roundtrip/multi_test.go
@@ -0,0 +1,134 @@
+package roundtrip
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// stubRoundTripper records whether RoundTrip was called and returns a
+// canned response so tests can assert the dispatch decision without
+// running a real network.
+type stubRoundTripper struct {
+ called bool
+ body string
+}
+
+func (s *stubRoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) {
+ s.called = true
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(s.body)),
+ Header: http.Header{},
+ }, nil
+}
+
+func TestMultiTransport_DispatchesByContextFlag(t *testing.T) {
+ embedded := &stubRoundTripper{body: "embedded"}
+ mt := NewMultiTransport(embedded, nil)
+
+ t.Run("default routes to embedded", func(t *testing.T) {
+ embedded.called = false
+ req := httptest.NewRequest(http.MethodGet, "http://example.invalid", nil)
+ resp, err := mt.RoundTrip(req)
+ require.NoError(t, err, "embedded path must not error on stubbed transport")
+ require.NotNil(t, resp)
+ _ = resp.Body.Close()
+ assert.True(t, embedded.called, "request without WithDirectUpstream must hit the embedded transport")
+ })
+
+ t.Run("WithDirectUpstream skips embedded", func(t *testing.T) {
+ embedded.called = false
+ // Hit a server we control to verify the stdlib transport is used.
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, "direct")
+ }))
+ defer srv.Close()
+
+ req, err := http.NewRequestWithContext(WithDirectUpstream(context.Background()), http.MethodGet, srv.URL, nil)
+ require.NoError(t, err)
+ resp, err := mt.RoundTrip(req)
+ require.NoError(t, err, "direct path must dial via stdlib transport")
+ body, err := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ require.NoError(t, err)
+ assert.Equal(t, "direct", string(body), "stdlib transport must reach the test server")
+ assert.False(t, embedded.called, "WithDirectUpstream must bypass the embedded transport")
+ })
+}
+
+// TestMultiTransport_AppliesEnvOverridesToDirect verifies that the
+// NB_PROXY_* env vars consumed by loadTransportConfig flow into the
+// direct branches (previously they only applied to the embedded
+// roundtripper, so direct-upstream traffic ignored operator tuning).
+func TestMultiTransport_AppliesEnvOverridesToDirect(t *testing.T) {
+ t.Setenv(EnvMaxIdleConns, "42")
+ t.Setenv(EnvIdleConnTimeout, "11s")
+ t.Setenv(EnvTLSHandshakeTimeout, "7s")
+
+ mt := NewMultiTransport(&stubRoundTripper{body: "embedded"}, nil)
+
+ assert.Equal(t, 42, mt.direct.MaxIdleConns,
+ "NB_PROXY_MAX_IDLE_CONNS must propagate to the direct transport")
+ assert.Equal(t, 11*time.Second, mt.direct.IdleConnTimeout,
+ "NB_PROXY_IDLE_CONN_TIMEOUT must propagate to the direct transport")
+ assert.Equal(t, 7*time.Second, mt.direct.TLSHandshakeTimeout,
+ "NB_PROXY_TLS_HANDSHAKE_TIMEOUT must propagate to the direct transport")
+ assert.Equal(t, 42, mt.insecure.MaxIdleConns,
+ "env tuning must also apply to the insecure-skip-verify direct transport")
+}
+
+// TestMultiTransport_NilEmbeddedErrorsWhenWGPathRequested guards
+// against the previous silent fallback: a MultiTransport constructed
+// without an embedded transport must reject requests that don't
+// explicitly opt into the direct branch, rather than routing them
+// over the host stack and bypassing WireGuard.
+func TestMultiTransport_NilEmbeddedErrorsWhenWGPathRequested(t *testing.T) {
+ mt := NewMultiTransport(nil, nil)
+
+ req := httptest.NewRequest(http.MethodGet, "http://example.invalid", nil)
+ resp, err := mt.RoundTrip(req)
+ if resp != nil {
+ _ = resp.Body.Close()
+ }
+ require.Error(t, err, "nil embedded must surface as an explicit error, not a silent direct dispatch")
+ assert.Nil(t, resp)
+ assert.ErrorIs(t, err, errNoEmbeddedTransport,
+ "the error must be the sentinel so callers can distinguish misconfiguration from network failures")
+}
+
+// TestMultiTransport_DirectOnlyServesDirectBranch verifies NewDirectOnly
+// constructs a MultiTransport whose direct branch handles requests with
+// the direct-upstream flag set, and surfaces the explicit sentinel
+// when the embedded path is reached.
+func TestMultiTransport_DirectOnlyServesDirectBranch(t *testing.T) {
+ mt := NewDirectOnly(nil)
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, "ok")
+ }))
+ defer srv.Close()
+
+ req, err := http.NewRequestWithContext(WithDirectUpstream(context.Background()), http.MethodGet, srv.URL, nil)
+ require.NoError(t, err)
+ resp, err := mt.RoundTrip(req)
+ require.NoError(t, err, "direct-only must serve requests that opt into the direct branch")
+ _ = resp.Body.Close()
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ wgReq := httptest.NewRequest(http.MethodGet, "http://example.invalid", nil)
+ resp, err = mt.RoundTrip(wgReq)
+ if resp != nil {
+ _ = resp.Body.Close()
+ }
+ require.Error(t, err, "direct-only must refuse requests that didn't opt into the direct branch")
+ assert.Nil(t, resp)
+ assert.ErrorIs(t, err, errNoEmbeddedTransport)
+}
diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go
index e38e3dc4e..13d386da2 100644
--- a/proxy/internal/roundtrip/netbird.go
+++ b/proxy/internal/roundtrip/netbird.go
@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"net/http"
+ "net/netip"
"sync"
"time"
@@ -27,6 +28,10 @@ import (
const deviceNamePrefix = "ingress-proxy-"
+const clientStopTimeout = 30 * time.Second
+
+const createProxyPeerTimeout = 30 * time.Second
+
// backendKey identifies a backend by its host:port from the target URL.
type backendKey string
@@ -76,6 +81,11 @@ type clientEntry struct {
services map[ServiceKey]serviceInfo
createdAt time.Time
started bool
+ // inbound is opaque per-account state owned by the NetBird parent's
+ // ReadyHandler. The roundtrip package never inspects this value; it
+ // only stores it so RemovePeer / StopAll can hand it back to the
+ // matching StopHandler. Nil when no inbound integration is active.
+ inbound any
// Per-backend in-flight limiting keyed by target host:port.
// TODO: clean up stale entries when backend targets change.
inflightMu sync.Mutex
@@ -83,6 +93,19 @@ type clientEntry struct {
maxInflight int
}
+// IdentityForIP resolves a tunnel IP to the peer identity locally known by
+// this account's embedded client. Returns (pubKey, fqdn) on success.
+// ok=false means the IP is not in the account's roster — callers can use
+// that as a fast deny without round-tripping management. The returned
+// strings carry only what the embedded peerstore exposes; user identity
+// (UserID / Email / Groups) still flows through ValidateTunnelPeer.
+func (e *clientEntry) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
+ if e == nil || e.client == nil || !ip.IsValid() {
+ return "", "", false
+ }
+ return e.client.IdentityForIP(ip)
+}
+
// acquireInflight attempts to acquire an in-flight slot for the given backend.
// It returns a release function that must always be called, and true on success.
func (e *clientEntry) acquireInflight(backend backendKey) (release func(), ok bool) {
@@ -112,6 +135,13 @@ type ClientConfig struct {
MgmtAddr string
WGPort uint16
PreSharedKey string
+ Performance embed.Performance
+ // BlockInbound mirrors embed.Options.BlockInbound. Set to true on the
+ // standalone proxy where the embedded client never accepts inbound;
+ // set to false on the private/embedded proxy so the engine creates
+ // the ACL manager and applies management's per-policy firewall rules
+ // (which is what gates per-account inbound listeners on the netstack).
+ BlockInbound bool
}
type statusNotifier interface {
@@ -126,6 +156,7 @@ type managementClient interface {
// backed by underlying NetBird connections.
// Clients are keyed by AccountID, allowing multiple services to share the same connection.
type NetBird struct {
+ ctx context.Context
proxyID string
proxyAddr string
clientCfg ClientConfig
@@ -135,8 +166,26 @@ type NetBird struct {
clientsMux sync.RWMutex
clients map[types.AccountID]*clientEntry
+ lifecycleMu sync.Map
initLogOnce sync.Once
statusNotifier statusNotifier
+ // readyHandler runs after the embedded client for an account reports
+ // Ready. The opaque return value is stored on clientEntry and handed
+ // back to stopHandler when the entry is torn down. Nil disables the
+ // hook entirely (default for the standalone proxy).
+ readyHandler func(ctx context.Context, accountID types.AccountID, client *embed.Client) any
+ // stopHandler runs when an account's last service is removed (or the
+ // transport is shutting down). Receives whatever readyHandler returned.
+ stopHandler func(accountID types.AccountID, state any)
+
+ // OnAddPeer, when set, is called after AddPeer completes for a new account
+ // (i.e. when a new client was actually created, not when an existing one
+ // was reused). The duration covers keygen + gRPC CreateProxyPeer + embed.New.
+ OnAddPeer func(d time.Duration, err error)
+
+ // startClient runs the post-create client startup. Nil uses runClientStartup;
+ // tests override it to avoid a real embed client.Start.
+ startClient func(accountID types.AccountID, client *embed.Client)
}
// ClientDebugInfo contains debug information about a client.
@@ -160,36 +209,33 @@ type skipTLSVerifyContextKey struct{}
func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, serviceID types.ServiceID) error {
si := serviceInfo{serviceID: serviceID}
- n.clientsMux.Lock()
-
- entry, exists := n.clients[accountID]
- if exists {
- entry.services[key] = si
- started := entry.started
- n.clientsMux.Unlock()
-
- n.logger.WithFields(log.Fields{
- "account_id": accountID,
- "service_key": key,
- }).Debug("registered service with existing client")
-
- if started && n.statusNotifier != nil {
- if err := n.statusNotifier.NotifyStatus(ctx, accountID, serviceID, true); err != nil {
- n.logger.WithFields(log.Fields{
- "account_id": accountID,
- "service_key": key,
- }).WithError(err).Warn("failed to notify status for existing client")
- }
- }
+ if n.registerExistingClient(accountID, key, si) {
return nil
}
+ lifecycle := n.accountLifecycle(accountID)
+ lifecycle.Lock()
+ transferred := false
+ defer func() {
+ if !transferred {
+ lifecycle.Unlock()
+ }
+ }()
+
+ if n.registerExistingClient(accountID, key, si) {
+ return nil
+ }
+
+ createStart := time.Now()
entry, err := n.createClientEntry(ctx, accountID, key, authToken, si)
+ if n.OnAddPeer != nil {
+ n.OnAddPeer(time.Since(createStart), err)
+ }
if err != nil {
- n.clientsMux.Unlock()
return err
}
+ n.clientsMux.Lock()
n.clients[accountID] = entry
n.clientsMux.Unlock()
@@ -198,15 +244,64 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
"service_key": key,
}).Info("created new client for account")
- // Attempt to start the client in the background; if this fails we will
- // retry on the first request via RoundTrip.
- go n.runClientStartup(ctx, accountID, entry.client)
+ transferred = true
+ go func() {
+ defer lifecycle.Unlock()
+ n.startClientStartup(accountID, entry.client)
+ }()
return nil
}
+func (n *NetBird) startClientStartup(accountID types.AccountID, client *embed.Client) {
+ if n.startClient != nil {
+ n.startClient(accountID, client)
+ return
+ }
+ n.runClientStartup(accountID, client)
+}
+
+// registerExistingClient registers the service against an already-present
+// client for the account and returns true when it did. It notifies management
+// of the new service when the client is already started.
+func (n *NetBird) registerExistingClient(accountID types.AccountID, key ServiceKey, si serviceInfo) bool {
+ n.clientsMux.Lock()
+ entry, exists := n.clients[accountID]
+ if !exists {
+ n.clientsMux.Unlock()
+ return false
+ }
+ entry.services[key] = si
+ started := entry.started
+ n.clientsMux.Unlock()
+
+ n.logger.WithFields(log.Fields{
+ "account_id": accountID,
+ "service_key": key,
+ }).Debug("registered service with existing client")
+
+ if started && n.statusNotifier != nil {
+ if err := n.statusNotifier.NotifyStatus(context.Background(), accountID, si.serviceID, true); err != nil {
+ n.logger.WithFields(log.Fields{
+ "account_id": accountID,
+ "service_key": key,
+ }).WithError(err).Warn("failed to notify status for existing client")
+ }
+ }
+ return true
+}
+
+// accountLifecycle returns the per-account lifecycle mutex, serialising client
+// creation against teardown so a slow client.Stop cannot race a new
+// client.Start for the same account, without blocking clientsMux.
+func (n *NetBird) accountLifecycle(accountID types.AccountID) *sync.Mutex {
+ mu, _ := n.lifecycleMu.LoadOrStore(accountID, &sync.Mutex{})
+ return mu.(*sync.Mutex)
+}
+
// createClientEntry generates a WireGuard keypair, authenticates with management,
-// and creates an embedded NetBird client. Must be called with clientsMux held.
+// and creates an embedded NetBird client. Must be called with the account's
+// lifecycle mutex held.
func (n *NetBird) createClientEntry(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, si serviceInfo) (*clientEntry, error) {
serviceID := si.serviceID
n.logger.WithFields(log.Fields{
@@ -226,7 +321,9 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
"public_key": publicKey.String(),
}).Debug("authenticating new proxy peer with management")
- resp, err := n.mgmtClient.CreateProxyPeer(ctx, &proto.CreateProxyPeerRequest{
+ createCtx, cancel := context.WithTimeout(ctx, createProxyPeerTimeout)
+ defer cancel()
+ resp, err := n.mgmtClient.CreateProxyPeer(createCtx, &proto.CreateProxyPeerRequest{
ServiceId: string(serviceID),
AccountId: string(accountID),
Token: authToken,
@@ -264,9 +361,16 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
ManagementURL: n.clientCfg.MgmtAddr,
PrivateKey: privateKey.String(),
LogLevel: log.WarnLevel.String(),
- BlockInbound: true,
- WireguardPort: &wgPort,
- PreSharedKey: n.clientCfg.PreSharedKey,
+ BlockInbound: n.clientCfg.BlockInbound,
+ // The embedded proxy peer must never be a stepping stone into
+ // the proxy host's LAN: it only exists to reach NetBird mesh
+ // targets or, when direct_upstream is set, the host network
+ // stack via the MultiTransport's direct branch (which bypasses
+ // the engine routing entirely).
+ BlockLANAccess: true,
+ WireguardPort: &wgPort,
+ PreSharedKey: n.clientCfg.PreSharedKey,
+ Performance: n.clientCfg.Performance,
})
if err != nil {
return nil, fmt.Errorf("create netbird client: %w", err)
@@ -305,8 +409,14 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
}, nil
}
-// runClientStartup starts the client and notifies registered services on success.
-func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountID, client *embed.Client) {
+// runClientStartup starts the client and notifies registered services on
+// success. This function runs in a goroutine launched from AddPeer, so it
+// must never inherit the caller's request-scoped context — a canceled
+// request must not abort the inbound listener bring-up or the management
+// status notification. The embedded client.Start gets its own bounded
+// startCtx; once Start succeeds, notifyClientReady takes over with a
+// fresh context.Background() (see that function for the contract).
+func (n *NetBird) runClientStartup(accountID types.AccountID, client *embed.Client) {
startCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -319,7 +429,17 @@ func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountI
return
}
- // Mark client as started and collect services to notify outside the lock.
+ n.notifyClientReady(accountID, client)
+}
+
+// notifyClientReady marks the account's client as started, fires the
+// readyHandler hook, and notifies management of the new tunnel
+// connection for every registered service. It is split out of
+// runClientStartup so a regression test can drive the post-Start tail
+// without needing a live embedded client. The contract that the
+// hooks/notifier see context.Background() — never the AddPeer caller's
+// ctx — lives here.
+func (n *NetBird) notifyClientReady(accountID types.AccountID, client *embed.Client) {
n.clientsMux.Lock()
entry, exists := n.clients[accountID]
if exists {
@@ -331,13 +451,30 @@ func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountI
toNotify = append(toNotify, serviceNotification{key: key, serviceID: info.serviceID})
}
}
+ readyHandler := n.readyHandler
n.clientsMux.Unlock()
+ if readyHandler != nil {
+ state := readyHandler(n.ctx, accountID, client)
+ n.clientsMux.Lock()
+ if e, ok := n.clients[accountID]; ok {
+ e.inbound = state
+ } else if state != nil && n.stopHandler != nil {
+ // Account was removed while readyHandler ran; tear down the
+ // resources it just brought up.
+ stop := n.stopHandler
+ n.clientsMux.Unlock()
+ stop(accountID, state)
+ n.clientsMux.Lock()
+ }
+ n.clientsMux.Unlock()
+ }
+
if n.statusNotifier == nil {
return
}
for _, sn := range toNotify {
- if err := n.statusNotifier.NotifyStatus(ctx, accountID, sn.serviceID, true); err != nil {
+ if err := n.statusNotifier.NotifyStatus(n.ctx, accountID, sn.serviceID, true); err != nil {
n.logger.WithFields(log.Fields{
"account_id": accountID,
"service_key": sn.key,
@@ -354,6 +491,15 @@ func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountI
// RemovePeer unregisters a service from an account. The client is only stopped
// when no services are using it anymore.
func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key ServiceKey) error {
+ lifecycle := n.accountLifecycle(accountID)
+ lifecycle.Lock()
+ transferred := false
+ defer func() {
+ if !transferred {
+ lifecycle.Unlock()
+ }
+ }()
+
n.clientsMux.Lock()
entry, exists := n.clients[accountID]
@@ -376,13 +522,8 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key
delete(entry.services, key)
stopClient := len(entry.services) == 0
- var client *embed.Client
- var transport, insecureTransport *http.Transport
if stopClient {
n.logger.WithField("account_id", accountID).Info("stopping client, no more services")
- client = entry.client
- transport = entry.transport
- insecureTransport = entry.insecureTransport
delete(n.clients, accountID)
} else {
n.logger.WithFields(log.Fields{
@@ -396,16 +537,40 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key
n.notifyDisconnect(ctx, accountID, key, si.serviceID)
if stopClient {
- transport.CloseIdleConnections()
- insecureTransport.CloseIdleConnections()
- if err := client.Stop(ctx); err != nil {
- n.logger.WithField("account_id", accountID).WithError(err).Warn("failed to stop netbird client")
- }
+ transferred = true
+ go n.stopClientLocked(accountID, lifecycle, entry)
}
return nil
}
+// stopClientLocked releases a client's resources off the caller's goroutine so a
+// slow client.Stop cannot wedge the mapping receive loop (which calls RemovePeer
+// synchronously). It unlocks lifecycle when done so a new client.Start for the
+// same account waits for this teardown.
+func (n *NetBird) stopClientLocked(accountID types.AccountID, lifecycle *sync.Mutex, entry *clientEntry) {
+ defer lifecycle.Unlock()
+
+ if entry.inbound != nil && n.stopHandler != nil {
+ n.stopHandler(accountID, entry.inbound)
+ }
+ if entry.transport != nil {
+ entry.transport.CloseIdleConnections()
+ }
+ if entry.insecureTransport != nil {
+ entry.insecureTransport.CloseIdleConnections()
+ }
+ if entry.client == nil {
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), clientStopTimeout)
+ defer cancel()
+ if err := entry.client.Stop(ctx); err != nil {
+ n.logger.WithField("account_id", accountID).WithError(err).Warn("failed to stop netbird client")
+ }
+}
+
func (n *NetBird) notifyDisconnect(ctx context.Context, accountID types.AccountID, key ServiceKey, serviceID types.ServiceID) {
if n.statusNotifier == nil {
return
@@ -482,8 +647,12 @@ func (n *NetBird) StopAll(ctx context.Context) error {
n.clientsMux.Lock()
defer n.clientsMux.Unlock()
+ stopHandler := n.stopHandler
var merr *multierror.Error
for accountID, entry := range n.clients {
+ if entry.inbound != nil && stopHandler != nil {
+ stopHandler(accountID, entry.inbound)
+ }
entry.transport.CloseIdleConnections()
entry.insecureTransport.CloseIdleConnections()
if err := entry.client.Stop(ctx); err != nil {
@@ -536,6 +705,19 @@ func (n *NetBird) GetClient(accountID types.AccountID) (*embed.Client, bool) {
return entry.client, true
}
+// IdentityForIP resolves a tunnel IP to a peer identity local to the given
+// account. Delegates to clientEntry.IdentityForIP. Returns ok=false when
+// the account has no client or the IP is not in its peerstore.
+func (n *NetBird) IdentityForIP(accountID types.AccountID, ip netip.Addr) (pubKey, fqdn string, ok bool) {
+ n.clientsMux.RLock()
+ entry, exists := n.clients[accountID]
+ n.clientsMux.RUnlock()
+ if !exists {
+ return "", "", false
+ }
+ return entry.IdentityForIP(ip)
+}
+
// ListClientsForDebug returns information about all clients for debug purposes.
func (n *NetBird) ListClientsForDebug() map[types.AccountID]ClientDebugInfo {
n.clientsMux.RLock()
@@ -575,11 +757,12 @@ func (n *NetBird) ListClientsForStartup() map[types.AccountID]*embed.Client {
// NewNetBird creates a new NetBird transport. Set clientCfg.WGPort to 0 for a random
// OS-assigned port. A fixed port only works with single-account deployments;
// multiple accounts will fail to bind the same port.
-func NewNetBird(proxyID, proxyAddr string, clientCfg ClientConfig, logger *log.Logger, notifier statusNotifier, mgmtClient managementClient) *NetBird {
+func NewNetBird(ctx context.Context, proxyID, proxyAddr string, clientCfg ClientConfig, logger *log.Logger, notifier statusNotifier, mgmtClient managementClient) *NetBird {
if logger == nil {
logger = log.StandardLogger()
}
return &NetBird{
+ ctx: ctx,
proxyID: proxyID,
proxyAddr: proxyAddr,
clientCfg: clientCfg,
@@ -591,6 +774,18 @@ func NewNetBird(proxyID, proxyAddr string, clientCfg ClientConfig, logger *log.L
}
}
+// SetClientLifecycle registers callbacks that run when an embedded
+// client becomes ready and when its entry is torn down. The opaque value
+// returned by ready is stored on the entry and handed back to stop on
+// cleanup. Must be called before AddPeer. A nil pair leaves the
+// outbound-only behaviour intact.
+func (n *NetBird) SetClientLifecycle(ready func(ctx context.Context, accountID types.AccountID, client *embed.Client) any, stop func(accountID types.AccountID, state any)) {
+ n.clientsMux.Lock()
+ defer n.clientsMux.Unlock()
+ n.readyHandler = ready
+ n.stopHandler = stop
+}
+
// dialWithTimeout wraps a DialContext function so that any dial timeout
// stored in the context (via types.WithDialTimeout) is applied only to
// the connection establishment phase, not the full request lifetime.
@@ -633,3 +828,22 @@ func skipTLSVerifyFromContext(ctx context.Context) bool {
v, _ := ctx.Value(skipTLSVerifyContextKey{}).(bool)
return v
}
+
+// directUpstreamContextKey signals that the request should bypass the embedded
+// NetBird WireGuard client and dial via the host's network stack instead.
+// Set by the reverse-proxy rewrite step when the matched target carries
+// PathTarget.DirectUpstream; consumed by MultiTransport.
+type directUpstreamContextKey struct{}
+
+// WithDirectUpstream marks the context so MultiTransport routes the request
+// through its stdlib transport instead of the embedded NetBird roundtripper.
+func WithDirectUpstream(ctx context.Context) context.Context {
+ return context.WithValue(ctx, directUpstreamContextKey{}, true)
+}
+
+// DirectUpstreamFromContext reports whether the context has been marked to
+// bypass the embedded NetBird client.
+func DirectUpstreamFromContext(ctx context.Context) bool {
+ v, _ := ctx.Value(directUpstreamContextKey{}).(bool)
+ return v
+}
diff --git a/proxy/internal/roundtrip/netbird_test.go b/proxy/internal/roundtrip/netbird_test.go
index 5444f6c11..700cca83e 100644
--- a/proxy/internal/roundtrip/netbird_test.go
+++ b/proxy/internal/roundtrip/netbird_test.go
@@ -3,13 +3,16 @@ package roundtrip
import (
"context"
"net/http"
+ "net/netip"
"sync"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
+ "github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/proxy/internal/types"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -20,6 +23,18 @@ func (m *mockMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxy
return &proto.CreateProxyPeerResponse{Success: true}, nil
}
+// signalMgmtClient closes entered the first time CreateProxyPeer is called, so
+// tests can detect AddPeer reaching client creation.
+type signalMgmtClient struct {
+ entered chan struct{}
+ once sync.Once
+}
+
+func (m *signalMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) {
+ m.once.Do(func() { close(m.entered) })
+ return &proto.CreateProxyPeerResponse{Success: true}, nil
+}
+
type mockStatusNotifier struct {
mu sync.Mutex
statuses []statusCall
@@ -29,12 +44,15 @@ type statusCall struct {
accountID types.AccountID
serviceID types.ServiceID
connected bool
+ // ctx is captured so tests can assert the notifier received a
+ // fresh background context rather than an inherited request ctx.
+ ctx context.Context
}
-func (m *mockStatusNotifier) NotifyStatus(_ context.Context, accountID types.AccountID, serviceID types.ServiceID, connected bool) error {
+func (m *mockStatusNotifier) NotifyStatus(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, connected bool) error {
m.mu.Lock()
defer m.mu.Unlock()
- m.statuses = append(m.statuses, statusCall{accountID, serviceID, connected})
+ m.statuses = append(m.statuses, statusCall{accountID, serviceID, connected, ctx})
return nil
}
@@ -47,11 +65,15 @@ func (m *mockStatusNotifier) calls() []statusCall {
// mockNetBird creates a NetBird instance for testing without actually connecting.
// It uses an invalid management URL to prevent real connections.
func mockNetBird() *NetBird {
- return NewNetBird("test-proxy", "invalid.test", ClientConfig{
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
MgmtAddr: "http://invalid.test:9999",
WGPort: 0,
PreSharedKey: "",
}, nil, nil, &mockMgmtClient{})
+ // Skip the real embed client.Start, which would hang against the unreachable
+ // mgmt URL and (now that the lifecycle lock spans startup) serialise removes.
+ nb.startClient = func(types.AccountID, *embed.Client) {}
+ return nb
}
func TestNetBird_AddPeer_CreatesClientForNewAccount(t *testing.T) {
@@ -278,11 +300,12 @@ func TestNetBird_RoundTrip_RequiresExistingClient(t *testing.T) {
func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) {
notifier := &mockStatusNotifier{}
- nb := NewNetBird("test-proxy", "invalid.test", ClientConfig{
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
MgmtAddr: "http://invalid.test:9999",
WGPort: 0,
PreSharedKey: "",
}, nil, notifier, &mockMgmtClient{})
+ nb.startClient = func(types.AccountID, *embed.Client) {}
accountID := types.AccountID("account-1")
// Add first service — creates a new client entry.
@@ -294,8 +317,12 @@ func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) {
nb.clients[accountID].started = true
nb.clientsMux.Unlock()
- // Add second service — should notify immediately since client is already started.
- err = nb.AddPeer(context.Background(), accountID, "domain2.test", "key-1", types.ServiceID("svc-2"))
+ // Add second service with an already-cancelled caller context —
+ // should notify immediately (client is started) AND the notification
+ // must not inherit the cancelled ctx.
+ cancelledCtx, cancel := context.WithCancel(context.Background())
+ cancel()
+ err = nb.AddPeer(cancelledCtx, accountID, "domain2.test", "key-1", types.ServiceID("svc-2"))
require.NoError(t, err)
calls := notifier.calls()
@@ -303,11 +330,44 @@ func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) {
assert.Equal(t, accountID, calls[0].accountID)
assert.Equal(t, types.ServiceID("svc-2"), calls[0].serviceID)
assert.True(t, calls[0].connected)
+ require.NotNil(t, calls[0].ctx, "NotifyStatus must receive a context")
+ require.NoError(t, calls[0].ctx.Err(),
+ "already-started NotifyStatus must use a background ctx, not the cancelled caller ctx")
+}
+
+// TestNetBird_IdentityForIP_UnknownAccountReturnsFalse confirms that the
+// public lookup short-circuits when no client has been registered for
+// the queried account. The auth middleware uses ok=false as a fast deny.
+func TestNetBird_IdentityForIP_UnknownAccountReturnsFalse(t *testing.T) {
+ nb := mockNetBird()
+ _, _, ok := nb.IdentityForIP("acct-missing", netip.MustParseAddr("100.64.0.10"))
+ assert.False(t, ok, "unknown account must yield ok=false")
+}
+
+// TestClientEntry_IdentityForIP_NilClientGuard ensures the receiver
+// methods stay safe when called on partially-initialized state, which
+// can happen briefly during AddPeer setup or test fixtures.
+func TestClientEntry_IdentityForIP_NilClientGuard(t *testing.T) {
+ var e *clientEntry
+ _, _, ok := e.IdentityForIP(netip.MustParseAddr("100.64.0.10"))
+ assert.False(t, ok, "nil clientEntry must yield ok=false")
+
+ e = &clientEntry{}
+ _, _, ok = e.IdentityForIP(netip.MustParseAddr("100.64.0.10"))
+ assert.False(t, ok, "clientEntry with nil embed.Client must yield ok=false")
+}
+
+// TestClientEntry_IdentityForIP_InvalidIPReturnsFalse covers the input
+// guard so callers don't have to repeat the check.
+func TestClientEntry_IdentityForIP_InvalidIPReturnsFalse(t *testing.T) {
+ e := &clientEntry{}
+ _, _, ok := e.IdentityForIP(netip.Addr{})
+ assert.False(t, ok, "invalid IP must yield ok=false")
}
func TestNetBird_RemovePeer_NotifiesDisconnection(t *testing.T) {
notifier := &mockStatusNotifier{}
- nb := NewNetBird("test-proxy", "invalid.test", ClientConfig{
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
MgmtAddr: "http://invalid.test:9999",
WGPort: 0,
PreSharedKey: "",
@@ -329,3 +389,164 @@ func TestNetBird_RemovePeer_NotifiesDisconnection(t *testing.T) {
assert.Equal(t, types.ServiceID("svc-1"), calls[0].serviceID)
assert.False(t, calls[0].connected)
}
+
+// TestNetBird_RemovePeer_TeardownIsAsync proves the fix for the receive-loop
+// stall: RemovePeer must return promptly even when the client teardown blocks,
+// because teardown runs off the caller's goroutine. The receive loop calls
+// RemovePeer synchronously, so a blocking teardown inline would wedge it.
+func TestNetBird_RemovePeer_TeardownIsAsync(t *testing.T) {
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
+ MgmtAddr: "http://invalid.test:9999",
+ }, nil, &mockStatusNotifier{}, &mockMgmtClient{})
+
+ accountID := types.AccountID("acct-async-teardown")
+ key := DomainServiceKey("svc.example")
+
+ teardownEntered := make(chan struct{})
+ releaseTeardown := make(chan struct{})
+ nb.SetClientLifecycle(nil, func(types.AccountID, any) {
+ close(teardownEntered)
+ <-releaseTeardown
+ })
+
+ nb.clientsMux.Lock()
+ nb.clients[accountID] = &clientEntry{
+ services: map[ServiceKey]serviceInfo{key: {serviceID: types.ServiceID("svc-1")}},
+ started: true,
+ inbound: struct{}{},
+ }
+ nb.clientsMux.Unlock()
+
+ done := make(chan error, 1)
+ go func() { done <- nb.RemovePeer(context.Background(), accountID, key) }()
+
+ select {
+ case err := <-done:
+ require.NoError(t, err)
+ case <-time.After(2 * time.Second):
+ t.Fatal("RemovePeer did not return while teardown was blocked — teardown is not async")
+ }
+
+ select {
+ case <-teardownEntered:
+ case <-time.After(2 * time.Second):
+ t.Fatal("teardown never ran")
+ }
+
+ close(releaseTeardown)
+}
+
+// TestNetBird_AddPeer_WaitsForTeardown proves the lifecycle lock serialises a
+// new client bringup behind an in-flight teardown for the same account, so a
+// slow client.Stop can never race a new client.Start for that account.
+//
+// It targets the handoff race specifically: AddPeer is launched immediately
+// after RemovePeer returns, WITHOUT waiting for the teardown goroutine to start.
+// This only passes if RemovePeer acquires the lifecycle lock synchronously
+// (before returning) and hands it to the teardown goroutine — if the goroutine
+// acquired the lock itself, AddPeer could win the lock in this window and start
+// a replacement client while the old teardown is still pending.
+func TestNetBird_AddPeer_WaitsForTeardown(t *testing.T) {
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
+ MgmtAddr: "http://invalid.test:9999",
+ }, nil, &mockStatusNotifier{}, &mockMgmtClient{})
+ nb.startClient = func(types.AccountID, *embed.Client) {}
+
+ accountID := types.AccountID("acct-serialize")
+ key := DomainServiceKey("svc.example")
+
+ addEntered := make(chan struct{})
+ releaseTeardown := make(chan struct{})
+ nb.SetClientLifecycle(nil, func(types.AccountID, any) {
+ // Block teardown until released. If AddPeer ever reaches createClientEntry
+ // (signalled via the mgmt client below) while we hold the lock, the lock
+ // failed to serialise and the test fails before we release.
+ <-releaseTeardown
+ })
+
+ nb.clientsMux.Lock()
+ nb.clients[accountID] = &clientEntry{
+ services: map[ServiceKey]serviceInfo{key: {serviceID: types.ServiceID("svc-1")}},
+ started: true,
+ inbound: struct{}{},
+ }
+ nb.clientsMux.Unlock()
+
+ // createClientEntry calls CreateProxyPeer; closing addEntered there tells us
+ // AddPeer got past the lifecycle lock and into client creation.
+ nb.mgmtClient = &signalMgmtClient{entered: addEntered}
+
+ require.NoError(t, nb.RemovePeer(context.Background(), accountID, key))
+
+ // Launch AddPeer with NO synchronisation against the teardown goroutine.
+ addReturned := make(chan struct{})
+ go func() {
+ _ = nb.AddPeer(context.Background(), accountID, DomainServiceKey("svc2.example"), "key-2", types.ServiceID("svc-2"))
+ close(addReturned)
+ }()
+
+ select {
+ case <-addEntered:
+ t.Fatal("AddPeer entered client creation while teardown held the lifecycle lock — handoff race not closed")
+ case <-addReturned:
+ t.Fatal("AddPeer completed while teardown held the lifecycle lock — not serialised")
+ case <-time.After(300 * time.Millisecond):
+ }
+
+ close(releaseTeardown)
+ select {
+ case <-addReturned:
+ case <-time.After(2 * time.Second):
+ t.Fatal("AddPeer never completed after teardown released the lifecycle lock")
+ }
+}
+
+// TestNotifyClientReady_UsesBackgroundCtx pins the contract that the
+// post-Start hooks (readyHandler + statusNotifier.NotifyStatus) run on
+// a fresh context.Background() rather than inheriting the AddPeer
+// caller's request- or stream-scoped ctx. Without this, a cancelled
+// caller ctx could abort the inbound listener bring-up or cause the
+// management status notification to fail spuriously and leave the
+// account in a half-connected state.
+func TestNotifyClientReady_UsesBackgroundCtx(t *testing.T) {
+ notifier := &mockStatusNotifier{}
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
+ MgmtAddr: "http://invalid.test:9999",
+ }, nil, notifier, &mockMgmtClient{})
+
+ accountID := types.AccountID("acct-async")
+ // Pre-populate a client entry so notifyClientReady has something
+ // to mark started + something to enumerate for NotifyStatus.
+ nb.clientsMux.Lock()
+ nb.clients[accountID] = &clientEntry{
+ services: map[ServiceKey]serviceInfo{
+ DomainServiceKey("svc.example"): {serviceID: types.ServiceID("svc-1")},
+ },
+ }
+ nb.clientsMux.Unlock()
+
+ var capturedReadyCtx context.Context
+ nb.SetClientLifecycle(
+ func(ctx context.Context, _ types.AccountID, _ *embed.Client) any {
+ capturedReadyCtx = ctx
+ return nil
+ },
+ nil,
+ )
+
+ // Drive the post-Start path directly; a real client.Start would
+ // need a working management URL.
+ nb.notifyClientReady(accountID, nil)
+
+ require.NotNil(t, capturedReadyCtx, "readyHandler must have been invoked")
+ require.NoError(t, capturedReadyCtx.Err(),
+ "readyHandler must receive a background context, not an inherited cancelled one")
+ deadline, ok := capturedReadyCtx.Deadline()
+ assert.False(t, ok, "readyHandler ctx must have no deadline (background); got %v", deadline)
+
+ calls := notifier.calls()
+ require.Len(t, calls, 1, "NotifyStatus must be invoked once per registered service")
+ require.NotNil(t, calls[0].ctx, "NotifyStatus must receive a context")
+ require.NoError(t, calls[0].ctx.Err(),
+ "NotifyStatus must receive a background context, not an inherited cancelled one")
+}
diff --git a/proxy/internal/tcp/bench_test.go b/proxy/internal/tcp/bench_test.go
index 049f8395d..6c0b1eea7 100644
--- a/proxy/internal/tcp/bench_test.go
+++ b/proxy/internal/tcp/bench_test.go
@@ -36,7 +36,7 @@ func BenchmarkPeekClientHello_TLS(b *testing.B) {
for b.Loop() {
r := bytes.NewReader(hello)
conn := &readerConn{Reader: r}
- sni, wrapped, err := PeekClientHello(conn)
+ sni, wrapped, _, err := PeekClientHello(conn)
if err != nil {
b.Fatal(err)
}
@@ -59,7 +59,7 @@ func BenchmarkPeekClientHello_NonTLS(b *testing.B) {
for b.Loop() {
r := bytes.NewReader(httpReq)
conn := &readerConn{Reader: r}
- _, wrapped, err := PeekClientHello(conn)
+ _, wrapped, _, err := PeekClientHello(conn)
if err != nil {
b.Fatal(err)
}
diff --git a/proxy/internal/tcp/router.go b/proxy/internal/tcp/router.go
index 05beb658b..15c5022b0 100644
--- a/proxy/internal/tcp/router.go
+++ b/proxy/internal/tcp/router.go
@@ -100,28 +100,50 @@ type Router struct {
// httpCh is immutable after construction: set only in NewRouter, nil in NewPortRouter.
httpCh chan net.Conn
httpListener *chanListener
- mu sync.RWMutex
- routes map[SNIHost][]Route
- fallback *Route
- draining bool
- dialResolve DialResolver
- activeConns sync.WaitGroup
- activeRelays sync.WaitGroup
- relaySem chan struct{}
- drainDone chan struct{}
- observer RelayObserver
- accessLog l4Logger
- geo restrict.GeoResolver
+ // httpPlainCh feeds non-TLS HTTP connections to a parallel http.Server.
+ // Set only when NewRouter is called with WithPlainHTTP option (used by
+ // per-account inbound listeners that accept both :80 and :443 traffic).
+ // Nil for the host SNI router and for port routers.
+ httpPlainCh chan net.Conn
+ httpPlainListener *chanListener
+ mu sync.RWMutex
+ routes map[SNIHost][]Route
+ fallback *Route
+ draining bool
+ dialResolve DialResolver
+ activeConns sync.WaitGroup
+ activeRelays sync.WaitGroup
+ relaySem chan struct{}
+ drainDone chan struct{}
+ observer RelayObserver
+ accessLog l4Logger
+ geo restrict.GeoResolver
// svcCtxs tracks a context per service ID. All relay goroutines for a
// service derive from its context; canceling it kills them immediately.
svcCtxs map[types.ServiceID]context.Context
svcCancels map[types.ServiceID]context.CancelFunc
}
+// RouterOption customises Router construction.
+type RouterOption func(*Router)
+
+// WithPlainHTTP enables a parallel plain-HTTP channel on the router. When
+// set, connections whose first byte is not a TLS handshake are forwarded
+// to the plain channel returned by HTTPListenerPlain instead of the TLS
+// channel. Used by per-account inbound listeners that share both :80 and
+// :443 traffic on the same router.
+func WithPlainHTTP(addr net.Addr) RouterOption {
+ return func(r *Router) {
+ ch := make(chan net.Conn, httpChannelBuffer)
+ r.httpPlainCh = ch
+ r.httpPlainListener = newChanListener(ch, addr)
+ }
+}
+
// NewRouter creates a new SNI-based connection router.
-func NewRouter(logger *log.Logger, dialResolve DialResolver, addr net.Addr) *Router {
+func NewRouter(logger *log.Logger, dialResolve DialResolver, addr net.Addr, opts ...RouterOption) *Router {
httpCh := make(chan net.Conn, httpChannelBuffer)
- return &Router{
+ r := &Router{
logger: logger,
httpCh: httpCh,
httpListener: newChanListener(httpCh, addr),
@@ -131,6 +153,10 @@ func NewRouter(logger *log.Logger, dialResolve DialResolver, addr net.Addr) *Rou
svcCtxs: make(map[types.ServiceID]context.Context),
svcCancels: make(map[types.ServiceID]context.CancelFunc),
}
+ for _, opt := range opts {
+ opt(r)
+ }
+ return r
}
// NewPortRouter creates a Router for a dedicated port without an HTTP
@@ -153,6 +179,16 @@ func (r *Router) HTTPListener() net.Listener {
return r.httpListener
}
+// HTTPListenerPlain returns a net.Listener yielding non-TLS connections
+// for use with a parallel plain http.Server. Returns nil when the router
+// was not constructed with WithPlainHTTP.
+func (r *Router) HTTPListenerPlain() net.Listener {
+ if r.httpPlainListener == nil {
+ return nil
+ }
+ return r.httpPlainListener
+}
+
// AddRoute registers an SNI route. Multiple routes for the same host are
// stored and resolved by priority at lookup time (HTTP > TCP).
// Empty host is ignored to prevent conflicts with ECH/ESNI fallback.
@@ -254,6 +290,9 @@ func (r *Router) Serve(ctx context.Context, ln net.Listener) error {
if r.httpListener != nil {
r.httpListener.Close()
}
+ if r.httpPlainListener != nil {
+ r.httpPlainListener.Close()
+ }
case <-done:
}
}()
@@ -270,6 +309,7 @@ func (r *Router) Serve(ctx context.Context, ln net.Listener) error {
r.logger.Debugf("SNI router accept: %v", err)
continue
}
+ r.logger.Debugf("SNI router accepted conn from %s on %s", conn.RemoteAddr(), conn.LocalAddr())
r.activeConns.Add(1)
go func() {
defer r.activeConns.Done()
@@ -278,13 +318,24 @@ func (r *Router) Serve(ctx context.Context, ln net.Listener) error {
}
}
+// HandleConn lets external accept loops feed a connection through the
+// router's peek-and-dispatch logic. Use this when the same router serves
+// a secondary listener (for example, a per-account inbound :80 socket
+// alongside its :443 socket).
+func (r *Router) HandleConn(ctx context.Context, conn net.Conn) {
+ r.activeConns.Add(1)
+ defer r.activeConns.Done()
+ r.handleConn(ctx, conn)
+}
+
// handleConn peeks at the TLS ClientHello and routes the connection.
func (r *Router) handleConn(ctx context.Context, conn net.Conn) {
// Fast path: when no SNI routes and no HTTP channel exist (pure TCP
// fallback port), skip the TLS peek entirely to avoid read errors on
// non-TLS connections and reduce latency.
if r.isFallbackOnly() {
- r.handleUnmatched(ctx, conn)
+ r.logger.Debugf("SNI router fallback-only mode for conn from %s; skipping ClientHello peek", conn.RemoteAddr())
+ r.handleUnmatched(ctx, conn, false)
return
}
@@ -294,11 +345,11 @@ func (r *Router) handleConn(ctx context.Context, conn net.Conn) {
return
}
- sni, wrapped, err := PeekClientHello(conn)
+ sni, wrapped, isTLS, err := PeekClientHello(conn)
if err != nil {
- r.logger.Debugf("SNI peek: %v", err)
+ r.logger.Debugf("SNI peek failed for conn from %s: %v", conn.RemoteAddr(), err)
if wrapped != nil {
- r.handleUnmatched(ctx, wrapped)
+ r.handleUnmatched(ctx, wrapped, isTLS)
} else {
_ = conn.Close()
}
@@ -313,13 +364,20 @@ func (r *Router) handleConn(ctx context.Context, conn net.Conn) {
host := SNIHost(strings.ToLower(sni))
route, ok := r.lookupRoute(host)
+ r.logger.WithFields(log.Fields{
+ "remote": wrapped.RemoteAddr().String(),
+ "sni": string(host),
+ "match": ok,
+ "tls": isTLS,
+ }).Debug("SNI route lookup")
if !ok {
- r.handleUnmatched(ctx, wrapped)
+ r.handleUnmatched(ctx, wrapped, isTLS)
return
}
if route.Type == RouteHTTP {
- r.sendToHTTP(wrapped)
+ r.logger.Debugf("SNI %q routed to HTTP handler (service_id=%s)", host, route.ServiceID)
+ r.sendToHTTP(wrapped, isTLS)
return
}
@@ -344,15 +402,17 @@ func (r *Router) isFallbackOnly() bool {
}
// handleUnmatched routes a connection that didn't match any SNI route.
-// This includes ECH/ESNI connections where the cleartext SNI is empty.
+// This includes ECH/ESNI connections where the cleartext SNI is empty,
+// and plain (non-TLS) HTTP connections when isTLS is false.
// It tries the fallback relay first, then the HTTP channel, and closes
// the connection if neither is available.
-func (r *Router) handleUnmatched(ctx context.Context, conn net.Conn) {
+func (r *Router) handleUnmatched(ctx context.Context, conn net.Conn, isTLS bool) {
r.mu.RLock()
fb := r.fallback
r.mu.RUnlock()
if fb != nil {
+ r.logger.Debugf("unmatched conn from %s relayed to TCP fallback (service_id=%s, target=%s)", conn.RemoteAddr(), fb.ServiceID, fb.Target)
if err := r.relayTCP(ctx, conn, SNIHost("fallback"), *fb); err != nil {
if !errors.Is(err, errAccessRestricted) {
r.logger.WithFields(log.Fields{
@@ -364,7 +424,8 @@ func (r *Router) handleUnmatched(ctx context.Context, conn net.Conn) {
}
return
}
- r.sendToHTTP(conn)
+ r.logger.Debugf("unmatched conn from %s sent to HTTP channel (no TCP fallback configured)", conn.RemoteAddr())
+ r.sendToHTTP(conn, isTLS)
}
// lookupRoute returns the highest-priority route for the given SNI host.
@@ -386,10 +447,20 @@ func (r *Router) lookupRoute(host SNIHost) (Route, bool) {
}
// sendToHTTP feeds the connection to the HTTP handler via the channel.
-// If no HTTP channel is configured (port router), the router is
-// draining, or the channel is full, the connection is closed.
-func (r *Router) sendToHTTP(conn net.Conn) {
- if r.httpCh == nil {
+// When isTLS is false and a plain channel is configured the connection
+// is forwarded to the plain channel; otherwise it lands on the TLS
+// channel. If no usable channel exists, the router is draining, or the
+// channel is full, the connection is closed.
+func (r *Router) sendToHTTP(conn net.Conn, isTLS bool) {
+ ch := r.httpCh
+ chanName := "HTTP"
+ if !isTLS && r.httpPlainCh != nil {
+ ch = r.httpPlainCh
+ chanName = "HTTP-plain"
+ }
+
+ if ch == nil {
+ r.logger.Debugf("%s channel nil; dropping conn from %s", chanName, conn.RemoteAddr())
_ = conn.Close()
return
}
@@ -399,14 +470,15 @@ func (r *Router) sendToHTTP(conn net.Conn) {
r.mu.RUnlock()
if draining {
+ r.logger.Debugf("router draining; dropping conn from %s", conn.RemoteAddr())
_ = conn.Close()
return
}
select {
- case r.httpCh <- conn:
+ case ch <- conn:
default:
- r.logger.Warnf("HTTP channel full, dropping connection from %s", conn.RemoteAddr())
+ r.logger.Warnf("%s channel full, dropping connection from %s", chanName, conn.RemoteAddr())
_ = conn.Close()
}
}
diff --git a/proxy/internal/tcp/router_test.go b/proxy/internal/tcp/router_test.go
index 93b6560f4..ea1b418f5 100644
--- a/proxy/internal/tcp/router_test.go
+++ b/proxy/internal/tcp/router_test.go
@@ -1739,3 +1739,100 @@ func TestCheckRestrictions_IPv4MappedIPv6(t *testing.T) {
connOutside := &fakeConn{remote: fakeAddr("[::ffff:192.168.1.1]:5678")}
assert.NotEqual(t, restrict.Allow, router.checkRestrictions(connOutside, route), "::ffff:192.168.1.1 not in v4 CIDR")
}
+
+// TestRouter_PlainHTTP_RoutesToPlainChannel verifies that a plain (non-TLS)
+// connection lands on the plain HTTP channel when the router was built
+// with WithPlainHTTP, leaving the TLS channel untouched.
+func TestRouter_PlainHTTP_RoutesToPlainChannel(t *testing.T) {
+ logger := log.StandardLogger()
+ addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443}
+
+ router := NewRouter(logger, nil, addr, WithPlainHTTP(addr))
+ router.AddRoute("example.com", Route{Type: RouteHTTP})
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "test listener bind must succeed")
+ defer ln.Close()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go func() {
+ _ = router.Serve(ctx, ln)
+ }()
+
+ // Plain HTTP request (no TLS handshake byte).
+ go func() {
+ conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second)
+ if err != nil {
+ return
+ }
+ _, _ = conn.Write([]byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"))
+ }()
+
+ plainListener := router.HTTPListenerPlain()
+ require.NotNil(t, plainListener, "plain listener must be exposed when WithPlainHTTP is set")
+
+ acceptDone := make(chan net.Conn, 1)
+ go func() {
+ conn, err := plainListener.Accept()
+ if err == nil {
+ acceptDone <- conn
+ }
+ }()
+
+ tlsListener, ok := router.HTTPListener().(*chanListener)
+ require.True(t, ok, "router.HTTPListener() must be the test's chanListener; the test relies on observing its channel directly")
+
+ select {
+ case conn := <-acceptDone:
+ require.NotNil(t, conn)
+ _ = conn.Close()
+ case <-tlsListener.ch:
+ t.Fatal("plain HTTP request leaked into TLS channel")
+ case <-time.After(3 * time.Second):
+ t.Fatal("plain HTTP connection never reached plain channel")
+ }
+}
+
+// TestRouter_TLS_StaysOnTLSChannel_WhenPlainEnabled verifies that the
+// presence of a plain channel does not divert TLS traffic — TLS still
+// goes to the TLS channel as before.
+func TestRouter_TLS_StaysOnTLSChannel_WhenPlainEnabled(t *testing.T) {
+ logger := log.StandardLogger()
+ addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443}
+
+ router := NewRouter(logger, nil, addr, WithPlainHTTP(addr))
+ router.AddRoute("example.com", Route{Type: RouteHTTP})
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err, "test listener bind must succeed")
+ defer ln.Close()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go func() { _ = router.Serve(ctx, ln) }()
+
+ // Send a TLS ClientHello.
+ go func() {
+ conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second)
+ if err != nil {
+ return
+ }
+ tlsConn := tls.Client(conn, &tls.Config{
+ ServerName: "example.com",
+ InsecureSkipVerify: true, //nolint:gosec
+ })
+ _ = tlsConn.Handshake()
+ _ = tlsConn.Close()
+ }()
+
+ select {
+ case conn := <-router.httpCh:
+ require.NotNil(t, conn, "TLS conn should land on the TLS channel")
+ _ = conn.Close()
+ case <-time.After(5 * time.Second):
+ t.Fatal("TLS conn never reached the TLS channel")
+ }
+}
diff --git a/proxy/internal/tcp/snipeek.go b/proxy/internal/tcp/snipeek.go
index 25ab8e5ef..dc3c96498 100644
--- a/proxy/internal/tcp/snipeek.go
+++ b/proxy/internal/tcp/snipeek.go
@@ -30,26 +30,30 @@ const (
// bytes transparently. If the data is not a valid TLS ClientHello or
// contains no SNI extension, sni is empty and err is nil.
//
+// isTLS reports whether the first byte indicated a TLS handshake record.
+// Callers can use this to distinguish plain (non-TLS) traffic from a TLS
+// stream that simply lacked an SNI extension or used ECH.
+//
// ECH/ESNI: When the client uses Encrypted Client Hello (TLS 1.3), the
// real server name is encrypted inside the encrypted_client_hello
// extension. This parser only reads the cleartext server_name extension
// (type 0x0000), so ECH connections return sni="" and are routed through
// the fallback path (or HTTP channel), which is the correct behavior
// for a transparent proxy that does not terminate TLS.
-func PeekClientHello(conn net.Conn) (sni string, wrapped net.Conn, err error) {
+func PeekClientHello(conn net.Conn) (sni string, wrapped net.Conn, isTLS bool, err error) {
// Read the 5-byte TLS record header into a small stack-friendly buffer.
var header [tlsRecordHeaderLen]byte
if _, err := io.ReadFull(conn, header[:]); err != nil {
- return "", nil, fmt.Errorf("read TLS record header: %w", err)
+ return "", nil, false, fmt.Errorf("read TLS record header: %w", err)
}
if header[0] != contentTypeHandshake {
- return "", newPeekedConn(conn, header[:]), nil
+ return "", newPeekedConn(conn, header[:]), false, nil
}
recordLen := int(binary.BigEndian.Uint16(header[3:5]))
if recordLen == 0 || recordLen > maxClientHelloLen {
- return "", newPeekedConn(conn, header[:]), nil
+ return "", newPeekedConn(conn, header[:]), true, nil
}
// Single allocation for header + payload. The peekedConn takes
@@ -59,11 +63,11 @@ func PeekClientHello(conn net.Conn) (sni string, wrapped net.Conn, err error) {
n, err := io.ReadFull(conn, buf[tlsRecordHeaderLen:])
if err != nil {
- return "", newPeekedConn(conn, buf[:tlsRecordHeaderLen+n]), fmt.Errorf("read TLS handshake payload: %w", err)
+ return "", newPeekedConn(conn, buf[:tlsRecordHeaderLen+n]), true, fmt.Errorf("read TLS handshake payload: %w", err)
}
sni = extractSNI(buf[tlsRecordHeaderLen:])
- return sni, newPeekedConn(conn, buf), nil
+ return sni, newPeekedConn(conn, buf), true, nil
}
// extractSNI parses a TLS handshake payload to find the SNI extension.
diff --git a/proxy/internal/tcp/snipeek_test.go b/proxy/internal/tcp/snipeek_test.go
index 9afe6261d..85dee15c1 100644
--- a/proxy/internal/tcp/snipeek_test.go
+++ b/proxy/internal/tcp/snipeek_test.go
@@ -29,10 +29,11 @@ func TestPeekClientHello_ValidSNI(t *testing.T) {
_ = tlsConn.Handshake()
}()
- sni, wrapped, err := PeekClientHello(serverConn)
+ sni, wrapped, isTLS, err := PeekClientHello(serverConn)
require.NoError(t, err)
assert.Equal(t, expectedSNI, sni, "should extract SNI from ClientHello")
assert.NotNil(t, wrapped, "wrapped connection should not be nil")
+ assert.True(t, isTLS, "TLS ClientHello should be flagged as TLS")
// Verify the wrapped connection replays the peeked bytes.
// Read the first 5 bytes (TLS record header) to confirm replay.
@@ -83,10 +84,11 @@ func TestPeekClientHello_MultipleSNIs(t *testing.T) {
_ = tlsConn.Handshake()
}()
- sni, wrapped, err := PeekClientHello(serverConn)
+ sni, wrapped, isTLS, err := PeekClientHello(serverConn)
require.NoError(t, err)
assert.Equal(t, tt.expectedSNI, sni)
assert.NotNil(t, wrapped)
+ assert.True(t, isTLS, "TLS handshake should be flagged as TLS")
})
}
}
@@ -102,10 +104,11 @@ func TestPeekClientHello_NonTLSData(t *testing.T) {
_, _ = clientConn.Write(httpData)
}()
- sni, wrapped, err := PeekClientHello(serverConn)
+ sni, wrapped, isTLS, err := PeekClientHello(serverConn)
require.NoError(t, err)
assert.Empty(t, sni, "should return empty SNI for non-TLS data")
assert.NotNil(t, wrapped)
+ assert.False(t, isTLS, "plain HTTP data should not be flagged as TLS")
// Verify the wrapped connection still provides the original data.
buf := make([]byte, len(httpData))
@@ -124,7 +127,7 @@ func TestPeekClientHello_TruncatedHeader(t *testing.T) {
clientConn.Close()
}()
- _, _, err := PeekClientHello(serverConn)
+ _, _, _, err := PeekClientHello(serverConn)
assert.Error(t, err, "should error on truncated header")
}
@@ -140,7 +143,7 @@ func TestPeekClientHello_TruncatedPayload(t *testing.T) {
clientConn.Close()
}()
- _, _, err := PeekClientHello(serverConn)
+ _, _, _, err := PeekClientHello(serverConn)
assert.Error(t, err, "should error on truncated payload")
}
@@ -154,10 +157,11 @@ func TestPeekClientHello_ZeroLengthRecord(t *testing.T) {
_, _ = clientConn.Write([]byte{0x16, 0x03, 0x01, 0x00, 0x00})
}()
- sni, wrapped, err := PeekClientHello(serverConn)
+ sni, wrapped, isTLS, err := PeekClientHello(serverConn)
require.NoError(t, err)
assert.Empty(t, sni)
assert.NotNil(t, wrapped)
+ assert.True(t, isTLS, "zero-length record should still be a TLS handshake byte")
}
func TestExtractSNI_InvalidPayload(t *testing.T) {
diff --git a/proxy/internal/types/types.go b/proxy/internal/types/types.go
index bf3731803..ffdbf2301 100644
--- a/proxy/internal/types/types.go
+++ b/proxy/internal/types/types.go
@@ -54,3 +54,23 @@ func DialTimeoutFromContext(ctx context.Context) (time.Duration, bool) {
d, ok := ctx.Value(dialTimeoutKey{}).(time.Duration)
return d, ok && d > 0
}
+
+// overlayOriginKey is the context key set by per-account inbound
+// listeners to mark a request as originating from the WireGuard
+// overlay rather than the public-facing host listener.
+type overlayOriginKey struct{}
+
+// WithOverlayOrigin marks the context as originating from the
+// embedded NetBird overlay (tunnel-side inbound listener).
+func WithOverlayOrigin(ctx context.Context) context.Context {
+ return context.WithValue(ctx, overlayOriginKey{}, true)
+}
+
+// IsOverlayOrigin reports whether the request reached the proxy via
+// the overlay listener. Middlewares that only make sense for WAN
+// traffic (geolocation, CrowdSec IP reputation) should short-circuit
+// when this is true.
+func IsOverlayOrigin(ctx context.Context) bool {
+ v, _ := ctx.Value(overlayOriginKey{}).(bool)
+ return v
+}
diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go
new file mode 100644
index 000000000..0d4aded9c
--- /dev/null
+++ b/proxy/lifecycle.go
@@ -0,0 +1,176 @@
+package proxy
+
+import (
+ "context"
+ "net/netip"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/embed"
+ "github.com/netbirdio/netbird/proxy/internal/acme"
+)
+
+// Config bundles every knob the proxy reads at construction time. It mirrors
+// the public fields on Server so library callers don't have to learn the
+// internal struct layout. Zero values mean "feature off" or "fall back to the
+// internal default" depending on the field — see the per-field doc.
+//
+// The standalone binary continues to populate Server fields directly, so
+// adding fields here must not change the zero-value behaviour of Server.
+type Config struct {
+ // ListenAddr is the TCP address the main listener binds. Required.
+ ListenAddr string
+ // ID identifies this proxy instance to management. Empty values are
+ // replaced with a timestamped default at Server.Start time (see
+ // initDefaults), not in New.
+ ID string
+ // Logger is the logrus logger used everywhere. Empty values fall
+ // back to log.StandardLogger() at Server.Start time (see
+ // initDefaults), not in New.
+ Logger *log.Logger
+ // Version is the build version string reported to management. Empty
+ // values are replaced with "dev" at Server.Start time (see
+ // initDefaults), not in New.
+ Version string
+ // ProxyURL is the public address operators use to reach this proxy.
+ ProxyURL string
+ // ManagementAddress is the gRPC URL of the management server.
+ ManagementAddress string
+ // ProxyToken authenticates this proxy with the management server.
+ ProxyToken string
+
+ // CertificateDirectory is the directory holding TLS certificate
+ // material (static or ACME-provisioned).
+ CertificateDirectory string
+ // CertificateFile is the certificate filename within
+ // CertificateDirectory.
+ CertificateFile string
+ // CertificateKeyFile is the private key filename within
+ // CertificateDirectory.
+ CertificateKeyFile string
+ // GenerateACMECertificates toggles ACME certificate provisioning.
+ GenerateACMECertificates bool
+ // ACMEChallengeAddress is the listen address for HTTP-01 challenges.
+ ACMEChallengeAddress string
+ // ACMEDirectory is the ACME directory URL (Let's Encrypt by default).
+ ACMEDirectory string
+ // ACMEEABKID is the External Account Binding Key ID for CAs that
+ // require EAB (e.g. ZeroSSL).
+ ACMEEABKID string
+ // ACMEEABHMACKey is the External Account Binding HMAC key for CAs
+ // that require EAB.
+ ACMEEABHMACKey string
+ // ACMEChallengeType is the ACME challenge type ("tls-alpn-01" or
+ // "http-01"). Empty defaults to "tls-alpn-01".
+ ACMEChallengeType string
+ // CertLockMethod controls how ACME certificate locks are coordinated
+ // across replicas.
+ CertLockMethod acme.CertLockMethod
+ // WildcardCertDir is an optional directory containing static wildcard
+ // certificates that override ACME for matching domains.
+ WildcardCertDir string
+
+ // DebugEndpointEnabled toggles the debug HTTP endpoint.
+ DebugEndpointEnabled bool
+ // DebugEndpointAddress is the bind address for the debug endpoint.
+ DebugEndpointAddress string
+ // HealthAddr is the bind address for the health probe and metrics
+ // surface. Empty disables the health probe entirely (library callers
+ // can attach their own).
+ HealthAddr string
+
+ // ForwardedProto overrides the X-Forwarded-Proto value sent to
+ // backends. Valid values: "auto", "http", "https".
+ ForwardedProto string
+ // TrustedProxies is a list of IP prefixes for trusted upstream
+ // proxies that may set forwarding headers.
+ TrustedProxies []netip.Prefix
+ // WireguardPort is the UDP port for the embedded NetBird tunnel.
+ // Zero asks the OS for a random port.
+ WireguardPort uint16
+ // ProxyProtocol enables PROXY protocol (v1/v2) on TCP listeners.
+ ProxyProtocol bool
+ // PreSharedKey is the WireGuard pre-shared key used between the
+ // proxy's embedded clients and peers.
+ PreSharedKey string
+ // Performance configures the tunnel pool/batch sizes for every
+ // embedded client this proxy creates. Zero values fall back to
+ // upstream defaults.
+ Performance embed.Performance
+
+ // SupportsCustomPorts indicates whether the proxy can bind arbitrary
+ // ports for TCP/UDP/TLS services.
+ SupportsCustomPorts bool
+ // RequireSubdomain forces accounts to use a subdomain in front of
+ // the proxy's cluster domain.
+ RequireSubdomain bool
+ // Private flags this proxy as embedded in a netbird client and
+ // serving exclusively over the WireGuard tunnel. Also enables
+ // per-account inbound listeners on each embedded client's netstack.
+ Private bool
+
+ // MaxDialTimeout caps the per-service backend dial timeout.
+ MaxDialTimeout time.Duration
+ // MaxSessionIdleTimeout caps the per-service session idle timeout.
+ MaxSessionIdleTimeout time.Duration
+ // MappingBatchWatchdog bounds how long a single mapping batch may spend
+ // being applied before the receive loop reconnects to resync. Zero falls
+ // back to the internal default.
+ MappingBatchWatchdog time.Duration
+
+ // GeoDataDir is the directory containing GeoLite2 MMDB files.
+ GeoDataDir string
+ // CrowdSecAPIURL is the CrowdSec LAPI URL. Empty disables CrowdSec.
+ CrowdSecAPIURL string
+ // CrowdSecAPIKey is the CrowdSec bouncer API key. Empty disables
+ // CrowdSec.
+ CrowdSecAPIKey string
+}
+
+// New builds a Server from cfg without performing any I/O. No goroutines
+// are spawned, no network connections are dialed, and no listeners are
+// bound — call Start to bring the proxy up. Returning a fully-formed
+// Server keeps the standalone code path (which still constructs Server
+// directly) byte-for-byte equivalent.
+func New(ctx context.Context, cfg Config) *Server {
+ return &Server{
+ ctx: ctx,
+ ListenAddr: cfg.ListenAddr,
+ ID: cfg.ID,
+ Logger: cfg.Logger,
+ Version: cfg.Version,
+ ProxyURL: cfg.ProxyURL,
+ ManagementAddress: cfg.ManagementAddress,
+ ProxyToken: cfg.ProxyToken,
+ CertificateDirectory: cfg.CertificateDirectory,
+ CertificateFile: cfg.CertificateFile,
+ CertificateKeyFile: cfg.CertificateKeyFile,
+ GenerateACMECertificates: cfg.GenerateACMECertificates,
+ ACMEChallengeAddress: cfg.ACMEChallengeAddress,
+ ACMEDirectory: cfg.ACMEDirectory,
+ ACMEEABKID: cfg.ACMEEABKID,
+ ACMEEABHMACKey: cfg.ACMEEABHMACKey,
+ ACMEChallengeType: cfg.ACMEChallengeType,
+ CertLockMethod: cfg.CertLockMethod,
+ WildcardCertDir: cfg.WildcardCertDir,
+ DebugEndpointEnabled: cfg.DebugEndpointEnabled,
+ DebugEndpointAddress: cfg.DebugEndpointAddress,
+ HealthAddress: cfg.HealthAddr,
+ ForwardedProto: cfg.ForwardedProto,
+ TrustedProxies: cfg.TrustedProxies,
+ WireguardPort: cfg.WireguardPort,
+ ProxyProtocol: cfg.ProxyProtocol,
+ PreSharedKey: cfg.PreSharedKey,
+ Performance: cfg.Performance,
+ SupportsCustomPorts: cfg.SupportsCustomPorts,
+ RequireSubdomain: cfg.RequireSubdomain,
+ Private: cfg.Private,
+ MaxDialTimeout: cfg.MaxDialTimeout,
+ MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout,
+ MappingBatchWatchdog: cfg.MappingBatchWatchdog,
+ GeoDataDir: cfg.GeoDataDir,
+ CrowdSecAPIURL: cfg.CrowdSecAPIURL,
+ CrowdSecAPIKey: cfg.CrowdSecAPIKey,
+ }
+}
diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go
index d7e891801..bf5067b85 100644
--- a/proxy/management_integration_test.go
+++ b/proxy/management_integration_test.go
@@ -239,6 +239,10 @@ func (m *testProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string)
return nil
}
+func (m *testProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
+ return nil
+}
+
func (m *testProxyManager) CleanupStale(_ context.Context, _ time.Duration) error {
return nil
}
@@ -565,6 +569,7 @@ func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T
proxytypes.AccountID(mapping.GetAccountId()),
proxytypes.ServiceID(mapping.GetId()),
nil,
+ mapping.GetPrivate(),
)
require.NoError(t, err)
diff --git a/proxy/mapping_stall_test.go b/proxy/mapping_stall_test.go
new file mode 100644
index 000000000..acf313d19
--- /dev/null
+++ b/proxy/mapping_stall_test.go
@@ -0,0 +1,282 @@
+package proxy
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
+
+ "github.com/netbirdio/netbird/proxy/internal/roundtrip"
+ "github.com/netbirdio/netbird/proxy/internal/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// blockingMgmtClient implements roundtrip's managementClient interface.
+// CreateProxyPeer parks until release is closed, signalling entry on entered.
+// This reproduces the confirmed real-world stall: createClientEntry calls
+// CreateProxyPeer synchronously while holding clientsMux, and the proxy's
+// receive loop calls that path synchronously inside processMappings.
+type blockingMgmtClient struct {
+ entered chan struct{}
+ once sync.Once
+}
+
+func (b *blockingMgmtClient) CreateProxyPeer(ctx context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) {
+ b.once.Do(func() { close(b.entered) })
+ // Park until the caller's context is cancelled. In production this ctx is
+ // the gRPC mapping-stream context with no per-call timeout, so a slow or
+ // unresponsive CreateProxyPeer parks the receive loop here indefinitely.
+ <-ctx.Done()
+ return nil, ctx.Err()
+}
+
+// gatedMappingStream is a mock GetMappingUpdate client stream that hands out a
+// pre-seeded list of messages, then records how many times Recv advanced. It
+// lets the test observe whether the single-threaded receive loop ever gets
+// past the first (blocking) batch to pull the second message.
+type gatedMappingStream struct {
+ grpc.ClientStream
+ messages []*proto.GetMappingUpdateResponse
+ idx int32
+}
+
+func (g *gatedMappingStream) Recv() (*proto.GetMappingUpdateResponse, error) {
+ i := int(atomic.LoadInt32(&g.idx))
+ if i >= len(g.messages) {
+ // Block instead of returning EOF so the loop doesn't exit; we only
+ // care whether the loop ever reaches this second Recv at all.
+ select {}
+ }
+ msg := g.messages[i]
+ atomic.AddInt32(&g.idx, 1)
+ return msg, nil
+}
+
+func (g *gatedMappingStream) deliveredCount() int32 { return atomic.LoadInt32(&g.idx) }
+
+func (g *gatedMappingStream) Header() (metadata.MD, error) { return nil, nil } //nolint:nilnil
+func (g *gatedMappingStream) Trailer() metadata.MD { return nil }
+func (g *gatedMappingStream) CloseSend() error { return nil }
+func (g *gatedMappingStream) Context() context.Context { return context.Background() }
+func (g *gatedMappingStream) SendMsg(any) error { return nil }
+func (g *gatedMappingStream) RecvMsg(any) error { return nil }
+
+// noopNotifier satisfies roundtrip's statusNotifier interface.
+type noopNotifier struct{}
+
+func (noopNotifier) NotifyStatus(context.Context, types.AccountID, types.ServiceID, bool) error {
+ return nil
+}
+
+// noopProxyClient is a proto.ProxyServiceClient that no-ops the one method the
+// teardown unwind reaches (SendStatusUpdate, via notifyError when the parked
+// AddPeer is cancelled). The embedded nil interface satisfies the rest at
+// compile time; none of those methods are called by this test.
+type noopProxyClient struct {
+ proto.ProxyServiceClient
+}
+
+func (noopProxyClient) SendStatusUpdate(context.Context, *proto.SendStatusUpdateRequest, ...grpc.CallOption) (*proto.SendStatusUpdateResponse, error) {
+ return &proto.SendStatusUpdateResponse{}, nil
+}
+
+// TestMappingStream_StallsWhenApplyBlocks proves the deadlock: the proxy's
+// mapping receive loop processes batches strictly serially, so when applying
+// one batch blocks (here: createClientEntry parked on a synchronous
+// CreateProxyPeer call, exactly as observed in production), the loop never
+// advances to Recv the next batch. Management can keep sending updates onto
+// the stream with no error and no channel overflow, yet the proxy applies
+// nothing further — it is stuck.
+func TestMappingStream_StallsWhenApplyBlocks(t *testing.T) {
+ logger := log.New()
+ logger.SetLevel(log.PanicLevel)
+
+ mgmt := &blockingMgmtClient{
+ entered: make(chan struct{}),
+ }
+
+ nb := roundtrip.NewNetBird(
+ context.Background(),
+ "proxy-test",
+ "proxy.example.com",
+ roundtrip.ClientConfig{},
+ logger,
+ noopNotifier{},
+ mgmt,
+ )
+
+ s := &Server{
+ Logger: logger,
+ netbird: nb,
+ mgmtClient: noopProxyClient{},
+ routerReady: closedChan(),
+ lastMappings: make(map[types.ServiceID]*proto.ProxyMapping),
+ }
+
+ // First batch: a CREATED mapping for a brand-new account. addMapping ->
+ // netbird.AddPeer -> createClientEntry -> CreateProxyPeer, which blocks.
+ // Empty Path keeps setupHTTPMapping a no-op (it returns early), so the
+ // ONLY blocking point is the synchronous CreateProxyPeer in AddPeer —
+ // no routers/auth need wiring. The second batch exists only to detect
+ // whether the loop ever advances past the blocked first batch.
+ stream := &gatedMappingStream{
+ messages: []*proto.GetMappingUpdateResponse{
+ {
+ Mapping: []*proto.ProxyMapping{
+ {
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: "svc-1",
+ AccountId: "acct-1",
+ AuthToken: "token-1",
+ },
+ },
+ },
+ {
+ Mapping: []*proto.ProxyMapping{
+ {
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: "svc-2",
+ AccountId: "acct-2",
+ AuthToken: "token-2",
+ },
+ },
+ },
+ },
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ // Unblock the parked apply on teardown via ctx (CreateProxyPeer returns
+ // ctx.Err()), so the wedged loop goroutine unwinds before embed.New —
+ // avoiding any dependency on collaborators this test deliberately leaves
+ // nil. The deadlock is fully proven before this fires.
+ t.Cleanup(cancel)
+
+ loopDone := make(chan struct{})
+ syncDone := false
+ go func() {
+ defer close(loopDone)
+ _ = s.handleMappingStream(ctx, stream, &syncDone, time.Time{})
+ }()
+
+ // The loop must reach the blocking apply for the first batch.
+ select {
+ case <-mgmt.entered:
+ case <-time.After(2 * time.Second):
+ t.Fatal("receive loop never reached CreateProxyPeer for the first batch")
+ }
+
+ // THE DEADLOCK: while the first batch is parked in CreateProxyPeer, the
+ // single-threaded loop cannot advance. The second batch is never pulled,
+ // even though it is already available on the stream. Give it ample time.
+ // deliveredCount is atomic; syncDone is intentionally not read here because
+ // the loop goroutine owns it (reading it from the test would race).
+ time.Sleep(500 * time.Millisecond)
+ assert.Equal(t, int32(1), stream.deliveredCount(),
+ "loop must NOT consume the second batch while the first is blocked in apply — proxy is stuck")
+
+ select {
+ case <-loopDone:
+ t.Fatal("receive loop returned while it should be wedged in apply")
+ default:
+ // Still wedged, as expected.
+ }
+}
+
+// TestMappingStream_StallsWhenRemoveBlocks proves the deadlock for the REMOVE
+// path observed in production: a mapping remove tears down the account's last
+// embedded client via netbird.RemovePeer -> client.Stop -> Engine.Stop, whose
+// jobExecutorWG.Wait() is unbounded. Because the receive loop is single-
+// threaded, a blocked remove wedges the loop: no further mapping updates of any
+// kind (create/modify/remove) are applied, while management keeps sending them
+// successfully (no send error, no channel-full). Matches the reported symptom:
+// the last log line is a remove that stops a client, then silence.
+func TestMappingStream_StallsWhenRemoveBlocks(t *testing.T) {
+ logger := log.New()
+ logger.SetLevel(log.PanicLevel)
+
+ enteredRemove := make(chan struct{})
+ blockRemove := make(chan struct{})
+ var once sync.Once
+
+ s := &Server{
+ Logger: logger,
+ mgmtClient: noopProxyClient{},
+ routerReady: closedChan(),
+ lastMappings: make(map[types.ServiceID]*proto.ProxyMapping),
+ // Stand in for netbird.RemovePeer -> client.Stop hanging on
+ // Engine.Stop's unbounded jobExecutorWG.Wait(). Only the first remove
+ // blocks; later removes return immediately so the recovery assertion
+ // can observe the loop advancing.
+ removePeer: func(ctx context.Context, _ types.AccountID, _ roundtrip.ServiceKey) error {
+ first := false
+ once.Do(func() {
+ first = true
+ close(enteredRemove)
+ })
+ if !first {
+ return nil
+ }
+ select {
+ case <-blockRemove:
+ case <-ctx.Done():
+ }
+ return nil
+ },
+ }
+
+ // Batch 1 removes a service (blocks in teardown). Batch 2 is a later update
+ // that must never be applied while the remove is wedged.
+ stream := &gatedMappingStream{
+ messages: []*proto.GetMappingUpdateResponse{
+ {
+ Mapping: []*proto.ProxyMapping{
+ {Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, Id: "svc-1", AccountId: "acct-1"},
+ },
+ },
+ {
+ Mapping: []*proto.ProxyMapping{
+ {Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, Id: "svc-2", AccountId: "acct-1"},
+ },
+ },
+ },
+ }
+
+ loopDone := make(chan struct{})
+ syncDone := false
+ go func() {
+ defer close(loopDone)
+ _ = s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
+ }()
+
+ select {
+ case <-enteredRemove:
+ case <-time.After(2 * time.Second):
+ t.Fatal("receive loop never reached the blocking remove for the first batch")
+ }
+
+ // THE DEADLOCK: the loop is parked in the blocked remove and cannot advance.
+ // syncDone is owned by the loop goroutine, so it is not read here.
+ time.Sleep(500 * time.Millisecond)
+ assert.Equal(t, int32(1), stream.deliveredCount(),
+ "loop must NOT consume the second batch while the first remove is blocked — proxy is stuck")
+
+ select {
+ case <-loopDone:
+ t.Fatal("receive loop returned while it should be wedged on the remove")
+ default:
+ }
+
+ // Unblock and confirm the wedge was solely the blocked remove: the loop
+ // then advances and consumes the next batch.
+ close(blockRemove)
+ assert.Eventually(t, func() bool {
+ return stream.deliveredCount() >= 2
+ }, 2*time.Second, 5*time.Millisecond,
+ "once the remove unblocks, the loop must advance and consume the next batch")
+}
diff --git a/proxy/process_mappings_bench_test.go b/proxy/process_mappings_bench_test.go
new file mode 100644
index 000000000..919cab95c
--- /dev/null
+++ b/proxy/process_mappings_bench_test.go
@@ -0,0 +1,300 @@
+package proxy
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc"
+
+ "github.com/netbirdio/netbird/proxy/internal/auth"
+ "github.com/netbirdio/netbird/proxy/internal/conntrack"
+ "github.com/netbirdio/netbird/proxy/internal/crowdsec"
+ proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics"
+ "github.com/netbirdio/netbird/proxy/internal/proxy"
+ "github.com/netbirdio/netbird/proxy/internal/roundtrip"
+ nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp"
+ "github.com/netbirdio/netbird/proxy/internal/types"
+ udprelay "github.com/netbirdio/netbird/proxy/internal/udp"
+ "github.com/netbirdio/netbird/shared/management/proto"
+
+ "go.opentelemetry.io/otel/metric/noop"
+)
+
+// latencyMockClient simulates realistic gRPC latency for management calls.
+type latencyMockClient struct {
+ proto.ProxyServiceClient
+ createPeerDelay time.Duration
+ statusUpdateDelay time.Duration
+}
+
+func (m *latencyMockClient) SendStatusUpdate(ctx context.Context, _ *proto.SendStatusUpdateRequest, _ ...grpc.CallOption) (*proto.SendStatusUpdateResponse, error) {
+ if m.statusUpdateDelay > 0 {
+ select {
+ case <-time.After(m.statusUpdateDelay):
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ }
+ return &proto.SendStatusUpdateResponse{}, nil
+}
+
+func (m *latencyMockClient) CreateProxyPeer(ctx context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) {
+ if m.createPeerDelay > 0 {
+ select {
+ case <-time.After(m.createPeerDelay):
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ }
+ return &proto.CreateProxyPeerResponse{Success: true}, nil
+}
+
+type discardWriter struct{}
+
+func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
+
+func benchServerWithLatency(b *testing.B, createPeerDelay, statusDelay time.Duration) *Server {
+ b.Helper()
+ logger := log.New()
+ logger.SetLevel(log.FatalLevel)
+ logger.SetOutput(&discardWriter{})
+
+ meter, err := proxymetrics.New(context.Background(), noop.Meter{})
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ mgmtClient := &latencyMockClient{
+ createPeerDelay: createPeerDelay,
+ statusUpdateDelay: statusDelay,
+ }
+
+ nb := roundtrip.NewNetBird(b.Context(), "bench-proxy", "bench.test",
+ roundtrip.ClientConfig{MgmtAddr: "http://bench.test:9999"},
+ logger, nil, mgmtClient)
+
+ mainRouter := nbtcp.NewRouter(logger, func(accountID types.AccountID) (types.DialContextFunc, error) {
+ return (&net.Dialer{}).DialContext, nil
+ }, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443})
+
+ return &Server{
+ Logger: logger,
+ mgmtClient: mgmtClient,
+ netbird: nb,
+ proxy: proxy.NewReverseProxy(nil, "auto", nil, logger),
+ auth: auth.NewMiddleware(logger, nil, nil),
+ mainRouter: mainRouter,
+ mainPort: 443,
+ meter: meter,
+ hijackTracker: conntrack.HijackTracker{},
+ crowdsecRegistry: crowdsec.NewRegistry("", "", log.NewEntry(logger)),
+ crowdsecServices: make(map[types.ServiceID]bool),
+ lastMappings: make(map[types.ServiceID]*proto.ProxyMapping),
+ portRouters: make(map[uint16]*portRouter),
+ svcPorts: make(map[types.ServiceID][]uint16),
+ udpRelays: make(map[types.ServiceID]*udprelay.Relay),
+ }
+}
+
+// generateHTTPMappings creates N HTTP-mode mappings with the given update type.
+// All belong to a single account to share the embedded client.
+func generateHTTPMappings(n int, updateType proto.ProxyMappingUpdateType) []*proto.ProxyMapping {
+ mappings := make([]*proto.ProxyMapping, n)
+ for i := range n {
+ mappings[i] = &proto.ProxyMapping{
+ Type: updateType,
+ Id: fmt.Sprintf("svc-%d", i),
+ AccountId: "account-1",
+ Domain: fmt.Sprintf("svc-%d.bench.example.com", i),
+ Mode: "http",
+ Path: []*proto.PathMapping{
+ {
+ Path: "/",
+ Target: fmt.Sprintf("http://10.0.%d.%d:8080", (i/256)%256, i%256),
+ },
+ },
+ Auth: &proto.Authentication{},
+ }
+ }
+ return mappings
+}
+
+// generateMultiAccountHTTPMappings creates N HTTP-mode CREATED mappings spread
+// across the given number of accounts. This stresses the AddPeer new-account
+// path which calls CreateProxyPeer + embed.New per unique account.
+func generateMultiAccountHTTPMappings(n, accounts int) []*proto.ProxyMapping {
+ mappings := make([]*proto.ProxyMapping, n)
+ for i := range n {
+ mappings[i] = &proto.ProxyMapping{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: fmt.Sprintf("svc-%d", i),
+ AccountId: fmt.Sprintf("account-%d", i%accounts),
+ Domain: fmt.Sprintf("svc-%d.bench.example.com", i),
+ Mode: "http",
+ Path: []*proto.PathMapping{
+ {
+ Path: "/",
+ Target: fmt.Sprintf("http://10.0.%d.%d:8080", (i/256)%256, i%256),
+ },
+ },
+ Auth: &proto.Authentication{},
+ }
+ }
+ return mappings
+}
+
+// generateMixedMappings creates mappings with a realistic distribution:
+// 70% HTTP create, 15% modify existing, 10% TLS on main port, 5% remove.
+// All use a single account to avoid embed.New dialing.
+func generateMixedMappings(n int) []*proto.ProxyMapping {
+ mappings := make([]*proto.ProxyMapping, n)
+ for i := range n {
+ var m *proto.ProxyMapping
+ switch {
+ case i%20 < 14: // 70% HTTP create
+ m = &proto.ProxyMapping{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: fmt.Sprintf("svc-http-%d", i),
+ AccountId: "account-1",
+ Domain: fmt.Sprintf("svc-%d.bench.example.com", i),
+ Mode: "http",
+ Path: []*proto.PathMapping{
+ {Path: "/", Target: fmt.Sprintf("http://10.0.%d.%d:8080", (i/256)%256, i%256)},
+ {Path: "/api", Target: fmt.Sprintf("http://10.0.%d.%d:8081", (i/256)%256, i%256)},
+ },
+ Auth: &proto.Authentication{},
+ }
+ case i%20 < 17: // 15% modify
+ m = &proto.ProxyMapping{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED,
+ Id: fmt.Sprintf("svc-http-%d", i%100),
+ AccountId: "account-1",
+ Domain: fmt.Sprintf("svc-%d.bench.example.com", i%100),
+ Mode: "http",
+ Path: []*proto.PathMapping{
+ {Path: "/", Target: fmt.Sprintf("http://10.1.%d.%d:8080", (i/256)%256, i%256)},
+ },
+ Auth: &proto.Authentication{},
+ }
+ case i%20 < 19: // 10% TLS passthrough on main port
+ m = &proto.ProxyMapping{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: fmt.Sprintf("svc-tls-%d", i),
+ AccountId: "account-1",
+ Domain: fmt.Sprintf("tls-%d.bench.example.com", i),
+ Mode: "tls",
+ ListenPort: 443,
+ Path: []*proto.PathMapping{
+ {Path: "/", Target: fmt.Sprintf("10.2.%d.%d:443", (i/256)%256, i%256)},
+ },
+ }
+ default: // 5% remove
+ m = &proto.ProxyMapping{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED,
+ Id: fmt.Sprintf("svc-http-%d", i%50),
+ AccountId: "account-1",
+ Domain: fmt.Sprintf("svc-%d.bench.example.com", i%50),
+ Mode: "http",
+ }
+ }
+ mappings[i] = m
+ }
+ return mappings
+}
+
+const (
+ createPeerLatency = 100 * time.Millisecond
+ statusUpdateLatency = 50 * time.Millisecond
+)
+
+// BenchmarkProcessMappings_HTTPCreate_SingleAccount benchmarks the initial sync
+// scenario: N HTTP mappings all on a single account. Only the first mapping
+// triggers CreateProxyPeer (100ms gRPC). The rest just register with the
+// existing client. This is the "best case" production path.
+func BenchmarkProcessMappings_HTTPCreate_SingleAccount(b *testing.B) {
+ for _, n := range []int{100, 1000, 5000} {
+ b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
+ mappings := generateHTTPMappings(n, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED)
+ for range b.N {
+ s := benchServerWithLatency(b, createPeerLatency, statusUpdateLatency)
+ s.processMappings(b.Context(), mappings)
+ }
+ })
+ }
+}
+
+// BenchmarkProcessMappings_HTTPCreate_MultiAccount benchmarks the worst-case
+// initial sync: every mapping belongs to a different account, so each one
+// triggers a full CreateProxyPeer gRPC round-trip (100ms) + embed.New.
+// With 500 accounts this serializes to ~50s of blocking I/O.
+func BenchmarkProcessMappings_HTTPCreate_MultiAccount(b *testing.B) {
+ for _, tc := range []struct {
+ mappings int
+ accounts int
+ }{
+ {100, 10},
+ {100, 50},
+ {1000, 50},
+ {1000, 200},
+ {3000, 500},
+ } {
+ b.Run(fmt.Sprintf("mappings=%d/accounts=%d", tc.mappings, tc.accounts), func(b *testing.B) {
+ mappings := generateMultiAccountHTTPMappings(tc.mappings, tc.accounts)
+ for range b.N {
+ s := benchServerWithLatency(b, createPeerLatency, statusUpdateLatency)
+ s.processMappings(b.Context(), mappings)
+ }
+ })
+ }
+}
+
+// BenchmarkProcessMappings_Mixed benchmarks a realistic mixed workload
+// of creates, modifies, TLS, and removes with production-like latency.
+// TLS mappings call SendStatusUpdate (50ms each), serialized.
+func BenchmarkProcessMappings_Mixed(b *testing.B) {
+ for _, n := range []int{100, 1000, 5000} {
+ b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
+ mappings := generateMixedMappings(n)
+ for range b.N {
+ s := benchServerWithLatency(b, createPeerLatency, statusUpdateLatency)
+ creates := generateHTTPMappings(100, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED)
+ s.processMappings(b.Context(), creates)
+ s.processMappings(b.Context(), mappings)
+ }
+ })
+ }
+}
+
+// BenchmarkProcessMappings_ModifyOnly benchmarks bulk modification of
+// already-registered mappings (no new peers needed, no gRPC).
+func BenchmarkProcessMappings_ModifyOnly(b *testing.B) {
+ for _, n := range []int{100, 1000, 5000} {
+ b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
+ creates := generateHTTPMappings(n, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED)
+ modifies := generateHTTPMappings(n, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED)
+ for range b.N {
+ s := benchServerWithLatency(b, createPeerLatency, statusUpdateLatency)
+ s.processMappings(b.Context(), creates)
+ s.processMappings(b.Context(), modifies)
+ }
+ })
+ }
+}
+
+// BenchmarkProcessMappings_NoLatency measures pure CPU/allocation overhead
+// with zero I/O latency for profiling purposes.
+func BenchmarkProcessMappings_NoLatency(b *testing.B) {
+ for _, n := range []int{1000, 5000} {
+ b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
+ mappings := generateHTTPMappings(n, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED)
+ for range b.N {
+ s := benchServerWithLatency(b, 0, 0)
+ s.processMappings(b.Context(), mappings)
+ }
+ })
+ }
+}
diff --git a/proxy/server.go b/proxy/server.go
index 6980e1df1..6d5acfe46 100644
--- a/proxy/server.go
+++ b/proxy/server.go
@@ -32,11 +32,16 @@ import (
"go.opentelemetry.io/otel/sdk/metric"
"golang.org/x/exp/maps"
"google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/keepalive"
+ grpcstatus "google.golang.org/grpc/status"
+ "google.golang.org/protobuf/encoding/protojson"
+ goproto "google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
+ "github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/proxy/internal/accesslog"
"github.com/netbirdio/netbird/proxy/internal/acme"
"github.com/netbirdio/netbird/proxy/internal/auth"
@@ -70,6 +75,7 @@ type portRouter struct {
}
type Server struct {
+ ctx context.Context
mgmtClient proto.ProxyServiceClient
proxy *proxy.ReverseProxy
netbird *roundtrip.NetBird
@@ -112,9 +118,31 @@ type Server struct {
// The mapping worker waits on this before processing updates.
routerReady chan struct{}
+ // removePeer defaults to netbird.RemovePeer; overridable in tests.
+ removePeer func(ctx context.Context, accountID types.AccountID, key roundtrip.ServiceKey) error
+
+ // inbound, when non-nil, manages per-account inbound listeners. Set by
+ // initPrivateInbound only when Private is true so the standalone
+ // proxy keeps its zero-overhead default path.
+ inbound *inboundManager
+
+ // Lifecycle state — populated by Start, consumed by Stop. The fields
+ // stay zero on a fresh Server until Start runs so direct struct
+ // construction (`&Server{...}`) used by tests still works.
+ runCancel context.CancelFunc
+ mgmtConn *grpc.ClientConn
+ runErr error
+ runErrCh chan struct{}
+ startMu sync.Mutex
+ started bool
+ stopOnce sync.Once
+
// Mostly used for debugging on management.
startTime time.Time
+ // ListenAddr is the address the main TCP listener binds. Populated by
+ // New from Config or by ListenAndServe from its addr argument.
+ ListenAddr string
ID string
Logger *log.Logger
Version string
@@ -162,6 +190,9 @@ type Server struct {
// single-account deployments; multiple accounts will fail to bind
// the same port.
WireguardPort uint16
+ // Performance configures the tunnel pool/batch sizes for every
+ // embedded client this proxy spawns.
+ Performance embed.Performance
// ProxyProtocol enables PROXY protocol (v1/v2) on TCP listeners.
// When enabled, the real client IP is extracted from the PROXY header
// sent by upstream L4 proxies that support PROXY protocol.
@@ -175,6 +206,14 @@ type Server struct {
// in front of this proxy's cluster domain. When true, accounts cannot
// create services on the bare cluster domain.
RequireSubdomain bool
+ // Private flags this proxy as embedded in a netbird client and serving
+ // exclusively over the WireGuard tunnel (i.e. `netbird proxy`). Reported
+ // upstream as a capability so dashboards can distinguish per-peer
+ // clusters from centralised ones, and turns on per-account inbound
+ // listeners on each embedded client's netstack: every account that
+ // registers a service exposes :80 + :443 inside its own WG tunnel,
+ // scoped to that account's services only.
+ Private bool
// MaxDialTimeout caps the per-service backend dial timeout.
// When the API sends a timeout, it is clamped to this value.
// When the API sends no timeout, this value is used as the default.
@@ -191,6 +230,10 @@ type Server struct {
// Zero means no cap (the proxy honors whatever management sends).
// Set via NB_PROXY_MAX_SESSION_IDLE_TIMEOUT for shared deployments.
MaxSessionIdleTimeout time.Duration
+ // MappingBatchWatchdog bounds how long a single mapping batch may spend
+ // in processMappings before the receive loop reconnects to resync.
+ // Zero uses defaultMappingBatchWatchdog.
+ MappingBatchWatchdog time.Duration
}
// clampIdleTimeout returns d capped to MaxSessionIdleTimeout when configured.
@@ -220,12 +263,16 @@ func (s *Server) NotifyStatus(ctx context.Context, accountID types.AccountID, se
status = proto.ProxyStatus_PROXY_STATUS_ACTIVE
}
- _, err := s.mgmtClient.SendStatusUpdate(ctx, &proto.SendStatusUpdateRequest{
+ req := &proto.SendStatusUpdateRequest{
ServiceId: string(serviceID),
AccountId: string(accountID),
Status: status,
CertificateIssued: false,
- })
+ }
+ if connected {
+ req.InboundListener = s.inboundListenerProto(accountID)
+ }
+ _, err := s.mgmtClient.SendStatusUpdate(ctx, req)
return err
}
@@ -236,53 +283,68 @@ func (s *Server) NotifyCertificateIssued(ctx context.Context, accountID types.Ac
AccountId: string(accountID),
Status: proto.ProxyStatus_PROXY_STATUS_ACTIVE,
CertificateIssued: true,
+ InboundListener: s.inboundListenerProto(accountID),
})
return err
}
-func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) {
- s.initDefaults()
- s.routerReady = make(chan struct{})
- s.udpRelays = make(map[types.ServiceID]*udprelay.Relay)
- s.portRouters = make(map[uint16]*portRouter)
- s.svcPorts = make(map[types.ServiceID][]uint16)
- s.lastMappings = make(map[types.ServiceID]*proto.ProxyMapping)
-
- exporter, err := prometheus.New()
- if err != nil {
- return fmt.Errorf("create prometheus exporter: %w", err)
+// inboundListenerProto resolves the per-account inbound listener state for
+// the SendStatusUpdate payload. Returns nil when --private is off
+// or the account has no live listener so management treats the field as
+// absent.
+func (s *Server) inboundListenerProto(accountID types.AccountID) *proto.ProxyInboundListener {
+ if s.inbound == nil {
+ return nil
}
-
- provider := metric.NewMeterProvider(metric.WithReader(exporter))
- pkg := reflect.TypeOf(Server{}).PkgPath()
- meter := provider.Meter(pkg)
-
- s.meter, err = proxymetrics.New(ctx, meter)
- if err != nil {
- return fmt.Errorf("create metrics: %w", err)
+ info, ok := s.inbound.ListenerInfo(accountID)
+ if !ok || info.TunnelIP == "" {
+ return nil
}
+ return &proto.ProxyInboundListener{
+ TunnelIp: info.TunnelIP,
+ HttpsPort: uint32(info.HTTPSPort),
+ HttpPort: uint32(info.HTTPPort),
+ }
+}
- mgmtConn, err := s.dialManagement()
- if err != nil {
+// ListenAndServe is the standalone entrypoint. It binds the listener, runs
+// the proxy until ctx is cancelled or a background goroutine fails, then
+// drains and stops. Library callers should prefer New + Start + Stop and
+// own their own shutdown signalling.
+func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
+ s.ListenAddr = addr
+ if err := s.Start(ctx); err != nil {
return err
}
- defer func() {
- if err := mgmtConn.Close(); err != nil {
- s.Logger.Debugf("management connection close: %v", err)
- }
- }()
- s.mgmtClient = proto.NewProxyServiceClient(mgmtConn)
+ return s.waitAndStop(ctx)
+}
+
+// Start brings the proxy up: dials management, configures TLS, binds the
+// main listener, and spawns the SNI router and HTTPS server goroutines. It
+// returns once the listener is bound; background errors are surfaced
+// through Stop's return value. Start is not safe to call twice.
+func (s *Server) Start(ctx context.Context) error {
+ s.startMu.Lock()
+ if s.started {
+ s.startMu.Unlock()
+ return errors.New("proxy already started")
+ }
+ s.started = true
+ s.startMu.Unlock()
+
+ s.initLifecycleState()
+ if err := s.initMetrics(ctx); err != nil {
+ return err
+ }
+
+ if err := s.initManagementClient(); err != nil {
+ return err
+ }
+
runCtx, runCancel := context.WithCancel(ctx)
- defer runCancel()
-
- // Initialize the netbird client, this is required to build peer connections
- // to proxy over.
- s.netbird = roundtrip.NewNetBird(s.ID, s.ProxyURL, roundtrip.ClientConfig{
- MgmtAddr: s.ManagementAddress,
- WGPort: s.WireguardPort,
- PreSharedKey: s.PreSharedKey,
- }, s.Logger, s, s.mgmtClient)
+ s.runCancel = runCancel
+ s.initNetBirdClient()
// Create health checker before the mapping worker so it can track
// management connectivity from the first stream connection.
s.healthChecker = health.NewChecker(s.Logger, s.netbird)
@@ -297,34 +359,25 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) {
return err
}
- // Configure the reverse proxy using NetBird's HTTP Client Transport for proxying.
- s.proxy = proxy.NewReverseProxy(s.meter.RoundTripper(s.netbird), s.ForwardedProto, s.TrustedProxies, s.Logger)
+ s.initReverseProxy()
- geoLookup, err := geolocation.NewLookup(s.Logger, s.GeoDataDir)
- if err != nil {
- return fmt.Errorf("initialize geolocation: %w", err)
- }
- s.geoRaw = geoLookup
- if geoLookup != nil {
- s.geo = geoLookup
+ if err := s.initGeoLookup(); err != nil {
+ return err
}
- var startupOK bool
+ startupOK := false
defer func() {
if startupOK {
return
}
if s.geoRaw != nil {
- if err := s.geoRaw.Close(); err != nil {
- s.Logger.Debugf("close geolocation on startup failure: %v", err)
+ if closeErr := s.geoRaw.Close(); closeErr != nil {
+ s.Logger.Debugf("close geolocation on startup failure: %v", closeErr)
}
}
}()
- // Configure the authentication middleware with session validator for OIDC group checks.
s.auth = auth.NewMiddleware(s.Logger, s.mgmtClient, s.geo)
-
- // Configure Access logs to management server.
s.accessLog = accesslog.NewLogger(s.mgmtClient, s.Logger, s.TrustedProxies)
s.startDebugEndpoint()
@@ -333,35 +386,21 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) {
return err
}
- // Build the handler chain from inside out.
- handler := http.Handler(s.proxy)
- handler = s.auth.Protect(handler)
- handler = web.AssetHandler(handler)
- handler = s.accessLog.Middleware(handler)
- handler = s.meter.Middleware(handler)
- handler = s.hijackTracker.Middleware(handler)
+ handler := s.buildHandlerChain()
+ s.initPrivateInbound(handler, tlsConfig)
- // Start a raw TCP listener; the SNI router peeks at ClientHello
- // and routes to either the HTTP handler or a TCP relay.
- lc := net.ListenConfig{}
- ln, err := lc.Listen(ctx, "tcp", addr)
+ ln, err := s.bindMainListener(ctx)
if err != nil {
- return fmt.Errorf("listen on %s: %w", addr, err)
+ return err
}
- if s.ProxyProtocol {
- ln = s.wrapProxyProtocol(ln)
- }
- s.mainPort = uint16(ln.Addr().(*net.TCPAddr).Port) //nolint:gosec // port from OS is always valid
- // Set up the SNI router for TCP/HTTP multiplexing on the main port.
s.mainRouter = nbtcp.NewRouter(s.Logger, s.resolveDialFunc, ln.Addr())
s.mainRouter.SetObserver(s.meter)
s.mainRouter.SetAccessLogger(s.accessLog)
close(s.routerReady)
- // The HTTP server uses the chanListener fed by the SNI router.
s.https = &http.Server{
- Addr: addr,
+ Addr: s.ListenAddr,
Handler: handler,
TLSConfig: tlsConfig,
ReadHeaderTimeout: httpReadHeaderTimeout,
@@ -371,35 +410,202 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) (err error) {
startupOK = true
- httpsErr := make(chan error, 1)
go func() {
s.Logger.Debug("starting HTTPS server on SNI router HTTP channel")
- httpsErr <- s.https.ServeTLS(s.mainRouter.HTTPListener(), "", "")
+ if serveErr := s.https.ServeTLS(s.mainRouter.HTTPListener(), "", ""); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
+ s.recordRunErr(fmt.Errorf("https server: %w", serveErr))
+ }
}()
- routerErr := make(chan error, 1)
go func() {
- s.Logger.Debugf("starting SNI router on %s", addr)
- routerErr <- s.mainRouter.Serve(runCtx, ln)
+ s.Logger.Debugf("starting SNI router on %s", s.ListenAddr)
+ if serveErr := s.mainRouter.Serve(runCtx, ln); serveErr != nil {
+ s.recordRunErr(fmt.Errorf("SNI router: %w", serveErr))
+ }
}()
+ return nil
+}
+
+// Stop drains in-flight connections, shuts down all background services,
+// and releases resources. Idempotent; calling it before Start is a no-op.
+// Returns the first fatal error reported by a background goroutine, if
+// any. The provided ctx bounds the total wait time; once it is cancelled
+// Stop returns even if drain is still in flight.
+func (s *Server) Stop(ctx context.Context) error {
+ s.stopOnce.Do(func() {
+ s.startMu.Lock()
+ started := s.started
+ s.startMu.Unlock()
+ if !started {
+ return
+ }
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ s.gracefulShutdown()
+ if s.runCancel != nil {
+ s.runCancel()
+ }
+ if s.mgmtConn != nil {
+ if err := s.mgmtConn.Close(); err != nil {
+ s.Logger.Debugf("management connection close: %v", err)
+ }
+ }
+ }()
+
+ select {
+ case <-done:
+ case <-ctx.Done():
+ s.Logger.Warnf("proxy stop deadline exceeded: %v", ctx.Err())
+ }
+ })
+
+ s.startMu.Lock()
+ defer s.startMu.Unlock()
+ return s.runErr
+}
+
+// waitAndStop blocks until ctx is cancelled or a background goroutine
+// reports a fatal error, then drains and stops. Used by ListenAndServe.
+func (s *Server) waitAndStop(ctx context.Context) error {
select {
- case err := <-httpsErr:
- s.shutdownServices()
- if !errors.Is(err, http.ErrServerClosed) {
- return fmt.Errorf("https server: %w", err)
- }
- return nil
- case err := <-routerErr:
- s.shutdownServices()
- if err != nil {
- return fmt.Errorf("SNI router: %w", err)
- }
- return nil
case <-ctx.Done():
- s.gracefulShutdown()
- return nil
+ case <-s.runErrCh:
}
+ stopCtx, cancel := context.WithTimeout(context.Background(), shutdownDrainTimeout+shutdownServiceTimeout)
+ defer cancel()
+ return s.Stop(stopCtx)
+}
+
+// recordRunErr stores the first fatal background error and signals
+// waitAndStop. Subsequent errors are logged at debug level so the first
+// cause is preserved.
+func (s *Server) recordRunErr(err error) {
+ s.startMu.Lock()
+ defer s.startMu.Unlock()
+ if s.runErr != nil {
+ s.Logger.Debugf("background error after first failure: %v", err)
+ return
+ }
+ s.runErr = err
+ if s.runErrCh != nil {
+ close(s.runErrCh)
+ }
+}
+
+// initLifecycleState seeds the maps and channels Start needs to wire up
+// background goroutines. Called once at the top of Start.
+func (s *Server) initLifecycleState() {
+ s.initDefaults()
+ s.routerReady = make(chan struct{})
+ s.runErrCh = make(chan struct{})
+ s.udpRelays = make(map[types.ServiceID]*udprelay.Relay)
+ s.portRouters = make(map[uint16]*portRouter)
+ s.svcPorts = make(map[types.ServiceID][]uint16)
+ s.lastMappings = make(map[types.ServiceID]*proto.ProxyMapping)
+}
+
+// initMetrics builds the prometheus exporter and meter bundle.
+func (s *Server) initMetrics(ctx context.Context) error {
+ exporter, err := prometheus.New()
+ if err != nil {
+ return fmt.Errorf("create prometheus exporter: %w", err)
+ }
+ provider := metric.NewMeterProvider(metric.WithReader(exporter))
+ pkg := reflect.TypeOf(Server{}).PkgPath()
+ meter := provider.Meter(pkg)
+ s.meter, err = proxymetrics.New(ctx, meter)
+ if err != nil {
+ return fmt.Errorf("create metrics: %w", err)
+ }
+ return nil
+}
+
+// initManagementClient dials management and stashes the connection so
+// Stop can close it deterministically.
+func (s *Server) initManagementClient() error {
+ conn, err := s.dialManagement()
+ if err != nil {
+ return err
+ }
+ s.mgmtConn = conn
+ s.mgmtClient = proto.NewProxyServiceClient(conn)
+ return nil
+}
+
+// initNetBirdClient builds the multi-tenant embedded NetBird client used
+// for outbound RoundTripping and (when --private is on) per-account
+// inbound listeners.
+func (s *Server) initNetBirdClient() {
+ s.netbird = roundtrip.NewNetBird(s.ctx, s.ID, s.ProxyURL, roundtrip.ClientConfig{
+ MgmtAddr: s.ManagementAddress,
+ WGPort: s.WireguardPort,
+ PreSharedKey: s.PreSharedKey,
+ Performance: s.Performance,
+ // On --private the embedded client serves per-account inbound
+ // listeners and must apply management's ACL: keep BlockInbound off
+ // so the engine creates the ACL manager. On the standalone proxy
+ // the embedded client never accepts inbound, so block.
+ BlockInbound: !s.Private,
+ }, s.Logger, s, s.mgmtClient)
+ s.netbird.OnAddPeer = s.meter.RecordAddPeerDuration
+}
+
+// initReverseProxy builds the meter-instrumented reverse proxy. MultiTransport
+// routes targets opted into direct_upstream through the host's network stack
+// (stdlib transport); everything else falls through to the embedded NetBird
+// client. The split is needed so direct_upstream targets resolve DNS via the
+// proxy host's resolver instead of the tunnel's DNS.
+func (s *Server) initReverseProxy() {
+ upstreamRT := roundtrip.NewMultiTransport(s.netbird, s.Logger)
+ s.proxy = proxy.NewReverseProxy(s.meter.RoundTripper(upstreamRT), s.ForwardedProto, s.TrustedProxies, s.Logger)
+}
+
+// initGeoLookup configures the GeoLite2 lookup used for country-based
+// access restrictions and access-log enrichment.
+func (s *Server) initGeoLookup() error {
+ geoLookup, err := geolocation.NewLookup(s.Logger, s.GeoDataDir)
+ if err != nil {
+ return fmt.Errorf("initialize geolocation: %w", err)
+ }
+ s.geoRaw = geoLookup
+ if geoLookup != nil {
+ s.geo = geoLookup
+ }
+ return nil
+}
+
+// buildHandlerChain wires the request middlewares from inside out.
+func (s *Server) buildHandlerChain() http.Handler {
+ handler := http.Handler(s.proxy)
+ handler = s.auth.Protect(handler)
+ handler = web.AssetHandler(handler)
+ handler = s.accessLog.Middleware(handler)
+ handler = s.meter.Middleware(handler)
+ return s.hijackTracker.Middleware(handler)
+}
+
+// bindMainListener binds the main TCP listener and wraps it with PROXY
+// protocol when configured.
+func (s *Server) bindMainListener(ctx context.Context) (net.Listener, error) {
+ lc := net.ListenConfig{}
+ ln, err := lc.Listen(ctx, "tcp", s.ListenAddr)
+ if err != nil {
+ return nil, fmt.Errorf("listen on %s: %w", s.ListenAddr, err)
+ }
+ if s.ProxyProtocol {
+ ln = s.wrapProxyProtocol(ln)
+ }
+ s.mainPort = uint16(ln.Addr().(*net.TCPAddr).Port) //nolint:gosec // port from OS is always valid
+ s.Logger.WithFields(log.Fields{
+ "requested_addr": s.ListenAddr,
+ "bound_addr": ln.Addr().String(),
+ "private": s.Private,
+ "proxy_protocol": s.ProxyProtocol,
+ }).Info("proxy main listener bound")
+ return ln, nil
}
// initDefaults sets fallback values for optional Server fields.
@@ -431,6 +637,9 @@ func (s *Server) startDebugEndpoint() {
if s.acme != nil {
debugHandler.SetCertStatus(s.acme)
}
+ if s.inbound != nil {
+ debugHandler.SetInboundProvider(inboundDebugAdapter{mgr: s.inbound})
+ }
s.debug = &http.Server{
Addr: debugAddr,
Handler: debugHandler,
@@ -444,16 +653,18 @@ func (s *Server) startDebugEndpoint() {
}()
}
-// startHealthServer launches the health probe and metrics server.
+// startHealthServer launches the health probe and metrics server. Empty
+// HealthAddress disables the probe entirely (intended for library callers
+// that want to manage their own health surface).
func (s *Server) startHealthServer() error {
- healthAddr := s.HealthAddress
- if healthAddr == "" {
- healthAddr = defaultHealthAddr
+ if s.HealthAddress == "" {
+ s.Logger.Debug("health probe disabled (empty HealthAddress)")
+ return nil
}
- s.healthServer = health.NewServer(healthAddr, s.healthChecker, s.Logger, promhttp.HandlerFor(prometheus2.DefaultGatherer, promhttp.HandlerOpts{EnableOpenMetrics: true}))
- healthListener, err := net.Listen("tcp", healthAddr)
+ s.healthServer = health.NewServer(s.HealthAddress, s.healthChecker, s.Logger, promhttp.HandlerFor(prometheus2.DefaultGatherer, promhttp.HandlerOpts{EnableOpenMetrics: true}))
+ healthListener, err := net.Listen("tcp", s.HealthAddress)
if err != nil {
- return fmt.Errorf("health probe server listen on %s: %w", healthAddr, err)
+ return fmt.Errorf("health probe server listen on %s: %w", s.HealthAddress, err)
}
go func() {
if err := s.healthServer.Serve(healthListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -504,8 +715,9 @@ func (s *Server) proxyProtocolPolicy(opts proxyproto.ConnPolicyOptions) (proxypr
}
const (
- defaultHealthAddr = "localhost:8080"
- defaultDebugAddr = "localhost:8444"
+ // defaultDebugAddr is the localhost-bound fallback for the debug endpoint
+ // when DebugEndpointAddress is empty.
+ defaultDebugAddr = "localhost:8444"
// proxyProtoHeaderTimeout is the deadline for reading the PROXY protocol
// header after accepting a connection.
@@ -658,8 +870,10 @@ func (s *Server) gracefulShutdown() {
defer drainCancel()
s.Logger.Info("draining in-flight connections")
- if err := s.https.Shutdown(drainCtx); err != nil {
- s.Logger.Warnf("https server drain: %v", err)
+ if s.https != nil {
+ if err := s.https.Shutdown(drainCtx); err != nil {
+ s.Logger.Warnf("https server drain: %v", err)
+ }
}
// Step 4: Close hijacked connections (WebSocket) that Shutdown does not handle.
@@ -806,6 +1020,18 @@ func (s *Server) resolveDialFunc(accountID types.AccountID) (types.DialContextFu
return client.DialContext, nil
}
+// initPrivateInbound wires per-account inbound listeners when --private
+// is set. When the flag is off this is a no-op and the standalone proxy keeps
+// its byte-for-byte previous behaviour.
+func (s *Server) initPrivateInbound(handler http.Handler, tlsConfig *tls.Config) {
+ if !s.Private {
+ return
+ }
+ s.inbound = newInboundManager(s.Logger, handler, tlsConfig)
+ s.netbird.SetClientLifecycle(s.inbound.onClientReady, s.inbound.onClientStop)
+ s.Logger.Info("private inbound listeners enabled (per-account :80 + :443)")
+}
+
// notifyError reports a resource error back to management so it can be
// surfaced to the user (e.g. port bind failure, dialer resolution error).
func (s *Server) notifyError(ctx context.Context, mapping *proto.ProxyMapping, err error) {
@@ -938,6 +1164,10 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
Clock: backoff.SystemClock,
}
+ // syncSupported tracks whether management supports SyncMappings.
+ // Starts true; set to false on the first Unimplemented error so
+ // subsequent retries skip straight to GetMappingUpdate.
+ syncSupported := true
initialSyncDone := false
operation := func() error {
@@ -949,36 +1179,31 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
s.healthChecker.SetManagementConnected(false)
}
- supportsCrowdSec := s.crowdsecRegistry.Available()
- mappingClient, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{
- ProxyId: s.ID,
- Version: s.Version,
- StartedAt: timestamppb.New(s.startTime),
- Address: s.ProxyURL,
- Capabilities: &proto.ProxyCapabilities{
- SupportsCustomPorts: &s.SupportsCustomPorts,
- RequireSubdomain: &s.RequireSubdomain,
- SupportsCrowdsec: &supportsCrowdSec,
- },
- })
- if err != nil {
- return fmt.Errorf("create mapping stream: %w", err)
+ connected := false
+ onConnected := func() { connected = true }
+
+ var streamErr error
+ if syncSupported {
+ streamErr = s.trySyncMappings(ctx, client, &initialSyncDone, onConnected)
+ if isSyncUnimplemented(streamErr) {
+ syncSupported = false
+ s.Logger.Info("management does not support SyncMappings, falling back to GetMappingUpdate")
+ streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone, onConnected)
+ }
+ } else {
+ streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone, onConnected)
}
- if s.healthChecker != nil {
- s.healthChecker.SetManagementConnected(true)
- }
- s.Logger.Debug("management mapping stream established")
-
- // Stream established — reset backoff so the next failure retries quickly.
- bo.Reset()
-
- streamErr := s.handleMappingStream(ctx, mappingClient, &initialSyncDone)
-
if s.healthChecker != nil {
s.healthChecker.SetManagementConnected(false)
}
+ // Reset backoff only when a stream actually connected, so immediate
+ // connect failures still back off instead of spinning.
+ if connected {
+ bo.Reset()
+ }
+
if streamErr == nil {
return fmt.Errorf("stream closed by server")
}
@@ -995,52 +1220,199 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
}
}
-func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.ProxyService_GetMappingUpdateClient, initialSyncDone *bool) error {
+func (s *Server) proxyCapabilities() *proto.ProxyCapabilities {
+ supportsCrowdSec := s.crowdsecRegistry.Available()
+ privateCapability := s.Private
+ // Always true: this build enforces ProxyMapping.private via the auth middleware.
+ supportsPrivateService := true
+ return &proto.ProxyCapabilities{
+ SupportsCustomPorts: &s.SupportsCustomPorts,
+ RequireSubdomain: &s.RequireSubdomain,
+ SupportsCrowdsec: &supportsCrowdSec,
+ Private: &privateCapability,
+ SupportsPrivateService: &supportsPrivateService,
+ }
+}
+
+func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error {
+ connectTime := time.Now()
+ mappingClient, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{
+ ProxyId: s.ID,
+ Version: s.Version,
+ StartedAt: timestamppb.New(s.startTime),
+ Address: s.ProxyURL,
+ Capabilities: s.proxyCapabilities(),
+ })
+ if err != nil {
+ return fmt.Errorf("create mapping stream: %w", err)
+ }
+
+ onConnected()
+ if s.healthChecker != nil {
+ s.healthChecker.SetManagementConnected(true)
+ }
+ s.Logger.Debug("management mapping stream established (GetMappingUpdate)")
+
+ return s.handleMappingStream(ctx, mappingClient, initialSyncDone, connectTime)
+}
+
+func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error {
+ connectTime := time.Now()
+ stream, err := client.SyncMappings(ctx)
+ if err != nil {
+ return fmt.Errorf("create sync stream: %w", err)
+ }
+
+ if err := stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: s.ID,
+ Version: s.Version,
+ StartedAt: timestamppb.New(s.startTime),
+ Address: s.ProxyURL,
+ Capabilities: s.proxyCapabilities(),
+ },
+ },
+ }); err != nil {
+ return fmt.Errorf("send sync init: %w", err)
+ }
+
+ onConnected()
+ if s.healthChecker != nil {
+ s.healthChecker.SetManagementConnected(true)
+ }
+ s.Logger.Debug("management mapping stream established (SyncMappings)")
+
+ return s.handleSyncMappingsStream(ctx, stream, initialSyncDone, connectTime)
+}
+
+func isSyncUnimplemented(err error) bool {
+ if err == nil {
+ return false
+ }
+ st, ok := grpcstatus.FromError(err)
+ return ok && st.Code() == codes.Unimplemented
+}
+
+// handleSyncMappingsStream consumes batches from a bidirectional SyncMappings
+// stream, sending an ack after each batch is fully processed. Management waits
+// for the ack before sending the next batch, providing application-level
+// back-pressure.
+func (s *Server) handleSyncMappingsStream(ctx context.Context, stream proto.ProxyService_SyncMappingsClient, initialSyncDone *bool, connectTime time.Time) error {
select {
case <-s.routerReady:
case <-ctx.Done():
return ctx.Err()
}
- var snapshotIDs map[types.ServiceID]struct{}
- if !*initialSyncDone {
- snapshotIDs = make(map[types.ServiceID]struct{})
- }
+ tracker := s.newSnapshotTracker(initialSyncDone, connectTime)
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ msg, err := stream.Recv()
+ switch {
+ case errors.Is(err, io.EOF):
+ return nil
+ case err != nil:
+ return fmt.Errorf("receive msg: %w", err)
+ }
+
+ batchStart := time.Now()
+ s.Logger.Debug("Received mapping update, starting processing")
+ if err := s.processMappingsGuarded(ctx, msg.GetMapping()); err != nil {
+ return err
+ }
+ s.Logger.Debug("Processing mapping update completed")
+ tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart)
+
+ if err := stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ }); err != nil {
+ return fmt.Errorf("send ack: %w", err)
+ }
+ }
+ }
+}
+
+// snapshotTracker accumulates service IDs during the initial snapshot and
+// finalises sync state when the complete flag arrives. Used by both
+// handleMappingStream and handleSyncMappingsStream so metric emission and
+// reconciliation behave identically on either RPC.
+type snapshotTracker struct {
+ done *bool
+ connectTime time.Time
+ snapshotIDs map[types.ServiceID]struct{}
+}
+
+func (s *Server) newSnapshotTracker(done *bool, connectTime time.Time) *snapshotTracker {
+ var ids map[types.ServiceID]struct{}
+ if !*done {
+ ids = make(map[types.ServiceID]struct{})
+ }
+ return &snapshotTracker{done: done, connectTime: connectTime, snapshotIDs: ids}
+}
+
+func (t *snapshotTracker) recordBatch(ctx context.Context, s *Server, mappings []*proto.ProxyMapping, syncComplete bool, batchStart time.Time) {
+ if *t.done {
+ return
+ }
+
+ if s.meter != nil {
+ s.meter.RecordSnapshotBatchDuration(time.Since(batchStart))
+ }
+
+ for _, m := range mappings {
+ t.snapshotIDs[types.ServiceID(m.GetId())] = struct{}{}
+ }
+
+ if !syncComplete {
+ return
+ }
+
+ s.reconcileSnapshot(ctx, t.snapshotIDs)
+ t.snapshotIDs = nil
+ if s.healthChecker != nil {
+ s.healthChecker.SetInitialSyncComplete()
+ }
+ *t.done = true
+ if s.meter != nil {
+ s.meter.RecordSnapshotSyncDuration(time.Since(t.connectTime))
+ }
+ s.Logger.Info("Initial mapping sync complete")
+}
+
+func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.ProxyService_GetMappingUpdateClient, initialSyncDone *bool, connectTime time.Time) error {
+ select {
+ case <-s.routerReady:
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+
+ tracker := s.newSnapshotTracker(initialSyncDone, connectTime)
for {
- // Check for context completion to gracefully shutdown.
select {
case <-ctx.Done():
- // Shutting down.
return ctx.Err()
default:
msg, err := mappingClient.Recv()
switch {
case errors.Is(err, io.EOF):
- // Mapping connection gracefully terminated by server.
return nil
case err != nil:
- // Something has gone horribly wrong, return and hope the parent retries the connection.
return fmt.Errorf("receive msg: %w", err)
}
- s.Logger.Debug("Received mapping update, starting processing")
- s.processMappings(ctx, msg.GetMapping())
- s.Logger.Debug("Processing mapping update completed")
- if !*initialSyncDone {
- for _, m := range msg.GetMapping() {
- snapshotIDs[types.ServiceID(m.GetId())] = struct{}{}
- }
- if msg.GetInitialSyncComplete() {
- s.reconcileSnapshot(ctx, snapshotIDs)
- snapshotIDs = nil
- if s.healthChecker != nil {
- s.healthChecker.SetInitialSyncComplete()
- }
- *initialSyncDone = true
- s.Logger.Info("Initial mapping sync complete")
- }
+ batchStart := time.Now()
+ s.Logger.Debug("Received mapping update, starting processing")
+ if err := s.processMappingsGuarded(ctx, msg.GetMapping()); err != nil {
+ return err
}
+ s.Logger.Debug("Processing mapping update completed")
+ tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart)
}
}
}
@@ -1066,15 +1438,96 @@ func (s *Server) reconcileSnapshot(ctx context.Context, snapshotIDs map[types.Se
}
}
+// mappingJSONMarshal dumps mappings on one line with zero-value fields visible for debug logs.
+var mappingJSONMarshal = protojson.MarshalOptions{
+ Multiline: false,
+ EmitUnpopulated: true,
+ UseProtoNames: true,
+}
+
+// redactMappingForLog returns a deep copy of the mapping with sensitive fields
+// (auth_token, header-auth hashed values, custom upstream headers) replaced so
+// debug logs never carry credentials.
+func redactMappingForLog(m *proto.ProxyMapping) *proto.ProxyMapping {
+ const placeholder = "[REDACTED]"
+ c := goproto.Clone(m).(*proto.ProxyMapping)
+ if c.GetAuthToken() != "" {
+ c.AuthToken = placeholder
+ }
+ if c.Auth != nil {
+ for _, h := range c.Auth.GetHeaderAuths() {
+ if h.GetHashedValue() != "" {
+ h.HashedValue = placeholder
+ }
+ }
+ }
+ for _, p := range c.GetPath() {
+ opts := p.GetOptions()
+ if opts == nil || len(opts.CustomHeaders) == 0 {
+ continue
+ }
+ redacted := make(map[string]string, len(opts.CustomHeaders))
+ for k := range opts.CustomHeaders {
+ redacted[k] = placeholder
+ }
+ opts.CustomHeaders = redacted
+ }
+ return c
+}
+
+const defaultMappingBatchWatchdog = 2 * time.Minute
+
+// mappingBatchWatchdog returns the configured batch watchdog or the default.
+func (s *Server) mappingBatchWatchdog() time.Duration {
+ if s.MappingBatchWatchdog > 0 {
+ return s.MappingBatchWatchdog
+ }
+ return defaultMappingBatchWatchdog
+}
+
+// processMappingsGuarded applies a batch under a watchdog, returning an error
+// if processing exceeds the watchdog so the caller reconnects and resyncs
+// instead of wedging silently.
+func (s *Server) processMappingsGuarded(ctx context.Context, mappings []*proto.ProxyMapping) error {
+ batchCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ s.processMappings(batchCtx, mappings)
+ }()
+
+ watchdog := s.mappingBatchWatchdog()
+ timer := time.NewTimer(watchdog)
+ defer timer.Stop()
+
+ select {
+ case <-done:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ s.Logger.Errorf("processing mapping batch exceeded %s, cancelling and reconnecting to resync", watchdog)
+ return fmt.Errorf("mapping batch processing stalled after %s", watchdog)
+ }
+}
+
func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMapping) {
+ debug := s.Logger != nil && s.Logger.IsLevelEnabled(log.DebugLevel)
for _, mapping := range mappings {
- s.Logger.WithFields(log.Fields{
- "type": mapping.GetType(),
- "domain": mapping.GetDomain(),
- "mode": mapping.GetMode(),
- "port": mapping.GetListenPort(),
- "id": mapping.GetId(),
- }).Debug("Processing mapping update")
+ if debug {
+ raw, err := mappingJSONMarshal.Marshal(redactMappingForLog(mapping))
+ if err != nil {
+ raw = []byte(fmt.Sprintf("", err))
+ }
+ s.Logger.WithFields(log.Fields{
+ "type": mapping.GetType(),
+ "domain": mapping.GetDomain(),
+ "id": mapping.GetId(),
+ "mapping": string(raw),
+ }).Debug("Processing mapping update")
+ }
switch mapping.GetType() {
case proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED:
if err := s.addMapping(ctx, mapping); err != nil {
@@ -1171,12 +1624,16 @@ func (s *Server) setupHTTPMapping(ctx context.Context, mapping *proto.ProxyMappi
if s.acme != nil {
wildcardHit = s.acme.AddDomain(d, accountID, svcID)
}
- s.mainRouter.AddRoute(nbtcp.SNIHost(mapping.GetDomain()), nbtcp.Route{
+ httpRoute := nbtcp.Route{
Type: nbtcp.RouteHTTP,
AccountID: accountID,
ServiceID: svcID,
Domain: mapping.GetDomain(),
- })
+ }
+ s.mainRouter.AddRoute(nbtcp.SNIHost(mapping.GetDomain()), httpRoute)
+ if s.inbound != nil {
+ s.inbound.AddRoute(accountID, nbtcp.SNIHost(mapping.GetDomain()), httpRoute)
+ }
if err := s.updateMapping(ctx, mapping); err != nil {
return fmt.Errorf("update mapping for domain %q: %w", d, err)
}
@@ -1536,7 +1993,7 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions())
maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second
- if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions); err != nil {
+ if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate()); err != nil {
return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err)
}
m := s.protoToMapping(ctx, mapping)
@@ -1551,7 +2008,11 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
func (s *Server) removeMapping(ctx context.Context, mapping *proto.ProxyMapping) {
accountID := types.AccountID(mapping.GetAccountId())
svcKey := s.serviceKeyForMapping(mapping)
- if err := s.netbird.RemovePeer(ctx, accountID, svcKey); err != nil {
+ removePeer := s.removePeer
+ if removePeer == nil {
+ removePeer = s.netbird.RemovePeer
+ }
+ if err := removePeer(ctx, accountID, svcKey); err != nil {
s.Logger.WithFields(log.Fields{
"account_id": accountID,
"service_id": mapping.GetId(),
@@ -1592,6 +2053,9 @@ func (s *Server) cleanupMappingRoutes(mapping *proto.ProxyMapping) {
}
// Remove SNI route from the main router (covers both HTTP and main-port TLS).
s.mainRouter.RemoveRoute(nbtcp.SNIHost(host), svcID)
+ if s.inbound != nil {
+ s.inbound.RemoveRoute(types.AccountID(mapping.GetAccountId()), nbtcp.SNIHost(host), svcID)
+ }
}
// Extract and delete tracked custom-port entries atomically.
@@ -1679,6 +2143,7 @@ func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping
if d := opts.GetRequestTimeout(); d != nil {
pt.RequestTimeout = d.AsDuration()
}
+ pt.DirectUpstream = opts.GetDirectUpstream()
}
pt.RequestTimeout = s.clampDialTimeout(pt.RequestTimeout)
paths[pathMapping.GetPath()] = pt
diff --git a/proxy/server_test.go b/proxy/server_test.go
index b4fb4f8ba..aa4892201 100644
--- a/proxy/server_test.go
+++ b/proxy/server_test.go
@@ -1,9 +1,17 @@
package proxy
import (
+ "context"
+ "errors"
+ "io"
"testing"
+ "time"
+ log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/shared/management/proto"
)
func TestDebugEndpointDisabledByDefault(t *testing.T) {
@@ -46,3 +54,151 @@ func TestDebugEndpointAddr(t *testing.T) {
})
}
}
+
+// quietLifecycleLogger keeps lifecycle tests from spamming the test output.
+func quietLifecycleLogger() *log.Logger {
+ l := log.New()
+ l.SetOutput(io.Discard)
+ l.SetLevel(log.PanicLevel)
+ return l
+}
+
+func TestStopBeforeStartIsNoOp(t *testing.T) {
+ srv := New(t.Context(), Config{Logger: quietLifecycleLogger()})
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ err := srv.Stop(ctx)
+ assert.NoError(t, err, "Stop on an unstarted server must succeed without error")
+
+ err = srv.Stop(ctx)
+ assert.NoError(t, err, "Stop must remain idempotent across repeated calls")
+}
+
+func TestStartFailsWithoutManagement(t *testing.T) {
+ srv := New(t.Context(), Config{
+ Logger: quietLifecycleLogger(),
+ ListenAddr: "127.0.0.1:0",
+ ManagementAddress: "://broken-url",
+ })
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ err := srv.Start(ctx)
+ require.Error(t, err, "Start must surface management dial failures")
+
+ assert.True(t, srv.started, "started flag is set before any dial attempt so a second Start fails fast")
+
+ err = srv.Start(ctx)
+ require.Error(t, err, "second Start must reject")
+ assert.Contains(t, err.Error(), "already started", "error must explain why the call was rejected")
+}
+
+func TestStopIsIdempotent(t *testing.T) {
+ srv := &Server{
+ Logger: quietLifecycleLogger(),
+ started: true,
+ runErrCh: make(chan struct{}),
+ runCancel: func() {},
+ }
+ srv.recordRunErr(errors.New("synthetic"))
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ err := srv.Stop(ctx)
+ require.Error(t, err, "Stop must surface the recorded background error")
+ assert.Contains(t, err.Error(), "synthetic", "error must round-trip recordRunErr's value")
+
+ err = srv.Stop(ctx)
+ require.Error(t, err, "second Stop must still report the same error")
+ assert.Contains(t, err.Error(), "synthetic", "idempotent Stop must return the cached error")
+}
+
+func TestRecordRunErrPreservesFirstFailure(t *testing.T) {
+ srv := &Server{
+ Logger: quietLifecycleLogger(),
+ runErrCh: make(chan struct{}),
+ }
+
+ srv.recordRunErr(errors.New("first"))
+ srv.recordRunErr(errors.New("second"))
+
+ require.Error(t, srv.runErr, "first failure must be retained")
+ assert.Contains(t, srv.runErr.Error(), "first", "second call must not overwrite the cached error")
+
+ select {
+ case <-srv.runErrCh:
+ default:
+ t.Fatal("recordRunErr must close runErrCh so waitAndStop unblocks")
+ }
+}
+
+func TestStopSkipsShutdownWhenNeverStarted(t *testing.T) {
+ srv := New(t.Context(), Config{Logger: quietLifecycleLogger()})
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ err := srv.Stop(ctx)
+ assert.NoError(t, err, "Stop on an unstarted server should not block on the cancelled ctx")
+}
+
+func TestRedactMappingForLog_ScrubsSensitiveFields(t *testing.T) {
+ original := &proto.ProxyMapping{
+ Id: "svc-1",
+ Domain: "example.com",
+ AuthToken: "super-secret-token",
+ Auth: &proto.Authentication{
+ SessionKey: "pubkey-not-secret",
+ HeaderAuths: []*proto.HeaderAuth{
+ {Header: "Authorization", HashedValue: "argon2-hash-1"},
+ {Header: "X-Api-Key", HashedValue: "argon2-hash-2"},
+ },
+ },
+ Path: []*proto.PathMapping{
+ {
+ Path: "/api",
+ Target: "10.0.0.1:8080",
+ Options: &proto.PathTargetOptions{
+ CustomHeaders: map[string]string{
+ "Authorization": "Bearer upstream-token",
+ "X-Tenant": "acme",
+ },
+ },
+ },
+ },
+ }
+
+ redacted := redactMappingForLog(original)
+
+ assert.Equal(t, "super-secret-token", original.AuthToken, "original must not be mutated")
+ assert.Equal(t, "argon2-hash-1", original.Auth.HeaderAuths[0].HashedValue, "original header hash must not be mutated")
+ assert.Equal(t, "Bearer upstream-token", original.Path[0].Options.CustomHeaders["Authorization"], "original custom header must not be mutated")
+
+ assert.Equal(t, "[REDACTED]", redacted.AuthToken, "auth_token must be redacted")
+ require.Len(t, redacted.Auth.HeaderAuths, 2, "header auths must be preserved in count")
+ assert.Equal(t, "Authorization", redacted.Auth.HeaderAuths[0].Header, "header name must be preserved")
+ assert.Equal(t, "[REDACTED]", redacted.Auth.HeaderAuths[0].HashedValue, "hashed_value must be redacted")
+ assert.Equal(t, "[REDACTED]", redacted.Auth.HeaderAuths[1].HashedValue, "hashed_value must be redacted for every header auth")
+ assert.Equal(t, "pubkey-not-secret", redacted.Auth.SessionKey, "session_key (public) must be preserved")
+
+ headers := redacted.Path[0].Options.CustomHeaders
+ require.Len(t, headers, 2, "custom header keys must be preserved")
+ assert.Equal(t, "[REDACTED]", headers["Authorization"], "custom header values must be redacted")
+ assert.Equal(t, "[REDACTED]", headers["X-Tenant"], "every custom header value must be redacted")
+
+ assert.Equal(t, "svc-1", redacted.Id, "non-sensitive fields must round-trip")
+ assert.Equal(t, "example.com", redacted.Domain, "non-sensitive fields must round-trip")
+}
+
+func TestRedactMappingForLog_HandlesEmptyOrNilFields(t *testing.T) {
+ empty := &proto.ProxyMapping{Id: "svc-empty"}
+ redacted := redactMappingForLog(empty)
+
+ assert.Equal(t, "", redacted.AuthToken, "empty auth_token must remain empty (no placeholder)")
+ assert.Nil(t, redacted.Auth, "nil Auth must remain nil")
+ assert.Empty(t, redacted.Path, "empty Path must remain empty")
+}
diff --git a/proxy/snapshot_reconcile_test.go b/proxy/snapshot_reconcile_test.go
index 042d8df77..2e6c80cfc 100644
--- a/proxy/snapshot_reconcile_test.go
+++ b/proxy/snapshot_reconcile_test.go
@@ -4,6 +4,7 @@ import (
"context"
"io"
"testing"
+ "time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
@@ -139,7 +140,7 @@ func TestHandleMappingStream_BatchedSnapshotSyncComplete(t *testing.T) {
}
syncDone := false
- err := s.handleMappingStream(context.Background(), stream, &syncDone)
+ err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
assert.NoError(t, err)
assert.True(t, syncDone, "sync should be marked done after final batch")
}
@@ -164,7 +165,7 @@ func TestHandleMappingStream_PostSyncDoesNotReconcile(t *testing.T) {
}
syncDone := true // sync already completed in a previous stream
- err := s.handleMappingStream(context.Background(), stream, &syncDone)
+ err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
require.NoError(t, err)
assert.Len(t, s.lastMappings, 2,
@@ -185,7 +186,7 @@ func TestHandleMappingStream_ImmediateEOF_NoReconciliation(t *testing.T) {
stream := &mockMappingStream{} // no messages → immediate EOF
syncDone := false
- err := s.handleMappingStream(context.Background(), stream, &syncDone)
+ err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
assert.NoError(t, err)
assert.False(t, syncDone, "sync should not be marked done on immediate EOF")
@@ -218,7 +219,7 @@ func TestHandleMappingStream_ErrorMidSync_NoReconciliation(t *testing.T) {
s.lastMappings["svc-stale"] = &proto.ProxyMapping{Id: "svc-stale", AccountId: "acct-1"}
syncDone := false
- err := s.handleMappingStream(context.Background(), &mockErrRecvStream{}, &syncDone)
+ err := s.handleMappingStream(context.Background(), &mockErrRecvStream{}, &syncDone, time.Time{})
assert.Error(t, err)
assert.False(t, syncDone)
diff --git a/proxy/sync_mappings_test.go b/proxy/sync_mappings_test.go
new file mode 100644
index 000000000..801587e4c
--- /dev/null
+++ b/proxy/sync_mappings_test.go
@@ -0,0 +1,525 @@
+package proxy
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/credentials/insecure"
+ grpcstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+func TestIntegration_SyncMappings_HappyPath(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ client := proto.NewProxyServiceClient(conn)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ stream, err := client.SyncMappings(ctx)
+ require.NoError(t, err)
+
+ // Send init.
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: "sync-proxy-1",
+ Version: "test-v1",
+ Address: "test.proxy.io",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ mappingsByID := make(map[string]*proto.ProxyMapping)
+ for {
+ msg, err := stream.Recv()
+ require.NoError(t, err)
+ for _, m := range msg.GetMapping() {
+ mappingsByID[m.GetId()] = m
+ }
+
+ // Ack every batch.
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ })
+ require.NoError(t, err)
+
+ if msg.GetInitialSyncComplete() {
+ break
+ }
+ }
+
+ assert.Len(t, mappingsByID, 2, "Should receive 2 mappings")
+
+ rp1 := mappingsByID["rp-1"]
+ require.NotNil(t, rp1)
+ assert.Equal(t, "app1.test.proxy.io", rp1.GetDomain())
+ assert.Equal(t, "test-account-1", rp1.GetAccountId())
+ assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, rp1.GetType())
+ assert.NotEmpty(t, rp1.GetAuthToken(), "Should have auth token")
+
+ rp2 := mappingsByID["rp-2"]
+ require.NotNil(t, rp2)
+ assert.Equal(t, "app2.test.proxy.io", rp2.GetDomain())
+}
+
+func TestIntegration_SyncMappings_BackPressure(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ // Add enough services to guarantee multiple batches (default batch size 500).
+ addServicesToStore(t, setup, 600, "test.proxy.io")
+
+ conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ client := proto.NewProxyServiceClient(conn)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ stream, err := client.SyncMappings(ctx)
+ require.NoError(t, err)
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: "sync-proxy-backpressure",
+ Version: "test-v1",
+ Address: "test.proxy.io",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ // Strategy: receive batch 1, then hold for a significant delay before
+ // acking. If back-pressure works, batch 2 cannot arrive until after
+ // the ack is sent — so its receive timestamp must be >= the ack
+ // timestamp. If management were fire-and-forget, all batches would
+ // already be buffered in the gRPC transport and batch 2 would arrive
+ // well before the ack time.
+ const ackDelay = 300 * time.Millisecond
+
+ type batchEvent struct {
+ recvAt time.Time
+ ackAt time.Time
+ count int
+ }
+ var batches []batchEvent
+ var totalMappings int
+
+ for {
+ msg, err := stream.Recv()
+ require.NoError(t, err)
+
+ recvAt := time.Now()
+ totalMappings += len(msg.GetMapping())
+
+ // Delay the ack on non-final batches to create a measurable gap.
+ if !msg.GetInitialSyncComplete() {
+ time.Sleep(ackDelay)
+ }
+
+ ackAt := time.Now()
+ batches = append(batches, batchEvent{
+ recvAt: recvAt,
+ ackAt: ackAt,
+ count: len(msg.GetMapping()),
+ })
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ })
+ require.NoError(t, err)
+
+ if msg.GetInitialSyncComplete() {
+ break
+ }
+ }
+
+ // 2 original + 600 added = 602 services total.
+ assert.Equal(t, 602, totalMappings, "should receive all 602 mappings")
+ require.GreaterOrEqual(t, len(batches), 2, "need at least 2 batches to verify back-pressure")
+
+ // For every batch after the first, its receive time must be after the
+ // previous batch's ack time. This proves management waited for the ack
+ // before sending the next batch.
+ for i := 1; i < len(batches); i++ {
+ prevAckAt := batches[i-1].ackAt
+ thisRecvAt := batches[i].recvAt
+ assert.True(t, !thisRecvAt.Before(prevAckAt),
+ "batch %d received at %v, but batch %d was acked at %v — "+
+ "management sent the next batch before receiving the ack",
+ i, thisRecvAt, i-1, prevAckAt)
+ }
+}
+
+func TestIntegration_SyncMappings_IncrementalUpdate(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ client := proto.NewProxyServiceClient(conn)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ stream, err := client.SyncMappings(ctx)
+ require.NoError(t, err)
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: "sync-proxy-incremental",
+ Version: "test-v1",
+ Address: "test.proxy.io",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ // Drain initial snapshot.
+ for {
+ msg, err := stream.Recv()
+ require.NoError(t, err)
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ })
+ require.NoError(t, err)
+
+ if msg.GetInitialSyncComplete() {
+ break
+ }
+ }
+
+ // Now send an incremental update via the management server.
+ setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{
+ Mapping: []*proto.ProxyMapping{{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED,
+ Id: "rp-1",
+ AccountId: "test-account-1",
+ Domain: "app1.test.proxy.io",
+ }},
+ })
+
+ // Receive the incremental update on the sync stream.
+ msg, err := stream.Recv()
+ require.NoError(t, err)
+ require.NotEmpty(t, msg.GetMapping())
+ assert.Equal(t, "rp-1", msg.GetMapping()[0].GetId())
+ assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, msg.GetMapping()[0].GetType())
+}
+
+func TestIntegration_SyncMappings_MixedProxyVersions(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ client := proto.NewProxyServiceClient(conn)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Old proxy uses GetMappingUpdate.
+ legacyStream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{
+ ProxyId: "legacy-proxy",
+ Version: "old-v1",
+ Address: "test.proxy.io",
+ })
+ require.NoError(t, err)
+
+ var legacyMappings []*proto.ProxyMapping
+ for {
+ msg, err := legacyStream.Recv()
+ require.NoError(t, err)
+ legacyMappings = append(legacyMappings, msg.GetMapping()...)
+ if msg.GetInitialSyncComplete() {
+ break
+ }
+ }
+
+ // New proxy uses SyncMappings.
+ syncStream, err := client.SyncMappings(ctx)
+ require.NoError(t, err)
+
+ err = syncStream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: "new-proxy",
+ Version: "new-v2",
+ Address: "test.proxy.io",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ var syncMappings []*proto.ProxyMapping
+ for {
+ msg, err := syncStream.Recv()
+ require.NoError(t, err)
+ syncMappings = append(syncMappings, msg.GetMapping()...)
+
+ err = syncStream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ })
+ require.NoError(t, err)
+
+ if msg.GetInitialSyncComplete() {
+ break
+ }
+ }
+
+ // Both should receive the same set of mappings.
+ assert.Equal(t, len(legacyMappings), len(syncMappings),
+ "legacy and sync proxies should receive the same number of mappings")
+
+ legacyIDs := make(map[string]bool)
+ for _, m := range legacyMappings {
+ legacyIDs[m.GetId()] = true
+ }
+ for _, m := range syncMappings {
+ assert.True(t, legacyIDs[m.GetId()],
+ "mapping %s should be present in both streams", m.GetId())
+ }
+
+ // Both proxies should be connected.
+ proxies := setup.proxyService.GetConnectedProxies()
+ assert.Contains(t, proxies, "legacy-proxy")
+ assert.Contains(t, proxies, "new-proxy")
+
+ // Both should receive incremental updates.
+ setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{
+ Mapping: []*proto.ProxyMapping{{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED,
+ Id: "rp-1",
+ AccountId: "test-account-1",
+ Domain: "app1.test.proxy.io",
+ }},
+ })
+
+ // Legacy proxy receives via GetMappingUpdateResponse.
+ legacyMsg, err := legacyStream.Recv()
+ require.NoError(t, err)
+ assert.Equal(t, "rp-1", legacyMsg.GetMapping()[0].GetId())
+
+ // Sync proxy receives via SyncMappingsResponse.
+ syncMsg, err := syncStream.Recv()
+ require.NoError(t, err)
+ assert.Equal(t, "rp-1", syncMsg.GetMapping()[0].GetId())
+}
+
+func TestIntegration_SyncMappings_Reconnect(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ client := proto.NewProxyServiceClient(conn)
+ proxyID := "sync-proxy-reconnect"
+
+ receiveMappings := func() []*proto.ProxyMapping {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ stream, err := client.SyncMappings(ctx)
+ require.NoError(t, err)
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: proxyID,
+ Version: "test-v1",
+ Address: "test.proxy.io",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ var mappings []*proto.ProxyMapping
+ for {
+ msg, err := stream.Recv()
+ require.NoError(t, err)
+ mappings = append(mappings, msg.GetMapping()...)
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ })
+ require.NoError(t, err)
+
+ if msg.GetInitialSyncComplete() {
+ break
+ }
+ }
+ return mappings
+ }
+
+ first := receiveMappings()
+ time.Sleep(100 * time.Millisecond)
+ second := receiveMappings()
+
+ assert.Equal(t, len(first), len(second),
+ "should receive same mappings on reconnect")
+
+ firstIDs := make(map[string]bool)
+ for _, m := range first {
+ firstIDs[m.GetId()] = true
+ }
+ for _, m := range second {
+ assert.True(t, firstIDs[m.GetId()],
+ "mapping %s should be present in both connections", m.GetId())
+ }
+}
+
+// --- Fallback tests: old management returns Unimplemented ---
+
+// unimplementedProxyServer embeds UnimplementedProxyServiceServer so
+// SyncMappings returns codes.Unimplemented while GetMappingUpdate works.
+type unimplementedSyncServer struct {
+ proto.UnimplementedProxyServiceServer
+ getMappingCalls atomic.Int32
+}
+
+func (s *unimplementedSyncServer) GetMappingUpdate(_ *proto.GetMappingUpdateRequest, stream proto.ProxyService_GetMappingUpdateServer) error {
+ s.getMappingCalls.Add(1)
+ return stream.Send(&proto.GetMappingUpdateResponse{
+ Mapping: []*proto.ProxyMapping{{Id: "svc-1", AccountId: "acct-1", Domain: "example.com"}},
+ InitialSyncComplete: true,
+ })
+}
+
+func TestIntegration_FallbackToGetMappingUpdate(t *testing.T) {
+ // Start a gRPC server that does NOT implement SyncMappings.
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+
+ srv := &unimplementedSyncServer{}
+ grpcServer := grpc.NewServer()
+ proto.RegisterProxyServiceServer(grpcServer, srv)
+ go func() { _ = grpcServer.Serve(lis) }()
+ defer grpcServer.GracefulStop()
+
+ conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ client := proto.NewProxyServiceClient(conn)
+
+ // Try SyncMappings — should get Unimplemented.
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ stream, err := client.SyncMappings(ctx)
+ require.NoError(t, err)
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: "test-proxy",
+ Address: "test.example.com",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ _, err = stream.Recv()
+ require.Error(t, err)
+ st, ok := grpcstatus.FromError(err)
+ require.True(t, ok)
+ assert.Equal(t, codes.Unimplemented, st.Code(),
+ "unimplemented SyncMappings should return Unimplemented code")
+
+ // isSyncUnimplemented should detect this.
+ assert.True(t, isSyncUnimplemented(err))
+
+ // The actual fallback: GetMappingUpdate should work.
+ legacyStream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{
+ ProxyId: "test-proxy",
+ Address: "test.example.com",
+ })
+ require.NoError(t, err)
+
+ msg, err := legacyStream.Recv()
+ require.NoError(t, err)
+ assert.True(t, msg.GetInitialSyncComplete())
+ assert.Len(t, msg.GetMapping(), 1)
+ assert.Equal(t, int32(1), srv.getMappingCalls.Load())
+}
+
+func TestIsSyncUnimplemented(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {"nil error", nil, false},
+ {"non-grpc error", errors.New("random"), false},
+ {"grpc internal", grpcstatus.Error(codes.Internal, "fail"), false},
+ {"grpc unavailable", grpcstatus.Error(codes.Unavailable, "fail"), false},
+ {"grpc unimplemented", grpcstatus.Error(codes.Unimplemented, "method not found"), true},
+ {
+ "wrapped unimplemented",
+ fmt.Errorf("create sync stream: %w", grpcstatus.Error(codes.Unimplemented, "nope")),
+ // grpc/status.FromError unwraps in recent versions of grpc-go.
+ true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, isSyncUnimplemented(tt.err))
+ })
+ }
+}
+
+// addServicesToStore adds n extra services to the test store for the given cluster.
+func addServicesToStore(t *testing.T, setup *integrationTestSetup, n int, cluster string) {
+ t.Helper()
+ ctx := context.Background()
+ for i := 0; i < n; i++ {
+ svc := &service.Service{
+ ID: fmt.Sprintf("extra-svc-%d", i),
+ AccountID: "test-account-1",
+ Name: fmt.Sprintf("Extra Service %d", i),
+ Domain: fmt.Sprintf("extra-%d.test.proxy.io", i),
+ ProxyCluster: cluster,
+ Enabled: true,
+ Targets: []*service.Target{{
+ Path: strPtr("/"),
+ Host: fmt.Sprintf("10.0.1.%d", i%256),
+ Port: 8080,
+ Protocol: "http",
+ TargetId: fmt.Sprintf("peer-extra-%d", i),
+ TargetType: "peer",
+ Enabled: true,
+ }},
+ }
+ require.NoError(t, setup.store.CreateService(ctx, svc))
+ }
+}
diff --git a/release_files/install.sh b/release_files/install.sh
index 1e71936f3..a002de472 100755
--- a/release_files/install.sh
+++ b/release_files/install.sh
@@ -417,15 +417,30 @@ if type uname >/dev/null 2>&1; then
# Check the availability of a compatible package manager
if check_use_bin_variable; then
PACKAGE_MANAGER="bin"
+ elif [ -e /run/ostree-booted ]; then
+ if [ -x "$(command -v rpm-ostree)" ]; then
+ PACKAGE_MANAGER="rpm-ostree"
+ echo "The installation will be performed using rpm-ostree package manager"
+ elif [ -x "$(command -v bootc)" ]; then
+ echo "Detected bootc system without rpm-ostree." >&2
+ echo "NetBird cannot be installed via package manager on this system." >&2
+ echo "Options:" >&2
+ echo " 1. Install via Distrobox (instructions in the installation docs)" >&2
+ echo " 2. Rebuild your base image with rpm-ostree included" >&2
+ echo " 3. Bake NetBird into your Containerfile" >&2
+ exit 1
+ else
+ echo "Detected ostree-booted system without rpm-ostree or bootc." >&2
+ echo "NetBird cannot be installed automatically on this atomic system." >&2
+ echo "Please install NetBird by rebuilding your base image or use a supported package manager." >&2
+ exit 1
+ fi
elif [ -x "$(command -v apt-get)" ]; then
PACKAGE_MANAGER="apt"
echo "The installation will be performed using apt package manager"
elif [ -x "$(command -v dnf)" ]; then
PACKAGE_MANAGER="dnf"
echo "The installation will be performed using dnf package manager"
- elif [ -x "$(command -v rpm-ostree)" ]; then
- PACKAGE_MANAGER="rpm-ostree"
- echo "The installation will be performed using rpm-ostree package manager"
elif [ -x "$(command -v yum)" ]; then
PACKAGE_MANAGER="yum"
echo "The installation will be performed using yum package manager"
diff --git a/shared/context/keys.go b/shared/context/keys.go
index c5b5da044..3287a6366 100644
--- a/shared/context/keys.go
+++ b/shared/context/keys.go
@@ -3,6 +3,8 @@ package context
const (
RequestIDKey = "requestID"
AccountIDKey = "accountID"
+ RoleKey = "role"
UserIDKey = "userID"
PeerIDKey = "peerID"
+ UserAgentKey = "userAgent"
)
diff --git a/shared/management/client/client.go b/shared/management/client/client.go
index 18efba87b..8205e3a4f 100644
--- a/shared/management/client/client.go
+++ b/shared/management/client/client.go
@@ -16,6 +16,10 @@ type Client interface {
Job(ctx context.Context, msgHandler func(msg *proto.JobRequest) *proto.JobResponse) error
Register(setupKey string, jwtToken string, sysInfo *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
Login(sysInfo *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
+ // ExtendAuthSession refreshes the peer's SSO session deadline using a fresh JWT.
+ // Returns the new absolute deadline; zero time when the server reports the peer
+ // is not eligible for session extension.
+ ExtendAuthSession(sysInfo *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error)
GetDeviceAuthorizationFlow() (*proto.DeviceAuthorizationFlow, error)
GetPKCEAuthorizationFlow() (*proto.PKCEAuthorizationFlow, error)
GetNetworkMap(sysInfo *system.Info) (*proto.NetworkMap, error)
diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go
index a8e8172dc..53f3a262d 100644
--- a/shared/management/client/client_test.go
+++ b/shared/management/client/client_test.go
@@ -17,8 +17,8 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
- "github.com/netbirdio/management-integrations/integrations"
ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
@@ -89,7 +89,7 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) {
gomock.Any(),
gomock.Any(),
).
- Return(true, nil).
+ Return(true, context.Background(), nil).
AnyTimes()
peersManger := peers.NewManager(store, permissionsManagerMock)
@@ -103,7 +103,7 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) {
t.Fatal(err)
}
- ia, _ := integrations.NewIntegratedValidator(ctx, peersManger, settingsManagerMock, eventStore, cacheStore)
+ ia, _ := validator.NewIntegratedValidator(ctx, peersManger, settingsManagerMock, eventStore, cacheStore)
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
require.NoError(t, err)
diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go
index 5b200e9d9..38e6992cf 100644
--- a/shared/management/client/grpc.go
+++ b/shared/management/client/grpc.go
@@ -607,6 +607,61 @@ func (c *GrpcClient) Login(sysInfo *system.Info, pubSSHKey []byte, dnsLabels dom
return c.login(&proto.LoginRequest{Meta: infoToMetaData(sysInfo), PeerKeys: keys, DnsLabels: dnsLabels.ToPunycodeList()})
}
+// ExtendAuthSession refreshes the peer's SSO session deadline on the management
+// server using a freshly issued JWT. The tunnel is untouched: no network map
+// sync, no peer reconnect. Returns the new absolute UTC deadline (zero time
+// when the server reports the field empty).
+func (c *GrpcClient) ExtendAuthSession(sysInfo *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error) {
+ if !c.ready() {
+ return nil, errors.New(errMsgNoMgmtConnection)
+ }
+
+ serverKey, err := c.getServerPublicKey()
+ if err != nil {
+ return nil, err
+ }
+
+ reqBody, err := encryption.EncryptMessage(*serverKey, c.key, &proto.ExtendAuthSessionRequest{
+ JwtToken: jwtToken,
+ Meta: infoToMetaData(sysInfo),
+ })
+ if err != nil {
+ log.Errorf("failed to encrypt extend auth session message: %s", err)
+ return nil, err
+ }
+
+ var resp *proto.EncryptedMessage
+ operation := func() error {
+ mgmCtx, cancel := context.WithTimeout(context.Background(), ConnectTimeout)
+ defer cancel()
+
+ var err error
+ resp, err = c.realClient.ExtendAuthSession(mgmCtx, &proto.EncryptedMessage{
+ WgPubKey: c.key.PublicKey().String(),
+ Body: reqBody,
+ })
+ if err != nil {
+ if s, ok := gstatus.FromError(err); ok && s.Code() == codes.Canceled {
+ return err
+ }
+ return backoff.Permanent(err)
+ }
+ return nil
+ }
+
+ if err := backoff.Retry(operation, nbgrpc.Backoff(c.ctx)); err != nil {
+ log.Errorf("failed to extend auth session on Management Service: %v", err)
+ return nil, err
+ }
+
+ out := &proto.ExtendAuthSessionResponse{}
+ if err := encryption.DecryptMessage(*serverKey, c.key, resp.Body, out); err != nil {
+ log.Errorf("failed to decrypt extend auth session response: %s", err)
+ return nil, err
+ }
+ return out, nil
+}
+
// GetDeviceAuthorizationFlow returns a device authorization flow information.
// It also takes care of encrypting and decrypting messages.
func (c *GrpcClient) GetDeviceAuthorizationFlow() (*proto.DeviceAuthorizationFlow, error) {
diff --git a/shared/management/client/mock.go b/shared/management/client/mock.go
index 361e8ffad..ba156a225 100644
--- a/shared/management/client/mock.go
+++ b/shared/management/client/mock.go
@@ -14,6 +14,7 @@ type MockClient struct {
SyncFunc func(ctx context.Context, sysInfo *system.Info, msgHandler func(msg *proto.SyncResponse) error) error
RegisterFunc func(setupKey string, jwtToken string, info *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
LoginFunc func(info *system.Info, sshKey []byte, dnsLabels domain.List) (*proto.LoginResponse, error)
+ ExtendAuthSessionFunc func(info *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error)
GetDeviceAuthorizationFlowFunc func() (*proto.DeviceAuthorizationFlow, error)
GetPKCEAuthorizationFlowFunc func() (*proto.PKCEAuthorizationFlow, error)
GetServerURLFunc func() string
@@ -65,6 +66,13 @@ func (m *MockClient) Login(info *system.Info, sshKey []byte, dnsLabels domain.Li
return m.LoginFunc(info, sshKey, dnsLabels)
}
+func (m *MockClient) ExtendAuthSession(info *system.Info, jwtToken string) (*proto.ExtendAuthSessionResponse, error) {
+ if m.ExtendAuthSessionFunc == nil {
+ return nil, nil
+ }
+ return m.ExtendAuthSessionFunc(info, jwtToken)
+}
+
func (m *MockClient) GetDeviceAuthorizationFlow() (*proto.DeviceAuthorizationFlow, error) {
if m.GetDeviceAuthorizationFlowFunc == nil {
return nil, nil
diff --git a/shared/management/client/rest/client.go b/shared/management/client/rest/client.go
index f0cb4d2d1..43312b9e6 100644
--- a/shared/management/client/rest/client.go
+++ b/shared/management/client/rest/client.go
@@ -143,6 +143,10 @@ type Client struct {
// ReverseProxyDomains NetBird reverse proxy domains APIs
ReverseProxyDomains *ReverseProxyDomainsAPI
+
+ // ReverseProxyTokens account-scoped proxy access tokens used to register
+ // self-hosted (bring-your-own-proxy) `netbird proxy` instances.
+ ReverseProxyTokens *ReverseProxyTokensAPI
}
// New initialize new Client instance using PAT token
@@ -204,6 +208,7 @@ func (c *Client) initialize() {
c.ReverseProxyServices = &ReverseProxyServicesAPI{c}
c.ReverseProxyClusters = &ReverseProxyClustersAPI{c}
c.ReverseProxyDomains = &ReverseProxyDomainsAPI{c}
+ c.ReverseProxyTokens = &ReverseProxyTokensAPI{c}
}
// NewRequest creates and executes new management API request
diff --git a/shared/management/client/rest/reverse_proxy_clusters.go b/shared/management/client/rest/reverse_proxy_clusters.go
index b55cd35a3..ca9714dc0 100644
--- a/shared/management/client/rest/reverse_proxy_clusters.go
+++ b/shared/management/client/rest/reverse_proxy_clusters.go
@@ -2,6 +2,8 @@ package rest
import (
"context"
+ "errors"
+ "net/url"
"github.com/netbirdio/netbird/shared/management/http/api"
)
@@ -11,7 +13,10 @@ type ReverseProxyClustersAPI struct {
c *Client
}
-// List lists all available proxy clusters
+// List lists all available proxy clusters. Each cluster is enriched with the
+// capability flags reported by its connected proxies (supports_custom_ports,
+// supports_crowdsec, private, etc.), so callers can render UX gates without
+// a follow-up round-trip.
func (a *ReverseProxyClustersAPI) List(ctx context.Context) ([]api.ProxyCluster, error) {
resp, err := a.c.NewRequest(ctx, "GET", "/api/reverse-proxies/clusters", nil, nil)
if err != nil {
@@ -23,3 +28,24 @@ func (a *ReverseProxyClustersAPI) List(ctx context.Context) ([]api.ProxyCluster,
ret, err := parseResponse[[]api.ProxyCluster](resp)
return ret, err
}
+
+// Delete removes every self-hosted (BYOP) proxy registration for the given
+// cluster address owned by the calling account. Shared clusters operated by
+// NetBird cannot be deleted via this endpoint; the server returns 404 / 400
+// for cluster addresses the account does not own.
+func (a *ReverseProxyClustersAPI) Delete(ctx context.Context, clusterAddress string) error {
+ // Guard against the empty input: url.PathEscape("") returns "" which
+ // would collapse the request URL onto the collection endpoint and
+ // silently delete nothing (or 405 depending on routing).
+ if clusterAddress == "" {
+ return errors.New("clusterAddress is required")
+ }
+ resp, err := a.c.NewRequest(ctx, "DELETE", "/api/reverse-proxies/clusters/"+url.PathEscape(clusterAddress), nil, nil)
+ if err != nil {
+ return err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ return nil
+}
diff --git a/shared/management/client/rest/reverse_proxy_clusters_test.go b/shared/management/client/rest/reverse_proxy_clusters_test.go
new file mode 100644
index 000000000..16f955d5a
--- /dev/null
+++ b/shared/management/client/rest/reverse_proxy_clusters_test.go
@@ -0,0 +1,104 @@
+//go:build integration
+
+package rest_test
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/shared/management/client/rest"
+ "github.com/netbirdio/netbird/shared/management/http/api"
+ "github.com/netbirdio/netbird/shared/management/http/util"
+)
+
+func boolPtr(b bool) *bool { return &b }
+
+var testCluster = api.ProxyCluster{
+ Id: "cluster-1",
+ Address: "proxy.netbird.local",
+ Type: "shared",
+ Online: true,
+ ConnectedProxies: 2,
+ SupportsCustomPorts: boolPtr(true),
+ RequireSubdomain: boolPtr(false),
+ SupportsCrowdsec: boolPtr(false),
+ Private: boolPtr(true),
+}
+
+func TestReverseProxyClusters_List_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/clusters", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "GET", r.Method, "List must use GET")
+ retBytes, _ := json.Marshal([]api.ProxyCluster{testCluster})
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.ReverseProxyClusters.List(context.Background())
+ require.NoError(t, err)
+ require.Len(t, ret, 1)
+ assert.Equal(t, testCluster.Id, ret[0].Id)
+ assert.Equal(t, testCluster.Address, ret[0].Address)
+ require.NotNil(t, ret[0].Private, "private capability must round-trip through the client")
+ assert.True(t, *ret[0].Private, "private capability must reflect the server value")
+ })
+}
+
+func TestReverseProxyClusters_List_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/clusters", func(w http.ResponseWriter, r *http.Request) {
+ retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 500})
+ w.WriteHeader(500)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.ReverseProxyClusters.List(context.Background())
+ assert.Error(t, err)
+ assert.Empty(t, ret)
+ })
+}
+
+func TestReverseProxyClusters_Delete_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ // PathEscape on "proxy.netbird.local" leaves it intact; the route mux
+ // matches the unescaped form. Sanity-check both the method and that
+ // path-escaping doesn't double-encode the dotted address.
+ mux.HandleFunc("/api/reverse-proxies/clusters/proxy.netbird.local", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "DELETE", r.Method, "Delete must use DELETE")
+ w.WriteHeader(200)
+ })
+ err := c.ReverseProxyClusters.Delete(context.Background(), "proxy.netbird.local")
+ require.NoError(t, err)
+ })
+}
+
+func TestReverseProxyClusters_Delete_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/clusters/proxy.netbird.local", func(w http.ResponseWriter, r *http.Request) {
+ retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404})
+ w.WriteHeader(404)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ err := c.ReverseProxyClusters.Delete(context.Background(), "proxy.netbird.local")
+ assert.Error(t, err)
+ })
+}
+
+// TestReverseProxyClusters_Delete_EmptyAddress guards against an empty
+// clusterAddress reaching the wire — that would collapse the URL onto
+// the collection endpoint instead of a specific cluster. The client
+// must short-circuit with a typed error before any request is issued.
+func TestReverseProxyClusters_Delete_EmptyAddress(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/clusters/", func(http.ResponseWriter, *http.Request) {
+ t.Fatal("empty clusterAddress must be rejected client-side; no request should reach the server")
+ })
+ err := c.ReverseProxyClusters.Delete(context.Background(), "")
+ assert.Error(t, err, "empty clusterAddress must surface as an error")
+ })
+}
diff --git a/shared/management/client/rest/reverse_proxy_services_test.go b/shared/management/client/rest/reverse_proxy_services_test.go
index 164563e97..1a93472db 100644
--- a/shared/management/client/rest/reverse_proxy_services_test.go
+++ b/shared/management/client/rest/reverse_proxy_services_test.go
@@ -116,8 +116,8 @@ func TestReverseProxyServices_Create_200(t *testing.T) {
Name: "test-service",
Domain: "test.example.com",
Enabled: true,
- Auth: api.ServiceAuthConfig{},
- Targets: []api.ServiceTarget{testServiceTarget},
+ Auth: &api.ServiceAuthConfig{},
+ Targets: &[]api.ServiceTarget{testServiceTarget},
})
require.NoError(t, err)
assert.Equal(t, testService.Id, ret.Id)
@@ -136,8 +136,8 @@ func TestReverseProxyServices_Create_Err(t *testing.T) {
Name: "test-service",
Domain: "test.example.com",
Enabled: true,
- Auth: api.ServiceAuthConfig{},
- Targets: []api.ServiceTarget{testServiceTarget},
+ Auth: &api.ServiceAuthConfig{},
+ Targets: &[]api.ServiceTarget{testServiceTarget},
})
assert.Error(t, err)
assert.Equal(t, "No", err.Error())
@@ -154,8 +154,9 @@ func TestReverseProxyServices_Create_WithPerTargetOptions(t *testing.T) {
var req api.ServiceRequest
require.NoError(t, json.Unmarshal(reqBytes, &req))
- require.Len(t, req.Targets, 1)
- target := req.Targets[0]
+ require.NotNil(t, req.Targets, "targets must be set on the request")
+ require.Len(t, *req.Targets, 1)
+ target := (*req.Targets)[0]
require.NotNil(t, target.Options, "options should be present")
opts := target.Options
require.NotNil(t, opts.SkipTlsVerify, "skip_tls_verify should be present")
@@ -177,8 +178,8 @@ func TestReverseProxyServices_Create_WithPerTargetOptions(t *testing.T) {
Name: "test-service",
Domain: "test.example.com",
Enabled: true,
- Auth: api.ServiceAuthConfig{},
- Targets: []api.ServiceTarget{
+ Auth: &api.ServiceAuthConfig{},
+ Targets: &[]api.ServiceTarget{
{
TargetId: "peer-123",
TargetType: "peer",
@@ -216,8 +217,8 @@ func TestReverseProxyServices_Update_200(t *testing.T) {
Name: "updated-service",
Domain: "test.example.com",
Enabled: true,
- Auth: api.ServiceAuthConfig{},
- Targets: []api.ServiceTarget{testServiceTarget},
+ Auth: &api.ServiceAuthConfig{},
+ Targets: &[]api.ServiceTarget{testServiceTarget},
})
require.NoError(t, err)
assert.Equal(t, testService.Id, ret.Id)
@@ -236,8 +237,8 @@ func TestReverseProxyServices_Update_Err(t *testing.T) {
Name: "updated-service",
Domain: "test.example.com",
Enabled: true,
- Auth: api.ServiceAuthConfig{},
- Targets: []api.ServiceTarget{testServiceTarget},
+ Auth: &api.ServiceAuthConfig{},
+ Targets: &[]api.ServiceTarget{testServiceTarget},
})
assert.Error(t, err)
assert.Equal(t, "No", err.Error())
diff --git a/shared/management/client/rest/reverse_proxy_tokens.go b/shared/management/client/rest/reverse_proxy_tokens.go
new file mode 100644
index 000000000..caa240395
--- /dev/null
+++ b/shared/management/client/rest/reverse_proxy_tokens.go
@@ -0,0 +1,79 @@
+package rest
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/url"
+
+ "github.com/netbirdio/netbird/shared/management/http/api"
+)
+
+// ReverseProxyTokensAPI exposes the account-scoped proxy access tokens that
+// self-hosted (bring-your-own-proxy) deployments use to register a
+// `netbird proxy` instance with management. Tokens are bound to the
+// calling account; revoking a token disconnects every proxy that
+// authenticated with it.
+type ReverseProxyTokensAPI struct {
+ c *Client
+}
+
+// List returns every proxy token the calling account has minted, including
+// already-revoked entries. The plain token is never returned — only the
+// metadata (id, name, created_at, last_used, revoked).
+func (a *ReverseProxyTokensAPI) List(ctx context.Context) ([]api.ProxyToken, error) {
+ resp, err := a.c.NewRequest(ctx, "GET", "/api/reverse-proxies/proxy-tokens", nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ ret, err := parseResponse[[]api.ProxyToken](resp)
+ return ret, err
+}
+
+// Create mints a fresh account-scoped proxy token. The returned
+// ProxyTokenCreated.PlainToken is shown only once — callers must persist
+// it immediately. Subsequent reads will only expose the token metadata,
+// not the secret material.
+func (a *ReverseProxyTokensAPI) Create(ctx context.Context, request api.ProxyTokenRequest) (*api.ProxyTokenCreated, error) {
+ requestBytes, err := json.Marshal(request)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := a.c.NewRequest(ctx, "POST", "/api/reverse-proxies/proxy-tokens", bytes.NewReader(requestBytes), nil)
+ if err != nil {
+ return nil, err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ ret, err := parseResponse[api.ProxyTokenCreated](resp)
+ if err != nil {
+ return nil, err
+ }
+ return &ret, nil
+}
+
+// Delete revokes a previously-issued proxy token by ID. Revoked tokens
+// remain in List output (with revoked=true) so operators can audit which
+// credentials existed; the plain secret can no longer authenticate any
+// new proxy registration.
+func (a *ReverseProxyTokensAPI) Delete(ctx context.Context, tokenID string) error {
+ // Guard against the empty input: url.PathEscape("") returns "" which
+ // would collapse the request URL onto the collection endpoint and
+ // silently delete nothing (or 405 depending on routing).
+ if tokenID == "" {
+ return errors.New("tokenID is required")
+ }
+ resp, err := a.c.NewRequest(ctx, "DELETE", "/api/reverse-proxies/proxy-tokens/"+url.PathEscape(tokenID), nil, nil)
+ if err != nil {
+ return err
+ }
+ if resp.Body != nil {
+ defer resp.Body.Close()
+ }
+ return nil
+}
diff --git a/shared/management/client/rest/reverse_proxy_tokens_test.go b/shared/management/client/rest/reverse_proxy_tokens_test.go
new file mode 100644
index 000000000..ecd80bd1a
--- /dev/null
+++ b/shared/management/client/rest/reverse_proxy_tokens_test.go
@@ -0,0 +1,144 @@
+//go:build integration
+
+package rest_test
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/shared/management/client/rest"
+ "github.com/netbirdio/netbird/shared/management/http/api"
+ "github.com/netbirdio/netbird/shared/management/http/util"
+)
+
+func intPtr(v int) *int { return &v }
+
+var testProxyToken = api.ProxyToken{
+ Id: "tok-1",
+ Name: "ci-runner",
+ CreatedAt: time.Date(2026, 5, 21, 9, 0, 0, 0, time.UTC),
+ Revoked: false,
+}
+
+var testProxyTokenCreated = api.ProxyTokenCreated{
+ Id: "tok-1",
+ Name: "ci-runner",
+ CreatedAt: time.Date(2026, 5, 21, 9, 0, 0, 0, time.UTC),
+ PlainToken: "nbproxy_abcdef0123456789",
+ Revoked: false,
+}
+
+func TestReverseProxyTokens_List_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "GET", r.Method, "List must use GET")
+ retBytes, _ := json.Marshal([]api.ProxyToken{testProxyToken})
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.ReverseProxyTokens.List(context.Background())
+ require.NoError(t, err)
+ require.Len(t, ret, 1)
+ assert.Equal(t, testProxyToken.Id, ret[0].Id)
+ assert.Equal(t, testProxyToken.Name, ret[0].Name)
+ })
+}
+
+func TestReverseProxyTokens_List_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens", func(w http.ResponseWriter, r *http.Request) {
+ retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 500})
+ w.WriteHeader(500)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.ReverseProxyTokens.List(context.Background())
+ assert.Error(t, err)
+ assert.Empty(t, ret)
+ })
+}
+
+func TestReverseProxyTokens_Create_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "POST", r.Method, "Create must use POST")
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ var req api.ProxyTokenRequest
+ require.NoError(t, json.Unmarshal(body, &req), "server must receive a valid ProxyTokenRequest body")
+ assert.Equal(t, "ci-runner", req.Name, "name must round-trip through the client")
+ require.NotNil(t, req.ExpiresIn, "expires_in must be sent when provided")
+ assert.Equal(t, 3600, *req.ExpiresIn, "expires_in value must round-trip unchanged")
+
+ retBytes, _ := json.Marshal(testProxyTokenCreated)
+ _, err = w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.ReverseProxyTokens.Create(context.Background(), api.ProxyTokenRequest{
+ Name: "ci-runner",
+ ExpiresIn: intPtr(3600),
+ })
+ require.NoError(t, err)
+ assert.Equal(t, testProxyTokenCreated.Id, ret.Id)
+ assert.Equal(t, testProxyTokenCreated.PlainToken, ret.PlainToken,
+ "PlainToken must be returned to the caller — it's the one-shot secret")
+ })
+}
+
+func TestReverseProxyTokens_Create_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens", func(w http.ResponseWriter, r *http.Request) {
+ retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Bad", Code: 400})
+ w.WriteHeader(400)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ ret, err := c.ReverseProxyTokens.Create(context.Background(), api.ProxyTokenRequest{Name: ""})
+ assert.Error(t, err)
+ assert.Nil(t, ret)
+ })
+}
+
+func TestReverseProxyTokens_Delete_200(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens/tok-1", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "DELETE", r.Method, "Delete must use DELETE")
+ w.WriteHeader(200)
+ })
+ err := c.ReverseProxyTokens.Delete(context.Background(), "tok-1")
+ require.NoError(t, err)
+ })
+}
+
+func TestReverseProxyTokens_Delete_Err(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens/tok-1", func(w http.ResponseWriter, r *http.Request) {
+ retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404})
+ w.WriteHeader(404)
+ _, err := w.Write(retBytes)
+ require.NoError(t, err)
+ })
+ err := c.ReverseProxyTokens.Delete(context.Background(), "tok-1")
+ assert.Error(t, err)
+ })
+}
+
+// TestReverseProxyTokens_Delete_EmptyID guards against an empty tokenID
+// reaching the wire — url.PathEscape("") would collapse the URL onto
+// the collection endpoint.
+func TestReverseProxyTokens_Delete_EmptyID(t *testing.T) {
+ withMockClient(func(c *rest.Client, mux *http.ServeMux) {
+ mux.HandleFunc("/api/reverse-proxies/proxy-tokens/", func(http.ResponseWriter, *http.Request) {
+ t.Fatal("empty tokenID must be rejected client-side; no request should reach the server")
+ })
+ err := c.ReverseProxyTokens.Delete(context.Background(), "")
+ assert.Error(t, err, "empty tokenID must surface as an error")
+ })
+}
diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml
index 4a6e1d7d3..9cd677951 100644
--- a/shared/management/http/api/openapi.yml
+++ b/shared/management/http/api/openapi.yml
@@ -3080,6 +3080,17 @@ components:
$ref: '#/components/schemas/AccessRestrictions'
meta:
$ref: '#/components/schemas/ServiceMeta'
+ private:
+ type: boolean
+ description: When true, the service is NetBird-only — its target points at a proxy cluster, inbound peers authenticate via their WireGuard tunnel identity (no OIDC), and an ACL policy is auto-generated from access_groups to the cluster's proxy-peer group. Requires mode=http.
+ default: false
+ example: false
+ access_groups:
+ type: array
+ items:
+ type: string
+ description: NetBird group IDs whose peers may reach this private service over the tunnel. Required when private=true; ignored otherwise. Mutually exclusive with bearer auth (SSO).
+ example: ["group-engineering"]
required:
- id
- name
@@ -3088,6 +3099,24 @@ components:
- enabled
- auth
- meta
+ allOf:
+ # When private=true, access_groups must be present and non-empty,
+ # and the service mode must be "http". The bearer-auth mutex is
+ # enforced at the service-validation layer
+ # (validatePrivateRequirements) because it sits in a nested
+ # ServiceAuthConfig and isn't cleanly expressible here.
+ - if:
+ required: [private]
+ properties:
+ private:
+ const: true
+ then:
+ required: [access_groups]
+ properties:
+ access_groups:
+ minItems: 1
+ mode:
+ const: http
ServiceMeta:
type: object
properties:
@@ -3160,10 +3189,38 @@ components:
$ref: '#/components/schemas/ServiceAuthConfig'
access_restrictions:
$ref: '#/components/schemas/AccessRestrictions'
+ private:
+ type: boolean
+ description: When true, the service is NetBird-only — its target points at a proxy cluster, inbound peers authenticate via their WireGuard tunnel identity (no OIDC), and an ACL policy is auto-generated from access_groups to the cluster's proxy-peer group. Requires mode=http.
+ default: false
+ example: false
+ access_groups:
+ type: array
+ items:
+ type: string
+ description: NetBird group IDs whose peers may reach this private service over the tunnel. Required when private=true; ignored otherwise. Mutually exclusive with bearer auth (SSO).
+ example: ["group-engineering"]
required:
- name
- domain
- enabled
+ allOf:
+ # Mirror of the Service conditional: when private=true the
+ # request must carry a non-empty access_groups list and the
+ # mode must be "http". The bearer-auth mutex is enforced at the
+ # service-validation layer (validatePrivateRequirements).
+ - if:
+ required: [private]
+ properties:
+ private:
+ const: true
+ then:
+ required: [access_groups]
+ properties:
+ access_groups:
+ minItems: 1
+ mode:
+ const: http
ServiceTargetOptions:
type: object
properties:
@@ -3198,6 +3255,15 @@ components:
type: string
description: Idle timeout before a UDP session is reaped, as a Go duration string (e.g. "30s", "2m").
example: "2m"
+ direct_upstream:
+ type: boolean
+ description: |
+ When true, the proxy dials this target via the host's network stack
+ instead of through its embedded NetBird client. Use for upstreams
+ reachable without WireGuard (public APIs, LAN services, localhost
+ sidecars).
+ default: false
+ example: false
ServiceTarget:
type: object
properties:
@@ -3208,7 +3274,7 @@ components:
target_type:
type: string
description: Target type
- enum: [peer, host, domain, subnet]
+ enum: [peer, host, domain, subnet, cluster]
example: "subnet"
path:
type: string
@@ -3452,6 +3518,10 @@ components:
type: boolean
description: Whether all active proxies in the cluster have CrowdSec configured
example: false
+ private:
+ type: boolean
+ description: True when at least one connected proxy in this cluster is running embedded in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel. Lets the dashboard distinguish per-peer / private clusters from centralised ones.
+ example: false
required:
- id
- address
@@ -3507,6 +3577,10 @@ components:
type: boolean
description: Whether the proxy cluster has CrowdSec configured
example: false
+ supports_private:
+ type: boolean
+ description: Whether the proxy cluster supports private (NetBird-only) services. True when at least one connected proxy in the cluster runs embedded in a netbird client.
+ example: false
required:
- id
- domain
@@ -5046,31 +5120,63 @@ components:
responses:
not_found:
description: Resource not found
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
validation_failed_simple:
description: Validation failed
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
bad_request:
description: Bad Request
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
internal_error:
description: Internal Server Error
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
validation_failed:
description: Validation failed
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
forbidden:
description: Forbidden
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
requires_authentication:
description: Requires authentication
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
conflict:
description: Conflict
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
+ headers:
+ X-Request-Id:
+ description: |
+ Unique identifier assigned to the request by the server and set on every
+ response. Useful for correlating client requests with server-side logs.
+ schema:
+ type: string
+ example: cot7r4n3l3vh3qj4qveg
securitySchemes:
BearerAuth:
type: http
diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go
index 5832be29b..093d2cad2 100644
--- a/shared/management/http/api/types.gen.go
+++ b/shared/management/http/api/types.gen.go
@@ -1063,15 +1063,18 @@ func (e ServiceTargetProtocol) Valid() bool {
// Defines values for ServiceTargetTargetType.
const (
- ServiceTargetTargetTypeDomain ServiceTargetTargetType = "domain"
- ServiceTargetTargetTypeHost ServiceTargetTargetType = "host"
- ServiceTargetTargetTypePeer ServiceTargetTargetType = "peer"
- ServiceTargetTargetTypeSubnet ServiceTargetTargetType = "subnet"
+ ServiceTargetTargetTypeCluster ServiceTargetTargetType = "cluster"
+ ServiceTargetTargetTypeDomain ServiceTargetTargetType = "domain"
+ ServiceTargetTargetTypeHost ServiceTargetTargetType = "host"
+ ServiceTargetTargetTypePeer ServiceTargetTargetType = "peer"
+ ServiceTargetTargetTypeSubnet ServiceTargetTargetType = "subnet"
)
// Valid indicates whether the value is a known member of the ServiceTargetTargetType enum.
func (e ServiceTargetTargetType) Valid() bool {
switch e {
+ case ServiceTargetTargetTypeCluster:
+ return true
case ServiceTargetTargetTypeDomain:
return true
case ServiceTargetTargetTypeHost:
@@ -3828,6 +3831,9 @@ type ProxyCluster struct {
// Online Whether at least one proxy in the cluster has heartbeated within the active window
Online bool `json:"online"`
+ // Private True when at least one connected proxy in this cluster is running embedded in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel. Lets the dashboard distinguish per-peer / private clusters from centralised ones.
+ Private *bool `json:"private,omitempty"`
+
// RequireSubdomain Whether services on this cluster must include a subdomain label
RequireSubdomain *bool `json:"require_subdomain,omitempty"`
@@ -3905,6 +3911,9 @@ type ReverseProxyDomain struct {
// SupportsCustomPorts Whether the cluster supports binding arbitrary TCP/UDP ports
SupportsCustomPorts *bool `json:"supports_custom_ports,omitempty"`
+ // SupportsPrivate Whether the proxy cluster supports private (NetBird-only) services. True when at least one connected proxy in the cluster runs embedded in a netbird client.
+ SupportsPrivate *bool `json:"supports_private,omitempty"`
+
// TargetCluster The proxy cluster this domain is validated against (only for custom domains)
TargetCluster *string `json:"target_cluster,omitempty"`
@@ -4094,6 +4103,9 @@ type SentinelOneMatchAttributesNetworkStatus string
// Service defines model for Service.
type Service struct {
+ // AccessGroups NetBird group IDs whose peers may reach this private service over the tunnel. Required when private=true; ignored otherwise. Mutually exclusive with bearer auth (SSO).
+ AccessGroups *[]string `json:"access_groups,omitempty"`
+
// AccessRestrictions Connection-level access restrictions based on IP address or geography. Applies to both HTTP and L4 services.
AccessRestrictions *AccessRestrictions `json:"access_restrictions,omitempty"`
Auth ServiceAuthConfig `json:"auth"`
@@ -4123,6 +4135,9 @@ type Service struct {
// PortAutoAssigned Whether the listen port was auto-assigned
PortAutoAssigned *bool `json:"port_auto_assigned,omitempty"`
+ // Private When true, the service is NetBird-only — its target points at a proxy cluster, inbound peers authenticate via their WireGuard tunnel identity (no OIDC), and an ACL policy is auto-generated from access_groups to the cluster's proxy-peer group. Requires mode=http.
+ Private *bool `json:"private,omitempty"`
+
// ProxyCluster The proxy cluster handling this service (derived from domain)
ProxyCluster *string `json:"proxy_cluster,omitempty"`
@@ -4165,6 +4180,9 @@ type ServiceMetaStatus string
// ServiceRequest defines model for ServiceRequest.
type ServiceRequest struct {
+ // AccessGroups NetBird group IDs whose peers may reach this private service over the tunnel. Required when private=true; ignored otherwise. Mutually exclusive with bearer auth (SSO).
+ AccessGroups *[]string `json:"access_groups,omitempty"`
+
// AccessRestrictions Connection-level access restrictions based on IP address or geography. Applies to both HTTP and L4 services.
AccessRestrictions *AccessRestrictions `json:"access_restrictions,omitempty"`
Auth *ServiceAuthConfig `json:"auth,omitempty"`
@@ -4187,6 +4205,9 @@ type ServiceRequest struct {
// PassHostHeader When true, the original client Host header is passed through to the backend instead of being rewritten to the backend's address
PassHostHeader *bool `json:"pass_host_header,omitempty"`
+ // Private When true, the service is NetBird-only — its target points at a proxy cluster, inbound peers authenticate via their WireGuard tunnel identity (no OIDC), and an ACL policy is auto-generated from access_groups to the cluster's proxy-peer group. Requires mode=http.
+ Private *bool `json:"private,omitempty"`
+
// RewriteRedirects When true, Location headers in backend responses are rewritten to replace the backend address with the public-facing domain
RewriteRedirects *bool `json:"rewrite_redirects,omitempty"`
@@ -4233,6 +4254,12 @@ type ServiceTargetOptions struct {
// CustomHeaders Extra headers sent to the backend. Hop-by-hop and proxy-managed headers (Host, Connection, Transfer-Encoding, etc.) are rejected.
CustomHeaders *map[string]string `json:"custom_headers,omitempty"`
+ // DirectUpstream When true, the proxy dials this target via the host's network stack
+ // instead of through its embedded NetBird client. Use for upstreams
+ // reachable without WireGuard (public APIs, LAN services, localhost
+ // sidecars).
+ DirectUpstream *bool `json:"direct_upstream,omitempty"`
+
// PathRewrite Controls how the request path is rewritten before forwarding to the backend. Default strips the matched prefix. "preserve" keeps the full original request path.
PathRewrite *ServiceTargetOptionsPathRewrite `json:"path_rewrite,omitempty"`
diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go
index ef029f79f..a3a468fed 100644
--- a/shared/management/proto/management.pb.go
+++ b/shared/management/proto/management.pb.go
@@ -381,7 +381,7 @@ func (x HostConfig_Protocol) Number() protoreflect.EnumNumber {
// Deprecated: Use HostConfig_Protocol.Descriptor instead.
func (HostConfig_Protocol) EnumDescriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{18, 0}
+ return file_management_proto_rawDescGZIP(), []int{20, 0}
}
type DeviceAuthorizationFlowProvider int32
@@ -424,7 +424,7 @@ func (x DeviceAuthorizationFlowProvider) Number() protoreflect.EnumNumber {
// Deprecated: Use DeviceAuthorizationFlowProvider.Descriptor instead.
func (DeviceAuthorizationFlowProvider) EnumDescriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{33, 0}
+ return file_management_proto_rawDescGZIP(), []int{35, 0}
}
type EncryptedMessage struct {
@@ -843,6 +843,15 @@ type SyncResponse struct {
NetworkMap *NetworkMap `protobuf:"bytes,5,opt,name=NetworkMap,proto3" json:"NetworkMap,omitempty"`
// Posture checks to be evaluated by client
Checks []*Checks `protobuf:"bytes,6,rep,name=Checks,proto3" json:"Checks,omitempty"`
+ // 3-state session deadline. Carried on every Sync snapshot so admin-side
+ // changes propagate live without a client reconnect.
+ //
+ // field unset (nil) → snapshot carries no info; client keeps the
+ // deadline it already had
+ // set, seconds=0 nanos=0 → explicit "expiry disabled" or peer is not
+ // SSO-registered; client clears its anchor
+ // set, valid timestamp → new absolute UTC deadline
+ SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"`
}
func (x *SyncResponse) Reset() {
@@ -919,6 +928,13 @@ func (x *SyncResponse) GetChecks() []*Checks {
return nil
}
+func (x *SyncResponse) GetSessionExpiresAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.SessionExpiresAt
+ }
+ return nil
+}
+
type SyncMetaRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -1604,6 +1620,12 @@ type LoginResponse struct {
PeerConfig *PeerConfig `protobuf:"bytes,2,opt,name=peerConfig,proto3" json:"peerConfig,omitempty"`
// Posture checks to be evaluated by client
Checks []*Checks `protobuf:"bytes,3,rep,name=Checks,proto3" json:"Checks,omitempty"`
+ // 3-state session deadline; same encoding as SyncResponse.sessionExpiresAt.
+ //
+ // field unset (nil) → no info; client keeps any deadline it had
+ // set, seconds=0 nanos=0 → explicit "expiry disabled" / non-SSO peer
+ // set, valid timestamp → new absolute UTC deadline
+ SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"`
}
func (x *LoginResponse) Reset() {
@@ -1659,6 +1681,125 @@ func (x *LoginResponse) GetChecks() []*Checks {
return nil
}
+func (x *LoginResponse) GetSessionExpiresAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.SessionExpiresAt
+ }
+ return nil
+}
+
+// ExtendAuthSessionRequest carries a fresh JWT to refresh the peer's session deadline.
+// The encrypted body of an EncryptedMessage with this payload is sent to the
+// ExtendAuthSession RPC.
+type ExtendAuthSessionRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ // SSO token (must be a fresh, valid JWT for the peer's owning user)
+ JwtToken string `protobuf:"bytes,1,opt,name=jwtToken,proto3" json:"jwtToken,omitempty"`
+ // Meta data of the peer (used for IdP user info refresh consistent with Login)
+ Meta *PeerSystemMeta `protobuf:"bytes,2,opt,name=meta,proto3" json:"meta,omitempty"`
+}
+
+func (x *ExtendAuthSessionRequest) Reset() {
+ *x = ExtendAuthSessionRequest{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_management_proto_msgTypes[15]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *ExtendAuthSessionRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ExtendAuthSessionRequest) ProtoMessage() {}
+
+func (x *ExtendAuthSessionRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_management_proto_msgTypes[15]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ExtendAuthSessionRequest.ProtoReflect.Descriptor instead.
+func (*ExtendAuthSessionRequest) Descriptor() ([]byte, []int) {
+ return file_management_proto_rawDescGZIP(), []int{15}
+}
+
+func (x *ExtendAuthSessionRequest) GetJwtToken() string {
+ if x != nil {
+ return x.JwtToken
+ }
+ return ""
+}
+
+func (x *ExtendAuthSessionRequest) GetMeta() *PeerSystemMeta {
+ if x != nil {
+ return x.Meta
+ }
+ return nil
+}
+
+// ExtendAuthSessionResponse contains the refreshed session deadline.
+type ExtendAuthSessionResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ // 3-state session deadline; same encoding as SyncResponse.sessionExpiresAt.
+ // In practice ExtendAuthSession only succeeds for SSO peers with expiry
+ // enabled, so this carries a valid timestamp on the success path. The
+ // 3-state encoding is documented here for symmetry with Login/Sync.
+ SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"`
+}
+
+func (x *ExtendAuthSessionResponse) Reset() {
+ *x = ExtendAuthSessionResponse{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_management_proto_msgTypes[16]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *ExtendAuthSessionResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ExtendAuthSessionResponse) ProtoMessage() {}
+
+func (x *ExtendAuthSessionResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_management_proto_msgTypes[16]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ExtendAuthSessionResponse.ProtoReflect.Descriptor instead.
+func (*ExtendAuthSessionResponse) Descriptor() ([]byte, []int) {
+ return file_management_proto_rawDescGZIP(), []int{16}
+}
+
+func (x *ExtendAuthSessionResponse) GetSessionExpiresAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.SessionExpiresAt
+ }
+ return nil
+}
+
type ServerKeyResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -1675,7 +1816,7 @@ type ServerKeyResponse struct {
func (x *ServerKeyResponse) Reset() {
*x = ServerKeyResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[15]
+ mi := &file_management_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1688,7 +1829,7 @@ func (x *ServerKeyResponse) String() string {
func (*ServerKeyResponse) ProtoMessage() {}
func (x *ServerKeyResponse) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[15]
+ mi := &file_management_proto_msgTypes[17]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1701,7 +1842,7 @@ func (x *ServerKeyResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ServerKeyResponse.ProtoReflect.Descriptor instead.
func (*ServerKeyResponse) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{15}
+ return file_management_proto_rawDescGZIP(), []int{17}
}
func (x *ServerKeyResponse) GetKey() string {
@@ -1734,7 +1875,7 @@ type Empty struct {
func (x *Empty) Reset() {
*x = Empty{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[16]
+ mi := &file_management_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1747,7 +1888,7 @@ func (x *Empty) String() string {
func (*Empty) ProtoMessage() {}
func (x *Empty) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[16]
+ mi := &file_management_proto_msgTypes[18]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1760,7 +1901,7 @@ func (x *Empty) ProtoReflect() protoreflect.Message {
// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
func (*Empty) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{16}
+ return file_management_proto_rawDescGZIP(), []int{18}
}
// NetbirdConfig is a common configuration of any Netbird peer. It contains STUN, TURN, Signal and Management servers configurations
@@ -1782,7 +1923,7 @@ type NetbirdConfig struct {
func (x *NetbirdConfig) Reset() {
*x = NetbirdConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[17]
+ mi := &file_management_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1795,7 +1936,7 @@ func (x *NetbirdConfig) String() string {
func (*NetbirdConfig) ProtoMessage() {}
func (x *NetbirdConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[17]
+ mi := &file_management_proto_msgTypes[19]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1808,7 +1949,7 @@ func (x *NetbirdConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use NetbirdConfig.ProtoReflect.Descriptor instead.
func (*NetbirdConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{17}
+ return file_management_proto_rawDescGZIP(), []int{19}
}
func (x *NetbirdConfig) GetStuns() []*HostConfig {
@@ -1860,7 +2001,7 @@ type HostConfig struct {
func (x *HostConfig) Reset() {
*x = HostConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[18]
+ mi := &file_management_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1873,7 +2014,7 @@ func (x *HostConfig) String() string {
func (*HostConfig) ProtoMessage() {}
func (x *HostConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[18]
+ mi := &file_management_proto_msgTypes[20]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1886,7 +2027,7 @@ func (x *HostConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use HostConfig.ProtoReflect.Descriptor instead.
func (*HostConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{18}
+ return file_management_proto_rawDescGZIP(), []int{20}
}
func (x *HostConfig) GetUri() string {
@@ -1916,7 +2057,7 @@ type RelayConfig struct {
func (x *RelayConfig) Reset() {
*x = RelayConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[19]
+ mi := &file_management_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1929,7 +2070,7 @@ func (x *RelayConfig) String() string {
func (*RelayConfig) ProtoMessage() {}
func (x *RelayConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[19]
+ mi := &file_management_proto_msgTypes[21]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1942,7 +2083,7 @@ func (x *RelayConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use RelayConfig.ProtoReflect.Descriptor instead.
func (*RelayConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{19}
+ return file_management_proto_rawDescGZIP(), []int{21}
}
func (x *RelayConfig) GetUrls() []string {
@@ -1987,7 +2128,7 @@ type FlowConfig struct {
func (x *FlowConfig) Reset() {
*x = FlowConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[20]
+ mi := &file_management_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2000,7 +2141,7 @@ func (x *FlowConfig) String() string {
func (*FlowConfig) ProtoMessage() {}
func (x *FlowConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[20]
+ mi := &file_management_proto_msgTypes[22]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2013,7 +2154,7 @@ func (x *FlowConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use FlowConfig.ProtoReflect.Descriptor instead.
func (*FlowConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{20}
+ return file_management_proto_rawDescGZIP(), []int{22}
}
func (x *FlowConfig) GetUrl() string {
@@ -2091,7 +2232,7 @@ type JWTConfig struct {
func (x *JWTConfig) Reset() {
*x = JWTConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[21]
+ mi := &file_management_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2104,7 +2245,7 @@ func (x *JWTConfig) String() string {
func (*JWTConfig) ProtoMessage() {}
func (x *JWTConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[21]
+ mi := &file_management_proto_msgTypes[23]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2117,7 +2258,7 @@ func (x *JWTConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use JWTConfig.ProtoReflect.Descriptor instead.
func (*JWTConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{21}
+ return file_management_proto_rawDescGZIP(), []int{23}
}
func (x *JWTConfig) GetIssuer() string {
@@ -2170,7 +2311,7 @@ type ProtectedHostConfig struct {
func (x *ProtectedHostConfig) Reset() {
*x = ProtectedHostConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[22]
+ mi := &file_management_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2183,7 +2324,7 @@ func (x *ProtectedHostConfig) String() string {
func (*ProtectedHostConfig) ProtoMessage() {}
func (x *ProtectedHostConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[22]
+ mi := &file_management_proto_msgTypes[24]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2196,7 +2337,7 @@ func (x *ProtectedHostConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use ProtectedHostConfig.ProtoReflect.Descriptor instead.
func (*ProtectedHostConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{22}
+ return file_management_proto_rawDescGZIP(), []int{24}
}
func (x *ProtectedHostConfig) GetHostConfig() *HostConfig {
@@ -2247,7 +2388,7 @@ type PeerConfig struct {
func (x *PeerConfig) Reset() {
*x = PeerConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[23]
+ mi := &file_management_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2260,7 +2401,7 @@ func (x *PeerConfig) String() string {
func (*PeerConfig) ProtoMessage() {}
func (x *PeerConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[23]
+ mi := &file_management_proto_msgTypes[25]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2273,7 +2414,7 @@ func (x *PeerConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use PeerConfig.ProtoReflect.Descriptor instead.
func (*PeerConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{23}
+ return file_management_proto_rawDescGZIP(), []int{25}
}
func (x *PeerConfig) GetAddress() string {
@@ -2353,7 +2494,7 @@ type AutoUpdateSettings struct {
func (x *AutoUpdateSettings) Reset() {
*x = AutoUpdateSettings{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[24]
+ mi := &file_management_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2366,7 +2507,7 @@ func (x *AutoUpdateSettings) String() string {
func (*AutoUpdateSettings) ProtoMessage() {}
func (x *AutoUpdateSettings) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[24]
+ mi := &file_management_proto_msgTypes[26]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2379,7 +2520,7 @@ func (x *AutoUpdateSettings) ProtoReflect() protoreflect.Message {
// Deprecated: Use AutoUpdateSettings.ProtoReflect.Descriptor instead.
func (*AutoUpdateSettings) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{24}
+ return file_management_proto_rawDescGZIP(), []int{26}
}
func (x *AutoUpdateSettings) GetVersion() string {
@@ -2436,7 +2577,7 @@ type NetworkMap struct {
func (x *NetworkMap) Reset() {
*x = NetworkMap{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[25]
+ mi := &file_management_proto_msgTypes[27]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2449,7 +2590,7 @@ func (x *NetworkMap) String() string {
func (*NetworkMap) ProtoMessage() {}
func (x *NetworkMap) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[25]
+ mi := &file_management_proto_msgTypes[27]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2462,7 +2603,7 @@ func (x *NetworkMap) ProtoReflect() protoreflect.Message {
// Deprecated: Use NetworkMap.ProtoReflect.Descriptor instead.
func (*NetworkMap) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{25}
+ return file_management_proto_rawDescGZIP(), []int{27}
}
func (x *NetworkMap) GetSerial() uint64 {
@@ -2579,7 +2720,7 @@ type SSHAuth struct {
func (x *SSHAuth) Reset() {
*x = SSHAuth{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[26]
+ mi := &file_management_proto_msgTypes[28]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2592,7 +2733,7 @@ func (x *SSHAuth) String() string {
func (*SSHAuth) ProtoMessage() {}
func (x *SSHAuth) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[26]
+ mi := &file_management_proto_msgTypes[28]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2605,7 +2746,7 @@ func (x *SSHAuth) ProtoReflect() protoreflect.Message {
// Deprecated: Use SSHAuth.ProtoReflect.Descriptor instead.
func (*SSHAuth) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{26}
+ return file_management_proto_rawDescGZIP(), []int{28}
}
func (x *SSHAuth) GetUserIDClaim() string {
@@ -2640,7 +2781,7 @@ type MachineUserIndexes struct {
func (x *MachineUserIndexes) Reset() {
*x = MachineUserIndexes{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[27]
+ mi := &file_management_proto_msgTypes[29]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2653,7 +2794,7 @@ func (x *MachineUserIndexes) String() string {
func (*MachineUserIndexes) ProtoMessage() {}
func (x *MachineUserIndexes) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[27]
+ mi := &file_management_proto_msgTypes[29]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2666,7 +2807,7 @@ func (x *MachineUserIndexes) ProtoReflect() protoreflect.Message {
// Deprecated: Use MachineUserIndexes.ProtoReflect.Descriptor instead.
func (*MachineUserIndexes) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{27}
+ return file_management_proto_rawDescGZIP(), []int{29}
}
func (x *MachineUserIndexes) GetIndexes() []uint32 {
@@ -2698,7 +2839,7 @@ type VNCAuth struct {
func (x *VNCAuth) Reset() {
*x = VNCAuth{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[28]
+ mi := &file_management_proto_msgTypes[30]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2711,7 +2852,7 @@ func (x *VNCAuth) String() string {
func (*VNCAuth) ProtoMessage() {}
func (x *VNCAuth) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[28]
+ mi := &file_management_proto_msgTypes[30]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2724,7 +2865,7 @@ func (x *VNCAuth) ProtoReflect() protoreflect.Message {
// Deprecated: Use VNCAuth.ProtoReflect.Descriptor instead.
func (*VNCAuth) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{28}
+ return file_management_proto_rawDescGZIP(), []int{30}
}
func (x *VNCAuth) GetAuthorizedUsers() [][]byte {
@@ -2772,7 +2913,7 @@ type SessionPubKey struct {
func (x *SessionPubKey) Reset() {
*x = SessionPubKey{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[29]
+ mi := &file_management_proto_msgTypes[31]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2785,7 +2926,7 @@ func (x *SessionPubKey) String() string {
func (*SessionPubKey) ProtoMessage() {}
func (x *SessionPubKey) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[29]
+ mi := &file_management_proto_msgTypes[31]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2798,7 +2939,7 @@ func (x *SessionPubKey) ProtoReflect() protoreflect.Message {
// Deprecated: Use SessionPubKey.ProtoReflect.Descriptor instead.
func (*SessionPubKey) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{29}
+ return file_management_proto_rawDescGZIP(), []int{31}
}
func (x *SessionPubKey) GetPubKey() []byte {
@@ -2843,7 +2984,7 @@ type RemotePeerConfig struct {
func (x *RemotePeerConfig) Reset() {
*x = RemotePeerConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[30]
+ mi := &file_management_proto_msgTypes[32]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2856,7 +2997,7 @@ func (x *RemotePeerConfig) String() string {
func (*RemotePeerConfig) ProtoMessage() {}
func (x *RemotePeerConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[30]
+ mi := &file_management_proto_msgTypes[32]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2869,7 +3010,7 @@ func (x *RemotePeerConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemotePeerConfig.ProtoReflect.Descriptor instead.
func (*RemotePeerConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{30}
+ return file_management_proto_rawDescGZIP(), []int{32}
}
func (x *RemotePeerConfig) GetWgPubKey() string {
@@ -2924,7 +3065,7 @@ type SSHConfig struct {
func (x *SSHConfig) Reset() {
*x = SSHConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[31]
+ mi := &file_management_proto_msgTypes[33]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2937,7 +3078,7 @@ func (x *SSHConfig) String() string {
func (*SSHConfig) ProtoMessage() {}
func (x *SSHConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[31]
+ mi := &file_management_proto_msgTypes[33]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2950,7 +3091,7 @@ func (x *SSHConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use SSHConfig.ProtoReflect.Descriptor instead.
func (*SSHConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{31}
+ return file_management_proto_rawDescGZIP(), []int{33}
}
func (x *SSHConfig) GetSshEnabled() bool {
@@ -2984,7 +3125,7 @@ type DeviceAuthorizationFlowRequest struct {
func (x *DeviceAuthorizationFlowRequest) Reset() {
*x = DeviceAuthorizationFlowRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[32]
+ mi := &file_management_proto_msgTypes[34]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2997,7 +3138,7 @@ func (x *DeviceAuthorizationFlowRequest) String() string {
func (*DeviceAuthorizationFlowRequest) ProtoMessage() {}
func (x *DeviceAuthorizationFlowRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[32]
+ mi := &file_management_proto_msgTypes[34]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3010,7 +3151,7 @@ func (x *DeviceAuthorizationFlowRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeviceAuthorizationFlowRequest.ProtoReflect.Descriptor instead.
func (*DeviceAuthorizationFlowRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{32}
+ return file_management_proto_rawDescGZIP(), []int{34}
}
// DeviceAuthorizationFlow represents Device Authorization Flow information
@@ -3029,7 +3170,7 @@ type DeviceAuthorizationFlow struct {
func (x *DeviceAuthorizationFlow) Reset() {
*x = DeviceAuthorizationFlow{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[33]
+ mi := &file_management_proto_msgTypes[35]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3042,7 +3183,7 @@ func (x *DeviceAuthorizationFlow) String() string {
func (*DeviceAuthorizationFlow) ProtoMessage() {}
func (x *DeviceAuthorizationFlow) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[33]
+ mi := &file_management_proto_msgTypes[35]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3055,7 +3196,7 @@ func (x *DeviceAuthorizationFlow) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeviceAuthorizationFlow.ProtoReflect.Descriptor instead.
func (*DeviceAuthorizationFlow) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{33}
+ return file_management_proto_rawDescGZIP(), []int{35}
}
func (x *DeviceAuthorizationFlow) GetProvider() DeviceAuthorizationFlowProvider {
@@ -3082,7 +3223,7 @@ type PKCEAuthorizationFlowRequest struct {
func (x *PKCEAuthorizationFlowRequest) Reset() {
*x = PKCEAuthorizationFlowRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[34]
+ mi := &file_management_proto_msgTypes[36]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3095,7 +3236,7 @@ func (x *PKCEAuthorizationFlowRequest) String() string {
func (*PKCEAuthorizationFlowRequest) ProtoMessage() {}
func (x *PKCEAuthorizationFlowRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[34]
+ mi := &file_management_proto_msgTypes[36]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3108,7 +3249,7 @@ func (x *PKCEAuthorizationFlowRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use PKCEAuthorizationFlowRequest.ProtoReflect.Descriptor instead.
func (*PKCEAuthorizationFlowRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{34}
+ return file_management_proto_rawDescGZIP(), []int{36}
}
// PKCEAuthorizationFlow represents Authorization Code Flow information
@@ -3125,7 +3266,7 @@ type PKCEAuthorizationFlow struct {
func (x *PKCEAuthorizationFlow) Reset() {
*x = PKCEAuthorizationFlow{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[35]
+ mi := &file_management_proto_msgTypes[37]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3138,7 +3279,7 @@ func (x *PKCEAuthorizationFlow) String() string {
func (*PKCEAuthorizationFlow) ProtoMessage() {}
func (x *PKCEAuthorizationFlow) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[35]
+ mi := &file_management_proto_msgTypes[37]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3151,7 +3292,7 @@ func (x *PKCEAuthorizationFlow) ProtoReflect() protoreflect.Message {
// Deprecated: Use PKCEAuthorizationFlow.ProtoReflect.Descriptor instead.
func (*PKCEAuthorizationFlow) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{35}
+ return file_management_proto_rawDescGZIP(), []int{37}
}
func (x *PKCEAuthorizationFlow) GetProviderConfig() *ProviderConfig {
@@ -3199,7 +3340,7 @@ type ProviderConfig struct {
func (x *ProviderConfig) Reset() {
*x = ProviderConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[36]
+ mi := &file_management_proto_msgTypes[38]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3212,7 +3353,7 @@ func (x *ProviderConfig) String() string {
func (*ProviderConfig) ProtoMessage() {}
func (x *ProviderConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[36]
+ mi := &file_management_proto_msgTypes[38]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3225,7 +3366,7 @@ func (x *ProviderConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use ProviderConfig.ProtoReflect.Descriptor instead.
func (*ProviderConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{36}
+ return file_management_proto_rawDescGZIP(), []int{38}
}
func (x *ProviderConfig) GetClientID() string {
@@ -3334,7 +3475,7 @@ type Route struct {
func (x *Route) Reset() {
*x = Route{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[37]
+ mi := &file_management_proto_msgTypes[39]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3347,7 +3488,7 @@ func (x *Route) String() string {
func (*Route) ProtoMessage() {}
func (x *Route) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[37]
+ mi := &file_management_proto_msgTypes[39]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3360,7 +3501,7 @@ func (x *Route) ProtoReflect() protoreflect.Message {
// Deprecated: Use Route.ProtoReflect.Descriptor instead.
func (*Route) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{37}
+ return file_management_proto_rawDescGZIP(), []int{39}
}
func (x *Route) GetID() string {
@@ -3449,7 +3590,7 @@ type DNSConfig struct {
func (x *DNSConfig) Reset() {
*x = DNSConfig{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[38]
+ mi := &file_management_proto_msgTypes[40]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3462,7 +3603,7 @@ func (x *DNSConfig) String() string {
func (*DNSConfig) ProtoMessage() {}
func (x *DNSConfig) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[38]
+ mi := &file_management_proto_msgTypes[40]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3475,7 +3616,7 @@ func (x *DNSConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use DNSConfig.ProtoReflect.Descriptor instead.
func (*DNSConfig) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{38}
+ return file_management_proto_rawDescGZIP(), []int{40}
}
func (x *DNSConfig) GetServiceEnable() bool {
@@ -3524,7 +3665,7 @@ type CustomZone struct {
func (x *CustomZone) Reset() {
*x = CustomZone{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[39]
+ mi := &file_management_proto_msgTypes[41]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3537,7 +3678,7 @@ func (x *CustomZone) String() string {
func (*CustomZone) ProtoMessage() {}
func (x *CustomZone) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[39]
+ mi := &file_management_proto_msgTypes[41]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3550,7 +3691,7 @@ func (x *CustomZone) ProtoReflect() protoreflect.Message {
// Deprecated: Use CustomZone.ProtoReflect.Descriptor instead.
func (*CustomZone) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{39}
+ return file_management_proto_rawDescGZIP(), []int{41}
}
func (x *CustomZone) GetDomain() string {
@@ -3597,7 +3738,7 @@ type SimpleRecord struct {
func (x *SimpleRecord) Reset() {
*x = SimpleRecord{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[40]
+ mi := &file_management_proto_msgTypes[42]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3610,7 +3751,7 @@ func (x *SimpleRecord) String() string {
func (*SimpleRecord) ProtoMessage() {}
func (x *SimpleRecord) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[40]
+ mi := &file_management_proto_msgTypes[42]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3623,7 +3764,7 @@ func (x *SimpleRecord) ProtoReflect() protoreflect.Message {
// Deprecated: Use SimpleRecord.ProtoReflect.Descriptor instead.
func (*SimpleRecord) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{40}
+ return file_management_proto_rawDescGZIP(), []int{42}
}
func (x *SimpleRecord) GetName() string {
@@ -3676,7 +3817,7 @@ type NameServerGroup struct {
func (x *NameServerGroup) Reset() {
*x = NameServerGroup{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[41]
+ mi := &file_management_proto_msgTypes[43]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3689,7 +3830,7 @@ func (x *NameServerGroup) String() string {
func (*NameServerGroup) ProtoMessage() {}
func (x *NameServerGroup) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[41]
+ mi := &file_management_proto_msgTypes[43]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3702,7 +3843,7 @@ func (x *NameServerGroup) ProtoReflect() protoreflect.Message {
// Deprecated: Use NameServerGroup.ProtoReflect.Descriptor instead.
func (*NameServerGroup) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{41}
+ return file_management_proto_rawDescGZIP(), []int{43}
}
func (x *NameServerGroup) GetNameServers() []*NameServer {
@@ -3747,7 +3888,7 @@ type NameServer struct {
func (x *NameServer) Reset() {
*x = NameServer{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[42]
+ mi := &file_management_proto_msgTypes[44]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3760,7 +3901,7 @@ func (x *NameServer) String() string {
func (*NameServer) ProtoMessage() {}
func (x *NameServer) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[42]
+ mi := &file_management_proto_msgTypes[44]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3773,7 +3914,7 @@ func (x *NameServer) ProtoReflect() protoreflect.Message {
// Deprecated: Use NameServer.ProtoReflect.Descriptor instead.
func (*NameServer) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{42}
+ return file_management_proto_rawDescGZIP(), []int{44}
}
func (x *NameServer) GetIP() string {
@@ -3824,7 +3965,7 @@ type FirewallRule struct {
func (x *FirewallRule) Reset() {
*x = FirewallRule{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[43]
+ mi := &file_management_proto_msgTypes[45]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3837,7 +3978,7 @@ func (x *FirewallRule) String() string {
func (*FirewallRule) ProtoMessage() {}
func (x *FirewallRule) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[43]
+ mi := &file_management_proto_msgTypes[45]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3850,7 +3991,7 @@ func (x *FirewallRule) ProtoReflect() protoreflect.Message {
// Deprecated: Use FirewallRule.ProtoReflect.Descriptor instead.
func (*FirewallRule) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{43}
+ return file_management_proto_rawDescGZIP(), []int{45}
}
// Deprecated: Do not use.
@@ -3929,7 +4070,7 @@ type NetworkAddress struct {
func (x *NetworkAddress) Reset() {
*x = NetworkAddress{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[44]
+ mi := &file_management_proto_msgTypes[46]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3942,7 +4083,7 @@ func (x *NetworkAddress) String() string {
func (*NetworkAddress) ProtoMessage() {}
func (x *NetworkAddress) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[44]
+ mi := &file_management_proto_msgTypes[46]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3955,7 +4096,7 @@ func (x *NetworkAddress) ProtoReflect() protoreflect.Message {
// Deprecated: Use NetworkAddress.ProtoReflect.Descriptor instead.
func (*NetworkAddress) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{44}
+ return file_management_proto_rawDescGZIP(), []int{46}
}
func (x *NetworkAddress) GetNetIP() string {
@@ -3983,7 +4124,7 @@ type Checks struct {
func (x *Checks) Reset() {
*x = Checks{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[45]
+ mi := &file_management_proto_msgTypes[47]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3996,7 +4137,7 @@ func (x *Checks) String() string {
func (*Checks) ProtoMessage() {}
func (x *Checks) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[45]
+ mi := &file_management_proto_msgTypes[47]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4009,7 +4150,7 @@ func (x *Checks) ProtoReflect() protoreflect.Message {
// Deprecated: Use Checks.ProtoReflect.Descriptor instead.
func (*Checks) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{45}
+ return file_management_proto_rawDescGZIP(), []int{47}
}
func (x *Checks) GetFiles() []string {
@@ -4034,7 +4175,7 @@ type PortInfo struct {
func (x *PortInfo) Reset() {
*x = PortInfo{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[46]
+ mi := &file_management_proto_msgTypes[48]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4047,7 +4188,7 @@ func (x *PortInfo) String() string {
func (*PortInfo) ProtoMessage() {}
func (x *PortInfo) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[46]
+ mi := &file_management_proto_msgTypes[48]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4060,7 +4201,7 @@ func (x *PortInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use PortInfo.ProtoReflect.Descriptor instead.
func (*PortInfo) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{46}
+ return file_management_proto_rawDescGZIP(), []int{48}
}
func (m *PortInfo) GetPortSelection() isPortInfo_PortSelection {
@@ -4131,7 +4272,7 @@ type RouteFirewallRule struct {
func (x *RouteFirewallRule) Reset() {
*x = RouteFirewallRule{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[47]
+ mi := &file_management_proto_msgTypes[49]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4144,7 +4285,7 @@ func (x *RouteFirewallRule) String() string {
func (*RouteFirewallRule) ProtoMessage() {}
func (x *RouteFirewallRule) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[47]
+ mi := &file_management_proto_msgTypes[49]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4157,7 +4298,7 @@ func (x *RouteFirewallRule) ProtoReflect() protoreflect.Message {
// Deprecated: Use RouteFirewallRule.ProtoReflect.Descriptor instead.
func (*RouteFirewallRule) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{47}
+ return file_management_proto_rawDescGZIP(), []int{49}
}
func (x *RouteFirewallRule) GetSourceRanges() []string {
@@ -4248,7 +4389,7 @@ type ForwardingRule struct {
func (x *ForwardingRule) Reset() {
*x = ForwardingRule{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[48]
+ mi := &file_management_proto_msgTypes[50]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4261,7 +4402,7 @@ func (x *ForwardingRule) String() string {
func (*ForwardingRule) ProtoMessage() {}
func (x *ForwardingRule) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[48]
+ mi := &file_management_proto_msgTypes[50]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4274,7 +4415,7 @@ func (x *ForwardingRule) ProtoReflect() protoreflect.Message {
// Deprecated: Use ForwardingRule.ProtoReflect.Descriptor instead.
func (*ForwardingRule) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{48}
+ return file_management_proto_rawDescGZIP(), []int{50}
}
func (x *ForwardingRule) GetProtocol() RuleProtocol {
@@ -4323,7 +4464,7 @@ type ExposeServiceRequest struct {
func (x *ExposeServiceRequest) Reset() {
*x = ExposeServiceRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[49]
+ mi := &file_management_proto_msgTypes[51]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4336,7 +4477,7 @@ func (x *ExposeServiceRequest) String() string {
func (*ExposeServiceRequest) ProtoMessage() {}
func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[49]
+ mi := &file_management_proto_msgTypes[51]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4349,7 +4490,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead.
func (*ExposeServiceRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{49}
+ return file_management_proto_rawDescGZIP(), []int{51}
}
func (x *ExposeServiceRequest) GetPort() uint32 {
@@ -4422,7 +4563,7 @@ type ExposeServiceResponse struct {
func (x *ExposeServiceResponse) Reset() {
*x = ExposeServiceResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[50]
+ mi := &file_management_proto_msgTypes[52]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4435,7 +4576,7 @@ func (x *ExposeServiceResponse) String() string {
func (*ExposeServiceResponse) ProtoMessage() {}
func (x *ExposeServiceResponse) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[50]
+ mi := &file_management_proto_msgTypes[52]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4448,7 +4589,7 @@ func (x *ExposeServiceResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceResponse.ProtoReflect.Descriptor instead.
func (*ExposeServiceResponse) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{50}
+ return file_management_proto_rawDescGZIP(), []int{52}
}
func (x *ExposeServiceResponse) GetServiceName() string {
@@ -4490,7 +4631,7 @@ type RenewExposeRequest struct {
func (x *RenewExposeRequest) Reset() {
*x = RenewExposeRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[51]
+ mi := &file_management_proto_msgTypes[53]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4503,7 +4644,7 @@ func (x *RenewExposeRequest) String() string {
func (*RenewExposeRequest) ProtoMessage() {}
func (x *RenewExposeRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[51]
+ mi := &file_management_proto_msgTypes[53]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4516,7 +4657,7 @@ func (x *RenewExposeRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenewExposeRequest.ProtoReflect.Descriptor instead.
func (*RenewExposeRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{51}
+ return file_management_proto_rawDescGZIP(), []int{53}
}
func (x *RenewExposeRequest) GetDomain() string {
@@ -4535,7 +4676,7 @@ type RenewExposeResponse struct {
func (x *RenewExposeResponse) Reset() {
*x = RenewExposeResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[52]
+ mi := &file_management_proto_msgTypes[54]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4548,7 +4689,7 @@ func (x *RenewExposeResponse) String() string {
func (*RenewExposeResponse) ProtoMessage() {}
func (x *RenewExposeResponse) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[52]
+ mi := &file_management_proto_msgTypes[54]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4561,7 +4702,7 @@ func (x *RenewExposeResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RenewExposeResponse.ProtoReflect.Descriptor instead.
func (*RenewExposeResponse) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{52}
+ return file_management_proto_rawDescGZIP(), []int{54}
}
type StopExposeRequest struct {
@@ -4575,7 +4716,7 @@ type StopExposeRequest struct {
func (x *StopExposeRequest) Reset() {
*x = StopExposeRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[53]
+ mi := &file_management_proto_msgTypes[55]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4588,7 +4729,7 @@ func (x *StopExposeRequest) String() string {
func (*StopExposeRequest) ProtoMessage() {}
func (x *StopExposeRequest) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[53]
+ mi := &file_management_proto_msgTypes[55]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4601,7 +4742,7 @@ func (x *StopExposeRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopExposeRequest.ProtoReflect.Descriptor instead.
func (*StopExposeRequest) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{53}
+ return file_management_proto_rawDescGZIP(), []int{55}
}
func (x *StopExposeRequest) GetDomain() string {
@@ -4620,7 +4761,7 @@ type StopExposeResponse struct {
func (x *StopExposeResponse) Reset() {
*x = StopExposeResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[54]
+ mi := &file_management_proto_msgTypes[56]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4633,7 +4774,7 @@ func (x *StopExposeResponse) String() string {
func (*StopExposeResponse) ProtoMessage() {}
func (x *StopExposeResponse) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[54]
+ mi := &file_management_proto_msgTypes[56]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4646,7 +4787,7 @@ func (x *StopExposeResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopExposeResponse.ProtoReflect.Descriptor instead.
func (*StopExposeResponse) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{54}
+ return file_management_proto_rawDescGZIP(), []int{56}
}
type PortInfo_Range struct {
@@ -4661,7 +4802,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
if protoimpl.UnsafeEnabled {
- mi := &file_management_proto_msgTypes[57]
+ mi := &file_management_proto_msgTypes[59]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4674,7 +4815,7 @@ func (x *PortInfo_Range) String() string {
func (*PortInfo_Range) ProtoMessage() {}
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
- mi := &file_management_proto_msgTypes[57]
+ mi := &file_management_proto_msgTypes[59]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4687,7 +4828,7 @@ func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
// Deprecated: Use PortInfo_Range.ProtoReflect.Descriptor instead.
func (*PortInfo_Range) Descriptor() ([]byte, []int) {
- return file_management_proto_rawDescGZIP(), []int{46, 0}
+ return file_management_proto_rawDescGZIP(), []int{48, 0}
}
func (x *PortInfo_Range) GetStart() uint32 {
@@ -4753,7 +4894,7 @@ var file_management_proto_rawDesc = []byte{
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18,
0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74,
- 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0xdb, 0x02, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63,
+ 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0xa3, 0x03, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62,
0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74,
@@ -4775,662 +4916,689 @@ var file_management_proto_rawDesc = []byte{
0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x12, 0x2a, 0x0a, 0x06, 0x43, 0x68, 0x65,
0x63, 0x6b, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, 0x43,
- 0x68, 0x65, 0x63, 0x6b, 0x73, 0x22, 0x41, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74,
- 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61,
- 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
- 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65,
- 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0xc6, 0x01, 0x0a, 0x0c, 0x4c, 0x6f, 0x67,
- 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x74,
- 0x75, 0x70, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x74,
- 0x75, 0x70, 0x4b, 0x65, 0x79, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x02, 0x20,
- 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
- 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52,
- 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65,
- 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65,
- 0x6e, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x18, 0x04, 0x20,
- 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
- 0x2e, 0x50, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x52, 0x08, 0x70, 0x65, 0x65, 0x72, 0x4b,
- 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73,
- 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c,
- 0x73, 0x22, 0x44, 0x0a, 0x08, 0x50, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a,
- 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c,
- 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x77,
- 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x77,
- 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x22, 0x3f, 0x0a, 0x0b, 0x45, 0x6e, 0x76, 0x69, 0x72,
- 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x18,
- 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x12, 0x1a, 0x0a, 0x08,
- 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
- 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x22, 0x5c, 0x0a, 0x04, 0x46, 0x69, 0x6c, 0x65,
- 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
- 0x70, 0x61, 0x74, 0x68, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x78, 0x69, 0x73, 0x74, 0x18, 0x02, 0x20,
- 0x01, 0x28, 0x08, 0x52, 0x05, 0x65, 0x78, 0x69, 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x10, 0x70, 0x72,
- 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x03,
- 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52,
- 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0x8d, 0x06, 0x0a, 0x05, 0x46, 0x6c, 0x61, 0x67, 0x73,
- 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61,
- 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x72, 0x6f, 0x73, 0x65,
- 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13,
- 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73,
- 0x69, 0x76, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e,
- 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x12, 0x2a,
- 0x0a, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77,
- 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72,
- 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, 0x64, 0x69,
- 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65,
- 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65,
- 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x13,
- 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x6f, 0x75,
- 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62,
- 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x1e,
- 0x0a, 0x0a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, 0x18, 0x06, 0x20, 0x01,
- 0x28, 0x08, 0x52, 0x0a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, 0x12, 0x28,
- 0x0a, 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c,
- 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65,
- 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x62, 0x6c, 0x6f, 0x63,
- 0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08,
- 0x52, 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73,
- 0x12, 0x22, 0x0a, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64,
- 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x49, 0x6e, 0x62,
- 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e,
- 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20,
- 0x01, 0x28, 0x08, 0x52, 0x15, 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74,
- 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e,
- 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, 0x6f, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28,
- 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, 0x6f, 0x74,
- 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54,
- 0x50, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53,
- 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x12, 0x42, 0x0a, 0x1c, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65,
- 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77,
- 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1c, 0x65, 0x6e,
- 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74,
- 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x44, 0x0a, 0x1d, 0x65, 0x6e,
- 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72,
- 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0e, 0x20, 0x01, 0x28,
- 0x08, 0x52, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, 0x6d, 0x6f,
- 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67,
- 0x12, 0x26, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75,
- 0x74, 0x68, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c,
- 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x69, 0x73, 0x61,
- 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x64,
- 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x12, 0x2a, 0x0a, 0x10, 0x73, 0x65,
- 0x72, 0x76, 0x65, 0x72, 0x56, 0x4e, 0x43, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x12,
- 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x56, 0x4e, 0x43, 0x41,
- 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x22, 0xb2, 0x05, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x53,
- 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73,
- 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73,
- 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x18, 0x02, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x12, 0x16, 0x0a, 0x06, 0x6b, 0x65, 0x72,
- 0x6e, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65,
- 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,
- 0x04, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72,
- 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72,
- 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x4f,
- 0x53, 0x12, 0x26, 0x0a, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73,
- 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69,
- 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x75, 0x69, 0x56,
- 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x69,
- 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65,
- 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d,
- 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a,
- 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x46, 0x0a, 0x10, 0x6e,
- 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18,
- 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
- 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73,
- 0x73, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73,
- 0x73, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c,
- 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79,
- 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a,
- 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18,
- 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63,
- 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75,
- 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f,
- 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x12,
- 0x39, 0x0a, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x0f,
- 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x0b, 0x65,
- 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x05, 0x66, 0x69,
- 0x6c, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x05, 0x66, 0x69, 0x6c,
- 0x65, 0x73, 0x12, 0x27, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, 0x11, 0x20, 0x01, 0x28,
- 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46,
- 0x6c, 0x61, 0x67, 0x73, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x3e, 0x0a, 0x0c, 0x63,
- 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x12, 0x20, 0x03, 0x28,
- 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50,
- 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x52, 0x0c, 0x63,
- 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0xb4, 0x01, 0x0a, 0x0d,
- 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a,
- 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01,
- 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52,
- 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36,
- 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01,
- 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72,
- 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2a, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73,
- 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
- 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x52, 0x06, 0x43, 0x68, 0x65, 0x63,
- 0x6b, 0x73, 0x22, 0x79, 0x0a, 0x11, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52,
- 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
- 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x65, 0x78, 0x70,
- 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67,
- 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54,
- 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65,
- 0x73, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03,
- 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x07, 0x0a,
- 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x62, 0x69,
- 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05, 0x73, 0x74, 0x75, 0x6e,
- 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
- 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52,
- 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x18,
- 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
- 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74,
- 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x12, 0x2e, 0x0a,
- 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43,
- 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12, 0x2d, 0x0a,
- 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43,
- 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x12, 0x2a, 0x0a, 0x04,
- 0x66, 0x6c, 0x6f, 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e,
- 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66,
- 0x69, 0x67, 0x52, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x22, 0x98, 0x01, 0x0a, 0x0a, 0x48, 0x6f, 0x73,
- 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x01,
- 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x3b, 0x0a, 0x08, 0x70, 0x72, 0x6f,
- 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e,
- 0x66, 0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72,
- 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63,
- 0x6f, 0x6c, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54,
- 0x43, 0x50, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x02, 0x12, 0x09,
- 0x0a, 0x05, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x54, 0x4c,
- 0x53, 0x10, 0x04, 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66,
- 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09,
- 0x52, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50,
- 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f,
- 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f,
- 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75,
- 0x72, 0x65, 0x22, 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69,
- 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
- 0x75, 0x72, 0x6c, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c,
+ 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x46, 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
+ 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32,
+ 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
+ 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x73, 0x65, 0x73,
+ 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x22, 0x41, 0x0a,
+ 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+ 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a,
+ 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72,
+ 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61,
+ 0x22, 0xc6, 0x01, 0x0a, 0x0c, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+ 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x12, 0x2e, 0x0a,
+ 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73,
+ 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a,
+ 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x65, 0x65,
+ 0x72, 0x4b, 0x65, 0x79, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79,
+ 0x73, 0x52, 0x08, 0x70, 0x65, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x64,
+ 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09,
+ 0x64, 0x6e, 0x73, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x22, 0x44, 0x0a, 0x08, 0x50, 0x65, 0x65,
+ 0x72, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b,
+ 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62,
+ 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18,
+ 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x22,
+ 0x3f, 0x0a, 0x0b, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x14,
+ 0x0a, 0x05, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x63,
+ 0x6c, 0x6f, 0x75, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d,
+ 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d,
+ 0x22, 0x5c, 0x0a, 0x04, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x14, 0x0a, 0x05,
+ 0x65, 0x78, 0x69, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x65, 0x78, 0x69,
+ 0x73, 0x74, 0x12, 0x2a, 0x0a, 0x10, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52,
+ 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x72,
+ 0x6f, 0x63, 0x65, 0x73, 0x73, 0x49, 0x73, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0x8d,
+ 0x06, 0x0a, 0x05, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x72, 0x6f, 0x73, 0x65,
+ 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x08, 0x52, 0x10, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x45, 0x6e, 0x61,
+ 0x62, 0x6c, 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73,
+ 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
+ 0x08, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x65, 0x72, 0x6d,
+ 0x69, 0x73, 0x73, 0x69, 0x76, 0x65, 0x12, 0x2a, 0x0a, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72,
+ 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08,
+ 0x52, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x53, 0x53, 0x48, 0x41, 0x6c, 0x6c, 0x6f, 0x77,
+ 0x65, 0x64, 0x12, 0x30, 0x0a, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6c, 0x69,
+ 0x65, 0x6e, 0x74, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52,
+ 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x6f,
+ 0x75, 0x74, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53,
+ 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28,
+ 0x08, 0x52, 0x13, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
+ 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c,
+ 0x65, 0x44, 0x4e, 0x53, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x64, 0x69, 0x73, 0x61,
+ 0x62, 0x6c, 0x65, 0x44, 0x4e, 0x53, 0x12, 0x28, 0x0a, 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c,
+ 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52,
+ 0x0f, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c,
+ 0x12, 0x26, 0x0a, 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c, 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65,
+ 0x73, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x4c,
+ 0x41, 0x4e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x62, 0x6c, 0x6f, 0x63,
+ 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c,
+ 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x34, 0x0a, 0x15,
+ 0x6c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e,
+ 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x6c, 0x61, 0x7a,
+ 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c,
+ 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52,
+ 0x6f, 0x6f, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c,
+ 0x65, 0x53, 0x53, 0x48, 0x52, 0x6f, 0x6f, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x65, 0x6e, 0x61, 0x62,
+ 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52,
+ 0x0d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x53, 0x46, 0x54, 0x50, 0x12, 0x42,
+ 0x0a, 0x1c, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c, 0x6f, 0x63, 0x61, 0x6c,
+ 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x0d,
+ 0x20, 0x01, 0x28, 0x08, 0x52, 0x1c, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x4c,
+ 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69,
+ 0x6e, 0x67, 0x12, 0x44, 0x0a, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x52,
+ 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64,
+ 0x69, 0x6e, 0x67, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1d, 0x65, 0x6e, 0x61, 0x62, 0x6c,
+ 0x65, 0x53, 0x53, 0x48, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x6f, 0x72, 0x74, 0x46, 0x6f,
+ 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x12, 0x26, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x61,
+ 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08,
+ 0x52, 0x0e, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68,
+ 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50, 0x76, 0x36, 0x18,
+ 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x49, 0x50,
+ 0x76, 0x36, 0x12, 0x2a, 0x0a, 0x10, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x56, 0x4e, 0x43, 0x41,
+ 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x12, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x73, 0x65,
+ 0x72, 0x76, 0x65, 0x72, 0x56, 0x4e, 0x43, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x22, 0xb2,
+ 0x05, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x4d, 0x65, 0x74,
+ 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a,
+ 0x04, 0x67, 0x6f, 0x4f, 0x53, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x67, 0x6f, 0x4f,
+ 0x53, 0x12, 0x16, 0x0a, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x06, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x72,
+ 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1a, 0x0a,
+ 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x4f, 0x53, 0x18,
+ 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x4f, 0x53, 0x12, 0x26, 0x0a, 0x0e, 0x6e, 0x65, 0x74,
+ 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x0e, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
+ 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x69, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12,
+ 0x24, 0x0a, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
+ 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x56, 0x65,
+ 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73, 0x69,
+ 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x4f, 0x53, 0x56, 0x65, 0x72, 0x73,
+ 0x69, 0x6f, 0x6e, 0x12, 0x46, 0x0a, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64,
+ 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
+ 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f,
+ 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f,
+ 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0f, 0x73,
+ 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0c,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x4e,
+ 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64,
+ 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73,
+ 0x79, 0x73, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a,
+ 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66, 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72,
+ 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x79, 0x73, 0x4d, 0x61, 0x6e, 0x75, 0x66,
+ 0x61, 0x63, 0x74, 0x75, 0x72, 0x65, 0x72, 0x12, 0x39, 0x0a, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72,
+ 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d,
+ 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f,
+ 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x0b, 0x65, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65,
+ 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28,
+ 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46,
+ 0x69, 0x6c, 0x65, 0x52, 0x05, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x12, 0x27, 0x0a, 0x05, 0x66, 0x6c,
+ 0x61, 0x67, 0x73, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x52, 0x05, 0x66, 0x6c,
+ 0x61, 0x67, 0x73, 0x12, 0x3e, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74,
+ 0x69, 0x65, 0x73, 0x18, 0x12, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62,
+ 0x69, 0x6c, 0x69, 0x74, 0x79, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74,
+ 0x69, 0x65, 0x73, 0x22, 0xfc, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73,
+ 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64,
+ 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d,
+ 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72,
+ 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0d, 0x6e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64,
+ 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f,
+ 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e,
+ 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
+ 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2a,
+ 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12,
+ 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63,
+ 0x6b, 0x73, 0x52, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x46, 0x0a, 0x10, 0x73, 0x65,
+ 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x04,
+ 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
+ 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70,
+ 0x52, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73,
+ 0x41, 0x74, 0x22, 0x66, 0x0a, 0x18, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68,
+ 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a,
+ 0x0a, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x08, 0x6a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65,
+ 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d,
+ 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0x63, 0x0a, 0x19, 0x45, 0x78,
+ 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52,
+ 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69,
+ 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28,
+ 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+ 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x73,
+ 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x22,
+ 0x79, 0x0a, 0x11, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70,
+ 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x38, 0x0a, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65,
+ 0x73, 0x41, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
+ 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65,
+ 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74,
+ 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28,
+ 0x05, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d,
+ 0x70, 0x74, 0x79, 0x22, 0xff, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x62, 0x69, 0x72, 0x64, 0x43,
+ 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2c, 0x0a, 0x05, 0x73, 0x74, 0x75, 0x6e, 0x73, 0x18, 0x01,
+ 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+ 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x73, 0x74,
+ 0x75, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03,
+ 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+ 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e,
+ 0x66, 0x69, 0x67, 0x52, 0x05, 0x74, 0x75, 0x72, 0x6e, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x73, 0x69,
+ 0x67, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e,
+ 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66,
+ 0x69, 0x67, 0x52, 0x06, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x12, 0x2d, 0x0a, 0x05, 0x72, 0x65,
+ 0x6c, 0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66,
+ 0x69, 0x67, 0x52, 0x05, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x12, 0x2a, 0x0a, 0x04, 0x66, 0x6c, 0x6f,
+ 0x77, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52,
+ 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x22, 0x98, 0x01, 0x0a, 0x0a, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f,
+ 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x3b, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63,
+ 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
+ 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+ 0x63, 0x6f, 0x6c, 0x22, 0x3b, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12,
+ 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10,
+ 0x01, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x54, 0x54, 0x50, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x48,
+ 0x54, 0x54, 0x50, 0x53, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x54, 0x4c, 0x53, 0x10, 0x04,
+ 0x22, 0x6d, 0x0a, 0x0b, 0x52, 0x65, 0x6c, 0x61, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
+ 0x12, 0x0a, 0x04, 0x75, 0x72, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x75,
+ 0x72, 0x6c, 0x73, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c,
0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e,
0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e,
0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
- 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12,
- 0x35, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28,
- 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
- 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x69, 0x6e,
- 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65,
- 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64,
- 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x01,
- 0x28, 0x08, 0x52, 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12,
- 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69,
- 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f,
- 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d,
- 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20,
- 0x01, 0x28, 0x08, 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69,
- 0x6f, 0x6e, 0x22, 0xa3, 0x01, 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
- 0x12, 0x16, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x75, 0x64, 0x69,
- 0x65, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x75, 0x64, 0x69,
- 0x65, 0x6e, 0x63, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61,
- 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6b, 0x65, 0x79, 0x73,
- 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x78, 0x54,
- 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6d,
- 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x75,
- 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x61,
- 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x73, 0x22, 0x7d, 0x0a, 0x13, 0x50, 0x72, 0x6f, 0x74,
- 0x65, 0x63, 0x74, 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
- 0x36, 0x0a, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
- 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x68, 0x6f, 0x73,
- 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18,
- 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x70,
- 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70,
- 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0xf2, 0x02, 0x0a, 0x0a, 0x50, 0x65, 0x65, 0x72,
- 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73,
- 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
- 0x12, 0x10, 0x0a, 0x03, 0x64, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x64,
- 0x6e, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18,
- 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
- 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x73, 0x73,
- 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18,
- 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x48, 0x0a, 0x1f, 0x52,
- 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73,
- 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05,
- 0x20, 0x01, 0x28, 0x08, 0x52, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65,
- 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e,
- 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e,
- 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06,
- 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63,
- 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x6d,
- 0x74, 0x75, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6d, 0x74, 0x75, 0x12, 0x3e, 0x0a,
- 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28,
- 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41,
- 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67,
- 0x73, 0x52, 0x0a, 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a,
- 0x0a, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x76, 0x36, 0x18, 0x09, 0x20, 0x01, 0x28,
- 0x0c, 0x52, 0x09, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x56, 0x36, 0x22, 0x52, 0x0a, 0x12,
- 0x41, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e,
- 0x67, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, 0x0c,
- 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01,
- 0x28, 0x08, 0x52, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
- 0x22, 0x97, 0x06, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x12,
- 0x16, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52,
- 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43,
- 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e,
- 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
- 0x3e, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x03,
- 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
- 0x69, 0x67, 0x52, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12,
- 0x2e, 0x0a, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73,
- 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x72, 0x65, 0x6d,
- 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12,
- 0x29, 0x0a, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32,
- 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75,
- 0x74, 0x65, 0x52, 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x44, 0x4e,
- 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x43, 0x6f,
- 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12,
- 0x40, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18,
- 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
- 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e,
- 0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72,
- 0x73, 0x12, 0x3e, 0x0a, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c,
- 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75,
- 0x6c, 0x65, 0x52, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65,
- 0x73, 0x12, 0x32, 0x0a, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c,
- 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52,
- 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73,
- 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46,
- 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03,
- 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c,
- 0x65, 0x52, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c,
- 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73,
- 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45,
- 0x6d, 0x70, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x72, 0x6f, 0x75, 0x74,
- 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49,
- 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72,
- 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32,
- 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6f, 0x72,
- 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, 0x6f, 0x72,
- 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, 0x07,
- 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75,
- 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x12, 0x2d, 0x0a, 0x07, 0x76,
- 0x6e, 0x63, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x4e, 0x43, 0x41, 0x75, 0x74,
- 0x68, 0x52, 0x07, 0x76, 0x6e, 0x63, 0x41, 0x75, 0x74, 0x68, 0x22, 0x82, 0x02, 0x0a, 0x07, 0x53,
- 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44,
- 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x55, 0x73, 0x65,
- 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, 0x0f, 0x41, 0x75, 0x74, 0x68,
+ 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x22,
+ 0xad, 0x02, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x10,
+ 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c,
+ 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64,
+ 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x50, 0x61, 0x79,
+ 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67,
+ 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f,
+ 0x6b, 0x65, 0x6e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x08,
+ 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19,
+ 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
+ 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72,
+ 0x76, 0x61, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05,
+ 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1a, 0x0a,
+ 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52,
+ 0x08, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x65, 0x78, 0x69,
+ 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18,
+ 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x65, 0x78, 0x69, 0x74, 0x4e, 0x6f, 0x64, 0x65, 0x43,
+ 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0d, 0x64, 0x6e, 0x73,
+ 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08,
+ 0x52, 0x0d, 0x64, 0x6e, 0x73, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22,
+ 0xa3, 0x01, 0x0a, 0x09, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x16, 0x0a,
+ 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x69,
+ 0x73, 0x73, 0x75, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63,
+ 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63,
+ 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f,
+ 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6b, 0x65, 0x79, 0x73, 0x4c, 0x6f, 0x63,
+ 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x78, 0x54, 0x6f, 0x6b, 0x65,
+ 0x6e, 0x41, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6d, 0x61, 0x78, 0x54,
+ 0x6f, 0x6b, 0x65, 0x6e, 0x41, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x75, 0x64, 0x69, 0x65,
+ 0x6e, 0x63, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x61, 0x75, 0x64, 0x69,
+ 0x65, 0x6e, 0x63, 0x65, 0x73, 0x22, 0x7d, 0x0a, 0x13, 0x50, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74,
+ 0x65, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x36, 0x0a, 0x0a,
+ 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
+ 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x6f,
+ 0x73, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x43, 0x6f,
+ 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73,
+ 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73,
+ 0x77, 0x6f, 0x72, 0x64, 0x22, 0xf2, 0x02, 0x0a, 0x0a, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e,
+ 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x10, 0x0a,
+ 0x03, 0x64, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x64, 0x6e, 0x73, 0x12,
+ 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01,
+ 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+ 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f,
+ 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x48, 0x0a, 0x1f, 0x52, 0x6f, 0x75, 0x74,
+ 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75,
+ 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28,
+ 0x08, 0x52, 0x1f, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x65, 0x72, 0x44, 0x6e,
+ 0x73, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c,
+ 0x65, 0x64, 0x12, 0x34, 0x0a, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63,
+ 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28,
+ 0x08, 0x52, 0x15, 0x4c, 0x61, 0x7a, 0x79, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f,
+ 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x74, 0x75, 0x18,
+ 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x6d, 0x74, 0x75, 0x12, 0x3e, 0x0a, 0x0a, 0x61, 0x75,
+ 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e,
+ 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x6f,
+ 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0a,
+ 0x61, 0x75, 0x74, 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64,
+ 0x64, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x76, 0x36, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09,
+ 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x56, 0x36, 0x22, 0x52, 0x0a, 0x12, 0x41, 0x75, 0x74,
+ 0x6f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12,
+ 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x6c, 0x77,
+ 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52,
+ 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x22, 0x97, 0x06,
+ 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x12, 0x16, 0x0a, 0x06,
+ 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x53, 0x65,
+ 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
+ 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
+ 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x3e, 0x0a, 0x0b,
+ 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,
+ 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52,
+ 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52,
+ 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x12,
+ 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70,
+ 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65,
+ 0x50, 0x65, 0x65, 0x72, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x29, 0x0a, 0x06,
+ 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d,
+ 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52,
+ 0x06, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f,
+ 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e,
+ 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+ 0x67, 0x52, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x40, 0x0a, 0x0c,
+ 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x18, 0x07, 0x20, 0x03,
+ 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+ 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
+ 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x3e,
+ 0x0a, 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18,
+ 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+ 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52,
+ 0x0d, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x32,
+ 0x0a, 0x14, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49,
+ 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x66, 0x69,
+ 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70,
+ 0x74, 0x79, 0x12, 0x4f, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65,
+ 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32,
+ 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75,
+ 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x13,
+ 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75,
+ 0x6c, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x1a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72,
+ 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74,
+ 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46,
+ 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d,
+ 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e,
+ 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d,
+ 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72,
+ 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72,
+ 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, 0x07, 0x73, 0x73, 0x68,
+ 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6d, 0x61, 0x6e,
+ 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x52,
+ 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x12, 0x2d, 0x0a, 0x07, 0x76, 0x6e, 0x63, 0x41,
+ 0x75, 0x74, 0x68, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x4e, 0x43, 0x41, 0x75, 0x74, 0x68, 0x52, 0x07,
+ 0x76, 0x6e, 0x63, 0x41, 0x75, 0x74, 0x68, 0x22, 0x82, 0x02, 0x0a, 0x07, 0x53, 0x53, 0x48, 0x41,
+ 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61,
+ 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44,
+ 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69,
+ 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f,
+ 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12,
+ 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73,
+ 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68,
+ 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d,
+ 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d,
+ 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79,
+ 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b,
+ 0x65, 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
+ 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d,
+ 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65,
+ 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x2e, 0x0a, 0x12,
+ 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78,
+ 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20,
+ 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x22, 0xa5, 0x02, 0x0a,
+ 0x07, 0x56, 0x4e, 0x43, 0x41, 0x75, 0x74, 0x68, 0x12, 0x28, 0x0a, 0x0f, 0x41, 0x75, 0x74, 0x68,
0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28,
0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65,
0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x2e, 0x4d,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x4e, 0x43, 0x41, 0x75, 0x74, 0x68, 0x2e, 0x4d,
0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79,
- 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x1a, 0x5f,
- 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e,
- 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
- 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64,
- 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22,
- 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e,
- 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73,
- 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x22,
- 0xa5, 0x02, 0x0a, 0x07, 0x56, 0x4e, 0x43, 0x41, 0x75, 0x74, 0x68, 0x12, 0x28, 0x0a, 0x0f, 0x41,
- 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18, 0x02,
- 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64,
- 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65,
- 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x4e, 0x43, 0x41, 0x75, 0x74,
- 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e,
- 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72,
- 0x73, 0x12, 0x43, 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x70, 0x75, 0x62,
- 0x5f, 0x6b, 0x65, 0x79, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
- 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x52, 0x0e, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x50,
- 0x75, 0x62, 0x4b, 0x65, 0x79, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e,
- 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b,
- 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a,
- 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e,
- 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61,
- 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x6d, 0x0a, 0x0d, 0x53, 0x65, 0x73, 0x73, 0x69,
- 0x6f, 0x6e, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x75, 0x62, 0x5f,
- 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x75, 0x62, 0x4b, 0x65,
- 0x79, 0x12, 0x20, 0x0a, 0x0c, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x5f, 0x68, 0x61, 0x73,
- 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x48,
- 0x61, 0x73, 0x68, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e,
- 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c,
- 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74,
- 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77,
- 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77,
- 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77,
- 0x65, 0x64, 0x49, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c,
- 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f,
- 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e,
- 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69,
- 0x67, 0x52, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04,
- 0x66, 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e,
- 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
- 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72,
- 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69,
- 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18,
- 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65,
- 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02,
- 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12,
- 0x33, 0x0a, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01,
- 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f,
- 0x6e, 0x66, 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75,
- 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52,
- 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63,
- 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c,
- 0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01,
- 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a,
- 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64,
- 0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e,
- 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02,
- 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
- 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
- 0x22, 0x16, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06,
- 0x48, 0x4f, 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45,
- 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f,
- 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45,
- 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f,
- 0x77, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e,
- 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43,
- 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43,
- 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64,
- 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65,
- 0x6e, 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65,
- 0x6e, 0x74, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65,
- 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0c,
- 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06,
- 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f,
- 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65,
- 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65,
- 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e,
- 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65,
- 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74,
- 0x12, 0x24, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e,
- 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e,
- 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18,
- 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a,
- 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08,
- 0x52, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15,
- 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64,
- 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74,
+ 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x43,
+ 0x0a, 0x10, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65,
+ 0x79, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x50, 0x75, 0x62,
+ 0x4b, 0x65, 0x79, 0x52, 0x0e, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x50, 0x75, 0x62, 0x4b,
+ 0x65, 0x79, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73,
+ 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, 0x61,
+ 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73,
+ 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
+ 0x3a, 0x02, 0x38, 0x01, 0x22, 0x6d, 0x0a, 0x0d, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x50,
+ 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x20,
+ 0x0a, 0x0c, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02,
+ 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x48, 0x61, 0x73, 0x68,
+ 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65,
+ 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e,
+ 0x61, 0x6d, 0x65, 0x22, 0xbb, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65,
+ 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75,
+ 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75,
+ 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49,
+ 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65,
+ 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+ 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09,
+ 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64,
+ 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, 0x0a,
+ 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
+ 0x6e, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e,
+ 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01,
+ 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c,
+ 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28,
+ 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x09,
+ 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32,
+ 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x57, 0x54,
+ 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+ 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f,
+ 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75,
+ 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75,
+ 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12,
+ 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28,
+ 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44,
+ 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69,
+ 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52,
+ 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f,
+ 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28,
+ 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50,
+ 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50,
+ 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, 0x0a,
+ 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, 0x53,
+ 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74,
+ 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65,
+ 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74,
+ 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x42,
+ 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
+ 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66,
+ 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43,
+ 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49,
+ 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49,
+ 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65,
+ 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0c, 0x43, 0x6c, 0x69,
+ 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d,
+ 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69,
+ 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x2e, 0x0a,
+ 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f,
+ 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63,
+ 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x24, 0x0a,
+ 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f,
+ 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x73, 0x65,
+ 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x55,
+ 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15, 0x41, 0x75, 0x74,
0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69,
- 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52,
- 0x4c, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65,
- 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c,
- 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01,
- 0x28, 0x08, 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70,
- 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46,
- 0x6c, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e,
- 0x46, 0x6c, 0x61, 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e,
- 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18,
- 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
- 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77,
- 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e,
- 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65,
- 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16,
- 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06,
- 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65,
- 0x72, 0x61, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71,
- 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18,
- 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07,
- 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44,
- 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f,
- 0x75, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52,
- 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f,
- 0x41, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69,
- 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44,
- 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76,
- 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
- 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47,
- 0x0a, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75,
- 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
- 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65,
- 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f,
- 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d,
- 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65,
- 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f,
- 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f,
- 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb8, 0x01, 0x0a, 0x0a,
- 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f,
- 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61,
- 0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20,
- 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
- 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52,
- 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68,
- 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03,
- 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61,
- 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x4e, 0x6f,
- 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x18, 0x04,
- 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69,
- 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65,
- 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01,
- 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79,
- 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14,
- 0x0a, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43,
- 0x6c, 0x61, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28,
- 0x03, 0x52, 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18,
- 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a,
- 0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70,
- 0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18,
- 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
- 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e,
- 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72,
- 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69,
- 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18,
- 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32,
- 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45,
- 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65,
- 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c,
- 0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
- 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50,
- 0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
- 0x52, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74,
- 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xfb, 0x02, 0x0a,
- 0x0c, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1a, 0x0a,
- 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18,
- 0x01, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72,
- 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69,
- 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69,
- 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01,
- 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69,
- 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04,
- 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08,
- 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74,
- 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08,
- 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14,
+ 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72,
+ 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12,
+ 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x18,
+ 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55,
+ 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72,
+ 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52,
+ 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f,
+ 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67,
+ 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61,
+ 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49,
+ 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x4e,
+ 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4e, 0x65,
+ 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
+ 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e, 0x65, 0x74, 0x77,
+ 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65, 0x65, 0x72, 0x18,
+ 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x4d,
+ 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4d, 0x65, 0x74,
+ 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64,
+ 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72,
+ 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d,
+ 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61,
+ 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65,
+ 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74,
+ 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70,
+ 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75,
+ 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43,
+ 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
+ 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, 0x65,
+ 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, 0x4e,
+ 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18,
+ 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+ 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f,
+ 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72,
+ 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f,
+ 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e,
+ 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x28,
+ 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x18,
+ 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61,
+ 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb8, 0x01, 0x0a, 0x0a, 0x43, 0x75, 0x73,
+ 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69,
+ 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12,
+ 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b,
+ 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69,
+ 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52, 0x65, 0x63, 0x6f,
+ 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d,
+ 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28,
+ 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44,
+ 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75,
+ 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28,
+ 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74,
+ 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63,
+ 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18,
+ 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x43,
+ 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, 0x73,
+ 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03,
+ 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, 0x61,
+ 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, 0x0a,
+ 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03,
+ 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+ 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e, 0x61, 0x6d, 0x65,
+ 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61,
+ 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72,
+ 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03,
+ 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53,
+ 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62,
+ 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63,
+ 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22,
+ 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a,
+ 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a,
+ 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4e,
+ 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20,
+ 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xfb, 0x02, 0x0a, 0x0c, 0x46, 0x69,
+ 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1a, 0x0a, 0x06, 0x50, 0x65,
+ 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x06,
+ 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74,
+ 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63,
+ 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12,
+ 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32,
+ 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c,
+ 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12,
+ 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28,
+ 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52,
+ 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f,
+ 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, 0x72,
+ 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66,
+ 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x50,
+ 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50,
+ 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f,
+ 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52,
+ 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12,
+ 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65,
+ 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50,
+ 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, 0x6f,
+ 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, 0x74,
+ 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x12,
+ 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x61,
+ 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46,
+ 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, 0x65,
+ 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14,
+ 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x04,
+ 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20,
+ 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+ 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x48,
+ 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67,
+ 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d,
+ 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02,
+ 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, 0x72,
+ 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, 0x52,
+ 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65,
+ 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73,
+ 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61,
+ 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02,
+ 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+ 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63,
+ 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74,
+ 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69,
+ 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63,
+ 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63,
+ 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, 0x08,
+ 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14,
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74,
- 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a,
- 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c,
- 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75,
- 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01,
- 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63,
- 0x6f, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66,
- 0x69, 0x78, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72,
- 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65,
- 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05,
- 0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74,
- 0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
- 0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14,
- 0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46,
- 0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66,
- 0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48,
- 0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65,
- 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
- 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e,
- 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52,
- 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e,
- 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d,
- 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03,
- 0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52,
- 0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e,
- 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63,
- 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f,
- 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
- 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52,
- 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69,
- 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65,
- 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f,
- 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f,
- 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12,
- 0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28,
- 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50,
- 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66,
- 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06,
- 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12,
- 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09,
- 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73,
- 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28,
- 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f,
- 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20,
- 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a,
- 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
- 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77,
- 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72,
- 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72,
- 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c,
- 0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50,
- 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52,
+ 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1c,
+ 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28,
+ 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07,
+ 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64,
+ 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d,
+ 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e,
+ 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a,
+ 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c,
+ 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, 0x6f,
+ 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, 0x75,
+ 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64,
+ 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+ 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f,
+ 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, 0x0a,
0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74,
- 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64,
- 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61,
- 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c,
- 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74,
- 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
- 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72,
- 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x02, 0x0a,
- 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65,
- 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f,
- 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50,
- 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f,
- 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
- 0x70, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18,
- 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12,
- 0x1f, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05,
- 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73,
- 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09,
- 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65,
- 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e,
- 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73,
- 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a,
- 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45,
- 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70,
- 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f,
- 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76,
- 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69,
- 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65,
- 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61,
- 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
- 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73,
- 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f,
- 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c,
- 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71,
- 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01,
- 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13,
- 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
- 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73,
- 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61,
- 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e,
- 0x22, 0x14, 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65,
- 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61,
- 0x74, 0x75, 0x73, 0x12, 0x12, 0x0a, 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73,
- 0x74, 0x61, 0x74, 0x75, 0x73, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65,
- 0x65, 0x64, 0x65, 0x64, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64,
- 0x10, 0x02, 0x2a, 0x6c, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69,
- 0x6c, 0x69, 0x74, 0x79, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61,
- 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12,
- 0x20, 0x0a, 0x1c, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74,
- 0x79, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10,
- 0x01, 0x12, 0x1d, 0x0a, 0x19, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c,
- 0x69, 0x74, 0x79, 0x49, 0x50, 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02,
- 0x2a, 0x4c, 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c,
- 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a,
- 0x03, 0x41, 0x4c, 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12,
- 0x07, 0x0a, 0x03, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50,
- 0x10, 0x04, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x2a, 0x20,
- 0x0a, 0x0d, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12,
- 0x06, 0x0a, 0x02, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01,
- 0x2a, 0x22, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a,
- 0x0a, 0x06, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52,
- 0x4f, 0x50, 0x10, 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72,
- 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45,
- 0x5f, 0x48, 0x54, 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53,
- 0x45, 0x5f, 0x48, 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50,
- 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50,
- 0x4f, 0x53, 0x45, 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50,
- 0x4f, 0x53, 0x45, 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xfd, 0x06, 0x0a, 0x11, 0x4d, 0x61,
- 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12,
- 0x45, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d,
- 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
- 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73,
- 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c,
- 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72,
- 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
- 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42,
- 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11,
- 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74,
- 0x79, 0x1a, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53,
- 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
- 0x22, 0x00, 0x12, 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12,
- 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70,
- 0x74, 0x79, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65,
- 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f,
- 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+ 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65,
+ 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a,
+ 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65,
+ 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c,
+ 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74,
+ 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20,
+ 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+ 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73,
+ 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45, 0x78,
+ 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
+ 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d,
+ 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63,
+ 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74,
+ 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x10,
+ 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e,
+ 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1f, 0x0a, 0x0b,
+ 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28,
+ 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, 0x0a,
+ 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64,
+ 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, 0x72,
+ 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65,
+ 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e,
+ 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69, 0x73,
+ 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70, 0x6f,
+ 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
+ 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d,
+ 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
+ 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f,
+ 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69,
+ 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18,
+ 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a,
+ 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69, 0x67,
+ 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74, 0x41,
+ 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12, 0x52,
+ 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+ 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, 0x6e,
+ 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+ 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65,
+ 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x14, 0x0a,
+ 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
+ 0x6e, 0x73, 0x65, 0x2a, 0x3a, 0x0a, 0x09, 0x4a, 0x6f, 0x62, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
+ 0x12, 0x12, 0x0a, 0x0e, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74,
+ 0x75, 0x73, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x65,
+ 0x64, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x10, 0x02, 0x2a,
+ 0x6c, 0x0a, 0x0e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74,
+ 0x79, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c,
+ 0x69, 0x74, 0x79, 0x55, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x10, 0x00, 0x12, 0x20, 0x0a, 0x1c,
+ 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x6f,
+ 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x10, 0x01, 0x12, 0x1d,
+ 0x0a, 0x19, 0x50, 0x65, 0x65, 0x72, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79,
+ 0x49, 0x50, 0x76, 0x36, 0x4f, 0x76, 0x65, 0x72, 0x6c, 0x61, 0x79, 0x10, 0x02, 0x2a, 0x4c, 0x0a,
+ 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x0b, 0x0a,
+ 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x4c,
+ 0x4c, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x07, 0x0a, 0x03,
+ 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x43, 0x4d, 0x50, 0x10, 0x04, 0x12,
+ 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x05, 0x2a, 0x20, 0x0a, 0x0d, 0x52,
+ 0x75, 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x06, 0x0a, 0x02,
+ 0x49, 0x4e, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4f, 0x55, 0x54, 0x10, 0x01, 0x2a, 0x22, 0x0a,
+ 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x0a, 0x0a, 0x06, 0x41,
+ 0x43, 0x43, 0x45, 0x50, 0x54, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x52, 0x4f, 0x50, 0x10,
+ 0x01, 0x2a, 0x63, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f,
+ 0x63, 0x6f, 0x6c, 0x12, 0x0f, 0x0a, 0x0b, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48, 0x54,
+ 0x54, 0x50, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45, 0x5f, 0x48,
+ 0x54, 0x54, 0x50, 0x53, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45,
+ 0x5f, 0x54, 0x43, 0x50, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45,
+ 0x5f, 0x55, 0x44, 0x50, 0x10, 0x03, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x50, 0x4f, 0x53, 0x45,
+ 0x5f, 0x54, 0x4c, 0x53, 0x10, 0x04, 0x32, 0xd0, 0x07, 0x0a, 0x11, 0x4d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x05,
+ 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
- 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75,
- 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12,
- 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63,
- 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79,
- 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a,
- 0x08, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x04, 0x53, 0x79, 0x6e, 0x63, 0x12, 0x1c, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74,
+ 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64,
- 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
- 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06,
- 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x42, 0x0a, 0x0c, 0x47,
+ 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x4b, 0x65, 0x79, 0x12, 0x11, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1d,
+ 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x72, 0x76,
+ 0x65, 0x72, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12,
+ 0x33, 0x0a, 0x09, 0x69, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x11, 0x2e, 0x6d,
+ 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a,
+ 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6d, 0x70,
+ 0x74, 0x79, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x44, 0x65, 0x76, 0x69, 0x63,
+ 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c,
+ 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+ 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
+ 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e,
+ 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00,
+ 0x12, 0x58, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f,
+ 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1c, 0x2e, 0x6d,
+ 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70,
+ 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e,
+ 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65,
+ 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x3d, 0x0a, 0x08, 0x53, 0x79,
+ 0x6e, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73,
0x73, 0x61, 0x67, 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62,
- 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e,
- 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c,
- 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72,
- 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01,
- 0x30, 0x01, 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f,
+ 0x74, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06, 0x4c, 0x6f, 0x67,
+ 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+ 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
+ 0x65, 0x1a, 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45,
+ 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, 0x1c, 0x2e,
+ 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79,
+ 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74,
+ 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x12,
+ 0x51, 0x0a, 0x11, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x73,
+ 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+ 0x74, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61,
+ 0x67, 0x65, 0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+ 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
+ 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x0c, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x45, 0x78, 0x70, 0x6f,
0x73, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
0x1a, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x6e,
@@ -5461,7 +5629,7 @@ func file_management_proto_rawDescGZIP() []byte {
}
var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 8)
-var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 58)
+var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 60)
var file_management_proto_goTypes = []interface{}{
(JobStatus)(0), // 0: management.JobStatus
(PeerCapability)(0), // 1: management.PeerCapability
@@ -5486,149 +5654,157 @@ var file_management_proto_goTypes = []interface{}{
(*Flags)(nil), // 20: management.Flags
(*PeerSystemMeta)(nil), // 21: management.PeerSystemMeta
(*LoginResponse)(nil), // 22: management.LoginResponse
- (*ServerKeyResponse)(nil), // 23: management.ServerKeyResponse
- (*Empty)(nil), // 24: management.Empty
- (*NetbirdConfig)(nil), // 25: management.NetbirdConfig
- (*HostConfig)(nil), // 26: management.HostConfig
- (*RelayConfig)(nil), // 27: management.RelayConfig
- (*FlowConfig)(nil), // 28: management.FlowConfig
- (*JWTConfig)(nil), // 29: management.JWTConfig
- (*ProtectedHostConfig)(nil), // 30: management.ProtectedHostConfig
- (*PeerConfig)(nil), // 31: management.PeerConfig
- (*AutoUpdateSettings)(nil), // 32: management.AutoUpdateSettings
- (*NetworkMap)(nil), // 33: management.NetworkMap
- (*SSHAuth)(nil), // 34: management.SSHAuth
- (*MachineUserIndexes)(nil), // 35: management.MachineUserIndexes
- (*VNCAuth)(nil), // 36: management.VNCAuth
- (*SessionPubKey)(nil), // 37: management.SessionPubKey
- (*RemotePeerConfig)(nil), // 38: management.RemotePeerConfig
- (*SSHConfig)(nil), // 39: management.SSHConfig
- (*DeviceAuthorizationFlowRequest)(nil), // 40: management.DeviceAuthorizationFlowRequest
- (*DeviceAuthorizationFlow)(nil), // 41: management.DeviceAuthorizationFlow
- (*PKCEAuthorizationFlowRequest)(nil), // 42: management.PKCEAuthorizationFlowRequest
- (*PKCEAuthorizationFlow)(nil), // 43: management.PKCEAuthorizationFlow
- (*ProviderConfig)(nil), // 44: management.ProviderConfig
- (*Route)(nil), // 45: management.Route
- (*DNSConfig)(nil), // 46: management.DNSConfig
- (*CustomZone)(nil), // 47: management.CustomZone
- (*SimpleRecord)(nil), // 48: management.SimpleRecord
- (*NameServerGroup)(nil), // 49: management.NameServerGroup
- (*NameServer)(nil), // 50: management.NameServer
- (*FirewallRule)(nil), // 51: management.FirewallRule
- (*NetworkAddress)(nil), // 52: management.NetworkAddress
- (*Checks)(nil), // 53: management.Checks
- (*PortInfo)(nil), // 54: management.PortInfo
- (*RouteFirewallRule)(nil), // 55: management.RouteFirewallRule
- (*ForwardingRule)(nil), // 56: management.ForwardingRule
- (*ExposeServiceRequest)(nil), // 57: management.ExposeServiceRequest
- (*ExposeServiceResponse)(nil), // 58: management.ExposeServiceResponse
- (*RenewExposeRequest)(nil), // 59: management.RenewExposeRequest
- (*RenewExposeResponse)(nil), // 60: management.RenewExposeResponse
- (*StopExposeRequest)(nil), // 61: management.StopExposeRequest
- (*StopExposeResponse)(nil), // 62: management.StopExposeResponse
- nil, // 63: management.SSHAuth.MachineUsersEntry
- nil, // 64: management.VNCAuth.MachineUsersEntry
- (*PortInfo_Range)(nil), // 65: management.PortInfo.Range
- (*timestamppb.Timestamp)(nil), // 66: google.protobuf.Timestamp
- (*durationpb.Duration)(nil), // 67: google.protobuf.Duration
+ (*ExtendAuthSessionRequest)(nil), // 23: management.ExtendAuthSessionRequest
+ (*ExtendAuthSessionResponse)(nil), // 24: management.ExtendAuthSessionResponse
+ (*ServerKeyResponse)(nil), // 25: management.ServerKeyResponse
+ (*Empty)(nil), // 26: management.Empty
+ (*NetbirdConfig)(nil), // 27: management.NetbirdConfig
+ (*HostConfig)(nil), // 28: management.HostConfig
+ (*RelayConfig)(nil), // 29: management.RelayConfig
+ (*FlowConfig)(nil), // 30: management.FlowConfig
+ (*JWTConfig)(nil), // 31: management.JWTConfig
+ (*ProtectedHostConfig)(nil), // 32: management.ProtectedHostConfig
+ (*PeerConfig)(nil), // 33: management.PeerConfig
+ (*AutoUpdateSettings)(nil), // 34: management.AutoUpdateSettings
+ (*NetworkMap)(nil), // 35: management.NetworkMap
+ (*SSHAuth)(nil), // 36: management.SSHAuth
+ (*MachineUserIndexes)(nil), // 37: management.MachineUserIndexes
+ (*VNCAuth)(nil), // 38: management.VNCAuth
+ (*SessionPubKey)(nil), // 39: management.SessionPubKey
+ (*RemotePeerConfig)(nil), // 40: management.RemotePeerConfig
+ (*SSHConfig)(nil), // 41: management.SSHConfig
+ (*DeviceAuthorizationFlowRequest)(nil), // 42: management.DeviceAuthorizationFlowRequest
+ (*DeviceAuthorizationFlow)(nil), // 43: management.DeviceAuthorizationFlow
+ (*PKCEAuthorizationFlowRequest)(nil), // 44: management.PKCEAuthorizationFlowRequest
+ (*PKCEAuthorizationFlow)(nil), // 45: management.PKCEAuthorizationFlow
+ (*ProviderConfig)(nil), // 46: management.ProviderConfig
+ (*Route)(nil), // 47: management.Route
+ (*DNSConfig)(nil), // 48: management.DNSConfig
+ (*CustomZone)(nil), // 49: management.CustomZone
+ (*SimpleRecord)(nil), // 50: management.SimpleRecord
+ (*NameServerGroup)(nil), // 51: management.NameServerGroup
+ (*NameServer)(nil), // 52: management.NameServer
+ (*FirewallRule)(nil), // 53: management.FirewallRule
+ (*NetworkAddress)(nil), // 54: management.NetworkAddress
+ (*Checks)(nil), // 55: management.Checks
+ (*PortInfo)(nil), // 56: management.PortInfo
+ (*RouteFirewallRule)(nil), // 57: management.RouteFirewallRule
+ (*ForwardingRule)(nil), // 58: management.ForwardingRule
+ (*ExposeServiceRequest)(nil), // 59: management.ExposeServiceRequest
+ (*ExposeServiceResponse)(nil), // 60: management.ExposeServiceResponse
+ (*RenewExposeRequest)(nil), // 61: management.RenewExposeRequest
+ (*RenewExposeResponse)(nil), // 62: management.RenewExposeResponse
+ (*StopExposeRequest)(nil), // 63: management.StopExposeRequest
+ (*StopExposeResponse)(nil), // 64: management.StopExposeResponse
+ nil, // 65: management.SSHAuth.MachineUsersEntry
+ nil, // 66: management.VNCAuth.MachineUsersEntry
+ (*PortInfo_Range)(nil), // 67: management.PortInfo.Range
+ (*timestamppb.Timestamp)(nil), // 68: google.protobuf.Timestamp
+ (*durationpb.Duration)(nil), // 69: google.protobuf.Duration
}
var file_management_proto_depIdxs = []int32{
11, // 0: management.JobRequest.bundle:type_name -> management.BundleParameters
0, // 1: management.JobResponse.status:type_name -> management.JobStatus
12, // 2: management.JobResponse.bundle:type_name -> management.BundleResult
21, // 3: management.SyncRequest.meta:type_name -> management.PeerSystemMeta
- 25, // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig
- 31, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig
- 38, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig
- 33, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap
- 53, // 8: management.SyncResponse.Checks:type_name -> management.Checks
- 21, // 9: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta
- 21, // 10: management.LoginRequest.meta:type_name -> management.PeerSystemMeta
- 17, // 11: management.LoginRequest.peerKeys:type_name -> management.PeerKeys
- 52, // 12: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress
- 18, // 13: management.PeerSystemMeta.environment:type_name -> management.Environment
- 19, // 14: management.PeerSystemMeta.files:type_name -> management.File
- 20, // 15: management.PeerSystemMeta.flags:type_name -> management.Flags
- 1, // 16: management.PeerSystemMeta.capabilities:type_name -> management.PeerCapability
- 25, // 17: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig
- 31, // 18: management.LoginResponse.peerConfig:type_name -> management.PeerConfig
- 53, // 19: management.LoginResponse.Checks:type_name -> management.Checks
- 66, // 20: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp
- 26, // 21: management.NetbirdConfig.stuns:type_name -> management.HostConfig
- 30, // 22: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig
- 26, // 23: management.NetbirdConfig.signal:type_name -> management.HostConfig
- 27, // 24: management.NetbirdConfig.relay:type_name -> management.RelayConfig
- 28, // 25: management.NetbirdConfig.flow:type_name -> management.FlowConfig
- 6, // 26: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol
- 67, // 27: management.FlowConfig.interval:type_name -> google.protobuf.Duration
- 26, // 28: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig
- 39, // 29: management.PeerConfig.sshConfig:type_name -> management.SSHConfig
- 32, // 30: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings
- 31, // 31: management.NetworkMap.peerConfig:type_name -> management.PeerConfig
- 38, // 32: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig
- 45, // 33: management.NetworkMap.Routes:type_name -> management.Route
- 46, // 34: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig
- 38, // 35: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig
- 51, // 36: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule
- 55, // 37: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule
- 56, // 38: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule
- 34, // 39: management.NetworkMap.sshAuth:type_name -> management.SSHAuth
- 36, // 40: management.NetworkMap.vncAuth:type_name -> management.VNCAuth
- 63, // 41: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry
- 64, // 42: management.VNCAuth.machine_users:type_name -> management.VNCAuth.MachineUsersEntry
- 37, // 43: management.VNCAuth.session_pub_keys:type_name -> management.SessionPubKey
- 39, // 44: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig
- 29, // 45: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig
- 7, // 46: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider
- 44, // 47: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
- 44, // 48: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
- 49, // 49: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup
- 47, // 50: management.DNSConfig.CustomZones:type_name -> management.CustomZone
- 48, // 51: management.CustomZone.Records:type_name -> management.SimpleRecord
- 50, // 52: management.NameServerGroup.NameServers:type_name -> management.NameServer
- 3, // 53: management.FirewallRule.Direction:type_name -> management.RuleDirection
- 4, // 54: management.FirewallRule.Action:type_name -> management.RuleAction
- 2, // 55: management.FirewallRule.Protocol:type_name -> management.RuleProtocol
- 54, // 56: management.FirewallRule.PortInfo:type_name -> management.PortInfo
- 65, // 57: management.PortInfo.range:type_name -> management.PortInfo.Range
- 4, // 58: management.RouteFirewallRule.action:type_name -> management.RuleAction
- 2, // 59: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol
- 54, // 60: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo
- 2, // 61: management.ForwardingRule.protocol:type_name -> management.RuleProtocol
- 54, // 62: management.ForwardingRule.destinationPort:type_name -> management.PortInfo
- 54, // 63: management.ForwardingRule.translatedPort:type_name -> management.PortInfo
- 5, // 64: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol
- 35, // 65: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
- 35, // 66: management.VNCAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
- 8, // 67: management.ManagementService.Login:input_type -> management.EncryptedMessage
- 8, // 68: management.ManagementService.Sync:input_type -> management.EncryptedMessage
- 24, // 69: management.ManagementService.GetServerKey:input_type -> management.Empty
- 24, // 70: management.ManagementService.isHealthy:input_type -> management.Empty
- 8, // 71: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage
- 8, // 72: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage
- 8, // 73: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage
- 8, // 74: management.ManagementService.Logout:input_type -> management.EncryptedMessage
- 8, // 75: management.ManagementService.Job:input_type -> management.EncryptedMessage
- 8, // 76: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage
- 8, // 77: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage
- 8, // 78: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage
- 8, // 79: management.ManagementService.Login:output_type -> management.EncryptedMessage
- 8, // 80: management.ManagementService.Sync:output_type -> management.EncryptedMessage
- 23, // 81: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse
- 24, // 82: management.ManagementService.isHealthy:output_type -> management.Empty
- 8, // 83: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage
- 8, // 84: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage
- 24, // 85: management.ManagementService.SyncMeta:output_type -> management.Empty
- 24, // 86: management.ManagementService.Logout:output_type -> management.Empty
- 8, // 87: management.ManagementService.Job:output_type -> management.EncryptedMessage
- 8, // 88: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage
- 8, // 89: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage
- 8, // 90: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage
- 79, // [79:91] is the sub-list for method output_type
- 67, // [67:79] is the sub-list for method input_type
- 67, // [67:67] is the sub-list for extension type_name
- 67, // [67:67] is the sub-list for extension extendee
- 0, // [0:67] is the sub-list for field type_name
+ 27, // 4: management.SyncResponse.netbirdConfig:type_name -> management.NetbirdConfig
+ 33, // 5: management.SyncResponse.peerConfig:type_name -> management.PeerConfig
+ 40, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig
+ 35, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap
+ 55, // 8: management.SyncResponse.Checks:type_name -> management.Checks
+ 68, // 9: management.SyncResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+ 21, // 10: management.SyncMetaRequest.meta:type_name -> management.PeerSystemMeta
+ 21, // 11: management.LoginRequest.meta:type_name -> management.PeerSystemMeta
+ 17, // 12: management.LoginRequest.peerKeys:type_name -> management.PeerKeys
+ 54, // 13: management.PeerSystemMeta.networkAddresses:type_name -> management.NetworkAddress
+ 18, // 14: management.PeerSystemMeta.environment:type_name -> management.Environment
+ 19, // 15: management.PeerSystemMeta.files:type_name -> management.File
+ 20, // 16: management.PeerSystemMeta.flags:type_name -> management.Flags
+ 1, // 17: management.PeerSystemMeta.capabilities:type_name -> management.PeerCapability
+ 27, // 18: management.LoginResponse.netbirdConfig:type_name -> management.NetbirdConfig
+ 33, // 19: management.LoginResponse.peerConfig:type_name -> management.PeerConfig
+ 55, // 20: management.LoginResponse.Checks:type_name -> management.Checks
+ 68, // 21: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+ 21, // 22: management.ExtendAuthSessionRequest.meta:type_name -> management.PeerSystemMeta
+ 68, // 23: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp
+ 68, // 24: management.ServerKeyResponse.expiresAt:type_name -> google.protobuf.Timestamp
+ 28, // 25: management.NetbirdConfig.stuns:type_name -> management.HostConfig
+ 32, // 26: management.NetbirdConfig.turns:type_name -> management.ProtectedHostConfig
+ 28, // 27: management.NetbirdConfig.signal:type_name -> management.HostConfig
+ 29, // 28: management.NetbirdConfig.relay:type_name -> management.RelayConfig
+ 30, // 29: management.NetbirdConfig.flow:type_name -> management.FlowConfig
+ 6, // 30: management.HostConfig.protocol:type_name -> management.HostConfig.Protocol
+ 69, // 31: management.FlowConfig.interval:type_name -> google.protobuf.Duration
+ 28, // 32: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig
+ 41, // 33: management.PeerConfig.sshConfig:type_name -> management.SSHConfig
+ 34, // 34: management.PeerConfig.autoUpdate:type_name -> management.AutoUpdateSettings
+ 33, // 35: management.NetworkMap.peerConfig:type_name -> management.PeerConfig
+ 40, // 36: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig
+ 47, // 37: management.NetworkMap.Routes:type_name -> management.Route
+ 48, // 38: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig
+ 40, // 39: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig
+ 53, // 40: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule
+ 57, // 41: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule
+ 58, // 42: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule
+ 36, // 43: management.NetworkMap.sshAuth:type_name -> management.SSHAuth
+ 38, // 44: management.NetworkMap.vncAuth:type_name -> management.VNCAuth
+ 65, // 45: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry
+ 66, // 46: management.VNCAuth.machine_users:type_name -> management.VNCAuth.MachineUsersEntry
+ 39, // 47: management.VNCAuth.session_pub_keys:type_name -> management.SessionPubKey
+ 41, // 48: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig
+ 31, // 49: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig
+ 7, // 50: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider
+ 46, // 51: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
+ 46, // 52: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig
+ 51, // 53: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup
+ 49, // 54: management.DNSConfig.CustomZones:type_name -> management.CustomZone
+ 50, // 55: management.CustomZone.Records:type_name -> management.SimpleRecord
+ 52, // 56: management.NameServerGroup.NameServers:type_name -> management.NameServer
+ 3, // 57: management.FirewallRule.Direction:type_name -> management.RuleDirection
+ 4, // 58: management.FirewallRule.Action:type_name -> management.RuleAction
+ 2, // 59: management.FirewallRule.Protocol:type_name -> management.RuleProtocol
+ 56, // 60: management.FirewallRule.PortInfo:type_name -> management.PortInfo
+ 67, // 61: management.PortInfo.range:type_name -> management.PortInfo.Range
+ 4, // 62: management.RouteFirewallRule.action:type_name -> management.RuleAction
+ 2, // 63: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol
+ 56, // 64: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo
+ 2, // 65: management.ForwardingRule.protocol:type_name -> management.RuleProtocol
+ 56, // 66: management.ForwardingRule.destinationPort:type_name -> management.PortInfo
+ 56, // 67: management.ForwardingRule.translatedPort:type_name -> management.PortInfo
+ 5, // 68: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol
+ 37, // 69: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
+ 37, // 70: management.VNCAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes
+ 8, // 71: management.ManagementService.Login:input_type -> management.EncryptedMessage
+ 8, // 72: management.ManagementService.Sync:input_type -> management.EncryptedMessage
+ 26, // 73: management.ManagementService.GetServerKey:input_type -> management.Empty
+ 26, // 74: management.ManagementService.isHealthy:input_type -> management.Empty
+ 8, // 75: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage
+ 8, // 76: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage
+ 8, // 77: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage
+ 8, // 78: management.ManagementService.Logout:input_type -> management.EncryptedMessage
+ 8, // 79: management.ManagementService.Job:input_type -> management.EncryptedMessage
+ 8, // 80: management.ManagementService.ExtendAuthSession:input_type -> management.EncryptedMessage
+ 8, // 81: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage
+ 8, // 82: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage
+ 8, // 83: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage
+ 8, // 84: management.ManagementService.Login:output_type -> management.EncryptedMessage
+ 8, // 85: management.ManagementService.Sync:output_type -> management.EncryptedMessage
+ 25, // 86: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse
+ 26, // 87: management.ManagementService.isHealthy:output_type -> management.Empty
+ 8, // 88: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage
+ 8, // 89: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage
+ 26, // 90: management.ManagementService.SyncMeta:output_type -> management.Empty
+ 26, // 91: management.ManagementService.Logout:output_type -> management.Empty
+ 8, // 92: management.ManagementService.Job:output_type -> management.EncryptedMessage
+ 8, // 93: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage
+ 8, // 94: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage
+ 8, // 95: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage
+ 8, // 96: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage
+ 84, // [84:97] is the sub-list for method output_type
+ 71, // [71:84] is the sub-list for method input_type
+ 71, // [71:71] is the sub-list for extension type_name
+ 71, // [71:71] is the sub-list for extension extendee
+ 0, // [0:71] is the sub-list for field type_name
}
func init() { file_management_proto_init() }
@@ -5818,7 +5994,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ServerKeyResponse); i {
+ switch v := v.(*ExtendAuthSessionRequest); i {
case 0:
return &v.state
case 1:
@@ -5830,7 +6006,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*Empty); i {
+ switch v := v.(*ExtendAuthSessionResponse); i {
case 0:
return &v.state
case 1:
@@ -5842,7 +6018,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NetbirdConfig); i {
+ switch v := v.(*ServerKeyResponse); i {
case 0:
return &v.state
case 1:
@@ -5854,7 +6030,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*HostConfig); i {
+ switch v := v.(*Empty); i {
case 0:
return &v.state
case 1:
@@ -5866,7 +6042,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*RelayConfig); i {
+ switch v := v.(*NetbirdConfig); i {
case 0:
return &v.state
case 1:
@@ -5878,7 +6054,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*FlowConfig); i {
+ switch v := v.(*HostConfig); i {
case 0:
return &v.state
case 1:
@@ -5890,7 +6066,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*JWTConfig); i {
+ switch v := v.(*RelayConfig); i {
case 0:
return &v.state
case 1:
@@ -5902,7 +6078,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ProtectedHostConfig); i {
+ switch v := v.(*FlowConfig); i {
case 0:
return &v.state
case 1:
@@ -5914,7 +6090,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*PeerConfig); i {
+ switch v := v.(*JWTConfig); i {
case 0:
return &v.state
case 1:
@@ -5926,7 +6102,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*AutoUpdateSettings); i {
+ switch v := v.(*ProtectedHostConfig); i {
case 0:
return &v.state
case 1:
@@ -5938,7 +6114,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NetworkMap); i {
+ switch v := v.(*PeerConfig); i {
case 0:
return &v.state
case 1:
@@ -5950,7 +6126,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SSHAuth); i {
+ switch v := v.(*AutoUpdateSettings); i {
case 0:
return &v.state
case 1:
@@ -5962,7 +6138,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*MachineUserIndexes); i {
+ switch v := v.(*NetworkMap); i {
case 0:
return &v.state
case 1:
@@ -5974,7 +6150,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*VNCAuth); i {
+ switch v := v.(*SSHAuth); i {
case 0:
return &v.state
case 1:
@@ -5986,7 +6162,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SessionPubKey); i {
+ switch v := v.(*MachineUserIndexes); i {
case 0:
return &v.state
case 1:
@@ -5998,7 +6174,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*RemotePeerConfig); i {
+ switch v := v.(*VNCAuth); i {
case 0:
return &v.state
case 1:
@@ -6010,7 +6186,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SSHConfig); i {
+ switch v := v.(*SessionPubKey); i {
case 0:
return &v.state
case 1:
@@ -6022,7 +6198,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[32].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*DeviceAuthorizationFlowRequest); i {
+ switch v := v.(*RemotePeerConfig); i {
case 0:
return &v.state
case 1:
@@ -6034,7 +6210,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[33].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*DeviceAuthorizationFlow); i {
+ switch v := v.(*SSHConfig); i {
case 0:
return &v.state
case 1:
@@ -6046,7 +6222,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[34].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*PKCEAuthorizationFlowRequest); i {
+ switch v := v.(*DeviceAuthorizationFlowRequest); i {
case 0:
return &v.state
case 1:
@@ -6058,7 +6234,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[35].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*PKCEAuthorizationFlow); i {
+ switch v := v.(*DeviceAuthorizationFlow); i {
case 0:
return &v.state
case 1:
@@ -6070,7 +6246,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[36].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ProviderConfig); i {
+ switch v := v.(*PKCEAuthorizationFlowRequest); i {
case 0:
return &v.state
case 1:
@@ -6082,7 +6258,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[37].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*Route); i {
+ switch v := v.(*PKCEAuthorizationFlow); i {
case 0:
return &v.state
case 1:
@@ -6094,7 +6270,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[38].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*DNSConfig); i {
+ switch v := v.(*ProviderConfig); i {
case 0:
return &v.state
case 1:
@@ -6106,7 +6282,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[39].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*CustomZone); i {
+ switch v := v.(*Route); i {
case 0:
return &v.state
case 1:
@@ -6118,7 +6294,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[40].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SimpleRecord); i {
+ switch v := v.(*DNSConfig); i {
case 0:
return &v.state
case 1:
@@ -6130,7 +6306,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[41].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NameServerGroup); i {
+ switch v := v.(*CustomZone); i {
case 0:
return &v.state
case 1:
@@ -6142,7 +6318,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[42].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NameServer); i {
+ switch v := v.(*SimpleRecord); i {
case 0:
return &v.state
case 1:
@@ -6154,7 +6330,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[43].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*FirewallRule); i {
+ switch v := v.(*NameServerGroup); i {
case 0:
return &v.state
case 1:
@@ -6166,7 +6342,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[44].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*NetworkAddress); i {
+ switch v := v.(*NameServer); i {
case 0:
return &v.state
case 1:
@@ -6178,7 +6354,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[45].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*Checks); i {
+ switch v := v.(*FirewallRule); i {
case 0:
return &v.state
case 1:
@@ -6190,7 +6366,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[46].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*PortInfo); i {
+ switch v := v.(*NetworkAddress); i {
case 0:
return &v.state
case 1:
@@ -6202,7 +6378,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[47].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*RouteFirewallRule); i {
+ switch v := v.(*Checks); i {
case 0:
return &v.state
case 1:
@@ -6214,7 +6390,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[48].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ForwardingRule); i {
+ switch v := v.(*PortInfo); i {
case 0:
return &v.state
case 1:
@@ -6226,7 +6402,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[49].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ExposeServiceRequest); i {
+ switch v := v.(*RouteFirewallRule); i {
case 0:
return &v.state
case 1:
@@ -6238,7 +6414,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[50].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ExposeServiceResponse); i {
+ switch v := v.(*ForwardingRule); i {
case 0:
return &v.state
case 1:
@@ -6250,7 +6426,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[51].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*RenewExposeRequest); i {
+ switch v := v.(*ExposeServiceRequest); i {
case 0:
return &v.state
case 1:
@@ -6262,7 +6438,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[52].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*RenewExposeResponse); i {
+ switch v := v.(*ExposeServiceResponse); i {
case 0:
return &v.state
case 1:
@@ -6274,7 +6450,7 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[53].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*StopExposeRequest); i {
+ switch v := v.(*RenewExposeRequest); i {
case 0:
return &v.state
case 1:
@@ -6286,6 +6462,30 @@ func file_management_proto_init() {
}
}
file_management_proto_msgTypes[54].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*RenewExposeResponse); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_management_proto_msgTypes[55].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*StopExposeRequest); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_management_proto_msgTypes[56].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*StopExposeResponse); i {
case 0:
return &v.state
@@ -6297,7 +6497,7 @@ func file_management_proto_init() {
return nil
}
}
- file_management_proto_msgTypes[57].Exporter = func(v interface{}, i int) interface{} {
+ file_management_proto_msgTypes[59].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*PortInfo_Range); i {
case 0:
return &v.state
@@ -6316,7 +6516,7 @@ func file_management_proto_init() {
file_management_proto_msgTypes[2].OneofWrappers = []interface{}{
(*JobResponse_Bundle)(nil),
}
- file_management_proto_msgTypes[46].OneofWrappers = []interface{}{
+ file_management_proto_msgTypes[48].OneofWrappers = []interface{}{
(*PortInfo_Port)(nil),
(*PortInfo_Range_)(nil),
}
@@ -6326,7 +6526,7 @@ func file_management_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_management_proto_rawDesc,
NumEnums: 8,
- NumMessages: 58,
+ NumMessages: 60,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto
index 421712ccd..b06f73f72 100644
--- a/shared/management/proto/management.proto
+++ b/shared/management/proto/management.proto
@@ -52,6 +52,14 @@ service ManagementService {
// Executes a job on a target peer (e.g., debug bundle)
rpc Job(stream EncryptedMessage) returns (stream EncryptedMessage) {}
+ // ExtendAuthSession refreshes the peer's session expiry deadline using a fresh JWT.
+ // Same JWT validation pipeline as Login (including jwt.UserID == peer.UserID check),
+ // but does not redo the network-map sync. Only valid for SSO-registered peers where
+ // login expiration is enabled. The tunnel remains up.
+ // EncryptedMessage of the request has a body of ExtendAuthSessionRequest.
+ // EncryptedMessage of the response has a body of ExtendAuthSessionResponse.
+ rpc ExtendAuthSession(EncryptedMessage) returns (EncryptedMessage) {}
+
// CreateExpose creates a temporary reverse proxy service for a peer
rpc CreateExpose(EncryptedMessage) returns (EncryptedMessage) {}
@@ -133,6 +141,15 @@ message SyncResponse {
// Posture checks to be evaluated by client
repeated Checks Checks = 6;
+
+ // 3-state session deadline. Carried on every Sync snapshot so admin-side
+ // changes propagate live without a client reconnect.
+ // field unset (nil) → snapshot carries no info; client keeps the
+ // deadline it already had
+ // set, seconds=0 nanos=0 → explicit "expiry disabled" or peer is not
+ // SSO-registered; client clears its anchor
+ // set, valid timestamp → new absolute UTC deadline
+ google.protobuf.Timestamp sessionExpiresAt = 7;
}
message SyncMetaRequest {
@@ -246,6 +263,31 @@ message LoginResponse {
PeerConfig peerConfig = 2;
// Posture checks to be evaluated by client
repeated Checks Checks = 3;
+
+ // 3-state session deadline; same encoding as SyncResponse.sessionExpiresAt.
+ // field unset (nil) → no info; client keeps any deadline it had
+ // set, seconds=0 nanos=0 → explicit "expiry disabled" / non-SSO peer
+ // set, valid timestamp → new absolute UTC deadline
+ google.protobuf.Timestamp sessionExpiresAt = 4;
+}
+
+// ExtendAuthSessionRequest carries a fresh JWT to refresh the peer's session deadline.
+// The encrypted body of an EncryptedMessage with this payload is sent to the
+// ExtendAuthSession RPC.
+message ExtendAuthSessionRequest {
+ // SSO token (must be a fresh, valid JWT for the peer's owning user)
+ string jwtToken = 1;
+ // Meta data of the peer (used for IdP user info refresh consistent with Login)
+ PeerSystemMeta meta = 2;
+}
+
+// ExtendAuthSessionResponse contains the refreshed session deadline.
+message ExtendAuthSessionResponse {
+ // 3-state session deadline; same encoding as SyncResponse.sessionExpiresAt.
+ // In practice ExtendAuthSession only succeeds for SSO peers with expiry
+ // enabled, so this carries a valid timestamp on the success path. The
+ // 3-state encoding is documented here for symmetry with Login/Sync.
+ google.protobuf.Timestamp sessionExpiresAt = 1;
}
message ServerKeyResponse {
diff --git a/shared/management/proto/management_grpc.pb.go b/shared/management/proto/management_grpc.pb.go
index 42b23519d..94c9767a0 100644
--- a/shared/management/proto/management_grpc.pb.go
+++ b/shared/management/proto/management_grpc.pb.go
@@ -28,6 +28,7 @@ const (
ManagementService_SyncMeta_FullMethodName = "/management.ManagementService/SyncMeta"
ManagementService_Logout_FullMethodName = "/management.ManagementService/Logout"
ManagementService_Job_FullMethodName = "/management.ManagementService/Job"
+ ManagementService_ExtendAuthSession_FullMethodName = "/management.ManagementService/ExtendAuthSession"
ManagementService_CreateExpose_FullMethodName = "/management.ManagementService/CreateExpose"
ManagementService_RenewExpose_FullMethodName = "/management.ManagementService/RenewExpose"
ManagementService_StopExpose_FullMethodName = "/management.ManagementService/StopExpose"
@@ -71,6 +72,13 @@ type ManagementServiceClient interface {
Logout(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*Empty, error)
// Executes a job on a target peer (e.g., debug bundle)
Job(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[EncryptedMessage, EncryptedMessage], error)
+ // ExtendAuthSession refreshes the peer's session expiry deadline using a fresh JWT.
+ // Same JWT validation pipeline as Login (including jwt.UserID == peer.UserID check),
+ // but does not redo the network-map sync. Only valid for SSO-registered peers where
+ // login expiration is enabled. The tunnel remains up.
+ // EncryptedMessage of the request has a body of ExtendAuthSessionRequest.
+ // EncryptedMessage of the response has a body of ExtendAuthSessionResponse.
+ ExtendAuthSession(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error)
// CreateExpose creates a temporary reverse proxy service for a peer
CreateExpose(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error)
// RenewExpose extends the TTL of an active expose session
@@ -189,6 +197,16 @@ func (c *managementServiceClient) Job(ctx context.Context, opts ...grpc.CallOpti
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type ManagementService_JobClient = grpc.BidiStreamingClient[EncryptedMessage, EncryptedMessage]
+func (c *managementServiceClient) ExtendAuthSession(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(EncryptedMessage)
+ err := c.cc.Invoke(ctx, ManagementService_ExtendAuthSession_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
func (c *managementServiceClient) CreateExpose(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(EncryptedMessage)
@@ -257,6 +275,13 @@ type ManagementServiceServer interface {
Logout(context.Context, *EncryptedMessage) (*Empty, error)
// Executes a job on a target peer (e.g., debug bundle)
Job(grpc.BidiStreamingServer[EncryptedMessage, EncryptedMessage]) error
+ // ExtendAuthSession refreshes the peer's session expiry deadline using a fresh JWT.
+ // Same JWT validation pipeline as Login (including jwt.UserID == peer.UserID check),
+ // but does not redo the network-map sync. Only valid for SSO-registered peers where
+ // login expiration is enabled. The tunnel remains up.
+ // EncryptedMessage of the request has a body of ExtendAuthSessionRequest.
+ // EncryptedMessage of the response has a body of ExtendAuthSessionResponse.
+ ExtendAuthSession(context.Context, *EncryptedMessage) (*EncryptedMessage, error)
// CreateExpose creates a temporary reverse proxy service for a peer
CreateExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error)
// RenewExpose extends the TTL of an active expose session
@@ -300,6 +325,9 @@ func (UnimplementedManagementServiceServer) Logout(context.Context, *EncryptedMe
func (UnimplementedManagementServiceServer) Job(grpc.BidiStreamingServer[EncryptedMessage, EncryptedMessage]) error {
return status.Error(codes.Unimplemented, "method Job not implemented")
}
+func (UnimplementedManagementServiceServer) ExtendAuthSession(context.Context, *EncryptedMessage) (*EncryptedMessage, error) {
+ return nil, status.Error(codes.Unimplemented, "method ExtendAuthSession not implemented")
+}
func (UnimplementedManagementServiceServer) CreateExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error) {
return nil, status.Error(codes.Unimplemented, "method CreateExpose not implemented")
}
@@ -474,6 +502,24 @@ func _ManagementService_Job_Handler(srv interface{}, stream grpc.ServerStream) e
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type ManagementService_JobServer = grpc.BidiStreamingServer[EncryptedMessage, EncryptedMessage]
+func _ManagementService_ExtendAuthSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(EncryptedMessage)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(ManagementServiceServer).ExtendAuthSession(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: ManagementService_ExtendAuthSession_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(ManagementServiceServer).ExtendAuthSession(ctx, req.(*EncryptedMessage))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
func _ManagementService_CreateExpose_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(EncryptedMessage)
if err := dec(in); err != nil {
@@ -563,6 +609,10 @@ var ManagementService_ServiceDesc = grpc.ServiceDesc{
MethodName: "Logout",
Handler: _ManagementService_Logout_Handler,
},
+ {
+ MethodName: "ExtendAuthSession",
+ Handler: _ManagementService_ExtendAuthSession_Handler,
+ },
{
MethodName: "CreateExpose",
Handler: _ManagementService_CreateExpose_Handler,
diff --git a/shared/management/proto/proxy_service.pb.go b/shared/management/proto/proxy_service.pb.go
index 1095b6411..22c215074 100644
--- a/shared/management/proto/proxy_service.pb.go
+++ b/shared/management/proto/proxy_service.pb.go
@@ -188,6 +188,13 @@ type ProxyCapabilities struct {
RequireSubdomain *bool `protobuf:"varint,2,opt,name=require_subdomain,json=requireSubdomain,proto3,oneof" json:"require_subdomain,omitempty"`
// Whether the proxy has CrowdSec configured and can enforce IP reputation checks.
SupportsCrowdsec *bool `protobuf:"varint,3,opt,name=supports_crowdsec,json=supportsCrowdsec,proto3,oneof" json:"supports_crowdsec,omitempty"`
+ // Whether the proxy is running embedded in the netbird client and serving
+ // exclusively over the WireGuard tunnel (i.e. `netbird proxy` rather than
+ // the standalone netbird-proxy binary). Surfaces upstream so dashboards can
+ // distinguish per-peer / private clusters from centralised ones.
+ Private *bool `protobuf:"varint,4,opt,name=private,proto3,oneof" json:"private,omitempty"`
+ // Whether the proxy enforces ProxyMapping.private (fails closed on ValidateTunnelPeer failure). Management MUST NOT stream private mappings to proxies that don't claim this.
+ SupportsPrivateService *bool `protobuf:"varint,5,opt,name=supports_private_service,json=supportsPrivateService,proto3,oneof" json:"supports_private_service,omitempty"`
}
func (x *ProxyCapabilities) Reset() {
@@ -243,6 +250,20 @@ func (x *ProxyCapabilities) GetSupportsCrowdsec() bool {
return false
}
+func (x *ProxyCapabilities) GetPrivate() bool {
+ if x != nil && x.Private != nil {
+ return *x.Private
+ }
+ return false
+}
+
+func (x *ProxyCapabilities) GetSupportsPrivateService() bool {
+ if x != nil && x.SupportsPrivateService != nil {
+ return *x.SupportsPrivateService
+ }
+ return false
+}
+
// GetMappingUpdateRequest is sent to initialise a mapping stream.
type GetMappingUpdateRequest struct {
state protoimpl.MessageState
@@ -396,6 +417,11 @@ type PathTargetOptions struct {
ProxyProtocol bool `protobuf:"varint,5,opt,name=proxy_protocol,json=proxyProtocol,proto3" json:"proxy_protocol,omitempty"`
// Idle timeout before a UDP session is reaped.
SessionIdleTimeout *durationpb.Duration `protobuf:"bytes,6,opt,name=session_idle_timeout,json=sessionIdleTimeout,proto3" json:"session_idle_timeout,omitempty"`
+ // When true, the proxy dials this target via the host's network stack
+ // instead of through the embedded NetBird client. Useful for upstreams
+ // reachable without WireGuard (public APIs, LAN services, localhost
+ // sidecars). Defaults to false — embedded client is the standard path.
+ DirectUpstream bool `protobuf:"varint,7,opt,name=direct_upstream,json=directUpstream,proto3" json:"direct_upstream,omitempty"`
}
func (x *PathTargetOptions) Reset() {
@@ -472,6 +498,13 @@ func (x *PathTargetOptions) GetSessionIdleTimeout() *durationpb.Duration {
return nil
}
+func (x *PathTargetOptions) GetDirectUpstream() bool {
+ if x != nil {
+ return x.DirectUpstream
+ }
+ return false
+}
+
type PathMapping struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -782,6 +815,8 @@ type ProxyMapping struct {
// For L4/TLS: the port the proxy listens on.
ListenPort int32 `protobuf:"varint,11,opt,name=listen_port,json=listenPort,proto3" json:"listen_port,omitempty"`
AccessRestrictions *AccessRestrictions `protobuf:"bytes,12,opt,name=access_restrictions,json=accessRestrictions,proto3" json:"access_restrictions,omitempty"`
+ // NetBird-only: the proxy MUST call ValidateTunnelPeer and fail closed; operator auth schemes are bypassed.
+ Private bool `protobuf:"varint,13,opt,name=private,proto3" json:"private,omitempty"`
}
func (x *ProxyMapping) Reset() {
@@ -900,6 +935,13 @@ func (x *ProxyMapping) GetAccessRestrictions() *AccessRestrictions {
return nil
}
+func (x *ProxyMapping) GetPrivate() bool {
+ if x != nil {
+ return x.Private
+ }
+ return false
+}
+
// SendAccessLogRequest consists of one or more AccessLogs from a Proxy.
type SendAccessLogRequest struct {
state protoimpl.MessageState
@@ -1489,6 +1531,11 @@ type SendStatusUpdateRequest struct {
Status ProxyStatus `protobuf:"varint,3,opt,name=status,proto3,enum=management.ProxyStatus" json:"status,omitempty"`
CertificateIssued bool `protobuf:"varint,4,opt,name=certificate_issued,json=certificateIssued,proto3" json:"certificate_issued,omitempty"`
ErrorMessage *string `protobuf:"bytes,5,opt,name=error_message,json=errorMessage,proto3,oneof" json:"error_message,omitempty"`
+ // Per-account inbound listener state for the account that owns
+ // service_id. Populated only when --private-inbound is enabled and the
+ // embedded client for the account is up. Field numbers >=50 reserved
+ // for observability extensions.
+ InboundListener *ProxyInboundListener `protobuf:"bytes,50,opt,name=inbound_listener,json=inboundListener,proto3,oneof" json:"inbound_listener,omitempty"`
}
func (x *SendStatusUpdateRequest) Reset() {
@@ -1558,6 +1605,84 @@ func (x *SendStatusUpdateRequest) GetErrorMessage() string {
return ""
}
+func (x *SendStatusUpdateRequest) GetInboundListener() *ProxyInboundListener {
+ if x != nil {
+ return x.InboundListener
+ }
+ return nil
+}
+
+// ProxyInboundListener describes a per-account inbound listener that the
+// proxy has bound on the embedded netstack of the account's WireGuard
+// client. Surfaced so dashboards can render "this account is reachable
+// at : on this proxy".
+type ProxyInboundListener struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ // Tunnel IP the embedded netstack listens on. Same address other peers
+ // in the account see for the proxy peer.
+ TunnelIp string `protobuf:"bytes,1,opt,name=tunnel_ip,json=tunnelIp,proto3" json:"tunnel_ip,omitempty"`
+ // TLS port served on tunnel_ip (auto-detected, default 443).
+ HttpsPort uint32 `protobuf:"varint,2,opt,name=https_port,json=httpsPort,proto3" json:"https_port,omitempty"`
+ // Plain-HTTP port served on tunnel_ip (auto-detected, default 80).
+ HttpPort uint32 `protobuf:"varint,3,opt,name=http_port,json=httpPort,proto3" json:"http_port,omitempty"`
+}
+
+func (x *ProxyInboundListener) Reset() {
+ *x = ProxyInboundListener{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_proxy_service_proto_msgTypes[18]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *ProxyInboundListener) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProxyInboundListener) ProtoMessage() {}
+
+func (x *ProxyInboundListener) ProtoReflect() protoreflect.Message {
+ mi := &file_proxy_service_proto_msgTypes[18]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProxyInboundListener.ProtoReflect.Descriptor instead.
+func (*ProxyInboundListener) Descriptor() ([]byte, []int) {
+ return file_proxy_service_proto_rawDescGZIP(), []int{18}
+}
+
+func (x *ProxyInboundListener) GetTunnelIp() string {
+ if x != nil {
+ return x.TunnelIp
+ }
+ return ""
+}
+
+func (x *ProxyInboundListener) GetHttpsPort() uint32 {
+ if x != nil {
+ return x.HttpsPort
+ }
+ return 0
+}
+
+func (x *ProxyInboundListener) GetHttpPort() uint32 {
+ if x != nil {
+ return x.HttpPort
+ }
+ return 0
+}
+
// SendStatusUpdateResponse is intentionally empty to allow for future expansion
type SendStatusUpdateResponse struct {
state protoimpl.MessageState
@@ -1568,7 +1693,7 @@ type SendStatusUpdateResponse struct {
func (x *SendStatusUpdateResponse) Reset() {
*x = SendStatusUpdateResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[18]
+ mi := &file_proxy_service_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1581,7 +1706,7 @@ func (x *SendStatusUpdateResponse) String() string {
func (*SendStatusUpdateResponse) ProtoMessage() {}
func (x *SendStatusUpdateResponse) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[18]
+ mi := &file_proxy_service_proto_msgTypes[19]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1594,7 +1719,7 @@ func (x *SendStatusUpdateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use SendStatusUpdateResponse.ProtoReflect.Descriptor instead.
func (*SendStatusUpdateResponse) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{18}
+ return file_proxy_service_proto_rawDescGZIP(), []int{19}
}
// CreateProxyPeerRequest is sent by the proxy to create a peer connection
@@ -1614,7 +1739,7 @@ type CreateProxyPeerRequest struct {
func (x *CreateProxyPeerRequest) Reset() {
*x = CreateProxyPeerRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[19]
+ mi := &file_proxy_service_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1627,7 +1752,7 @@ func (x *CreateProxyPeerRequest) String() string {
func (*CreateProxyPeerRequest) ProtoMessage() {}
func (x *CreateProxyPeerRequest) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[19]
+ mi := &file_proxy_service_proto_msgTypes[20]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1640,7 +1765,7 @@ func (x *CreateProxyPeerRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateProxyPeerRequest.ProtoReflect.Descriptor instead.
func (*CreateProxyPeerRequest) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{19}
+ return file_proxy_service_proto_rawDescGZIP(), []int{20}
}
func (x *CreateProxyPeerRequest) GetServiceId() string {
@@ -1691,7 +1816,7 @@ type CreateProxyPeerResponse struct {
func (x *CreateProxyPeerResponse) Reset() {
*x = CreateProxyPeerResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[20]
+ mi := &file_proxy_service_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1704,7 +1829,7 @@ func (x *CreateProxyPeerResponse) String() string {
func (*CreateProxyPeerResponse) ProtoMessage() {}
func (x *CreateProxyPeerResponse) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[20]
+ mi := &file_proxy_service_proto_msgTypes[21]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1717,7 +1842,7 @@ func (x *CreateProxyPeerResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateProxyPeerResponse.ProtoReflect.Descriptor instead.
func (*CreateProxyPeerResponse) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{20}
+ return file_proxy_service_proto_rawDescGZIP(), []int{21}
}
func (x *CreateProxyPeerResponse) GetSuccess() bool {
@@ -1747,7 +1872,7 @@ type GetOIDCURLRequest struct {
func (x *GetOIDCURLRequest) Reset() {
*x = GetOIDCURLRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[21]
+ mi := &file_proxy_service_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1760,7 +1885,7 @@ func (x *GetOIDCURLRequest) String() string {
func (*GetOIDCURLRequest) ProtoMessage() {}
func (x *GetOIDCURLRequest) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[21]
+ mi := &file_proxy_service_proto_msgTypes[22]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1773,7 +1898,7 @@ func (x *GetOIDCURLRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetOIDCURLRequest.ProtoReflect.Descriptor instead.
func (*GetOIDCURLRequest) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{21}
+ return file_proxy_service_proto_rawDescGZIP(), []int{22}
}
func (x *GetOIDCURLRequest) GetId() string {
@@ -1808,7 +1933,7 @@ type GetOIDCURLResponse struct {
func (x *GetOIDCURLResponse) Reset() {
*x = GetOIDCURLResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[22]
+ mi := &file_proxy_service_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1821,7 +1946,7 @@ func (x *GetOIDCURLResponse) String() string {
func (*GetOIDCURLResponse) ProtoMessage() {}
func (x *GetOIDCURLResponse) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[22]
+ mi := &file_proxy_service_proto_msgTypes[23]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1834,7 +1959,7 @@ func (x *GetOIDCURLResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetOIDCURLResponse.ProtoReflect.Descriptor instead.
func (*GetOIDCURLResponse) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{22}
+ return file_proxy_service_proto_rawDescGZIP(), []int{23}
}
func (x *GetOIDCURLResponse) GetUrl() string {
@@ -1856,7 +1981,7 @@ type ValidateSessionRequest struct {
func (x *ValidateSessionRequest) Reset() {
*x = ValidateSessionRequest{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[23]
+ mi := &file_proxy_service_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1869,7 +1994,7 @@ func (x *ValidateSessionRequest) String() string {
func (*ValidateSessionRequest) ProtoMessage() {}
func (x *ValidateSessionRequest) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[23]
+ mi := &file_proxy_service_proto_msgTypes[24]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1882,7 +2007,7 @@ func (x *ValidateSessionRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ValidateSessionRequest.ProtoReflect.Descriptor instead.
func (*ValidateSessionRequest) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{23}
+ return file_proxy_service_proto_rawDescGZIP(), []int{24}
}
func (x *ValidateSessionRequest) GetDomain() string {
@@ -1908,12 +2033,21 @@ type ValidateSessionResponse struct {
UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
UserEmail string `protobuf:"bytes,3,opt,name=user_email,json=userEmail,proto3" json:"user_email,omitempty"`
DeniedReason string `protobuf:"bytes,4,opt,name=denied_reason,json=deniedReason,proto3" json:"denied_reason,omitempty"`
+ // peer_group_ids carries the calling user's group memberships so the
+ // proxy can authorise policy-aware middlewares without an additional
+ // management round-trip.
+ PeerGroupIds []string `protobuf:"bytes,5,rep,name=peer_group_ids,json=peerGroupIds,proto3" json:"peer_group_ids,omitempty"`
+ // peer_group_names carries the human-readable display names for the
+ // ids in peer_group_ids, ordered identically (positional pairing).
+ // Stamped onto upstream requests as X-NetBird-Groups so downstream
+ // services can read names rather than opaque ids.
+ PeerGroupNames []string `protobuf:"bytes,6,rep,name=peer_group_names,json=peerGroupNames,proto3" json:"peer_group_names,omitempty"`
}
func (x *ValidateSessionResponse) Reset() {
*x = ValidateSessionResponse{}
if protoimpl.UnsafeEnabled {
- mi := &file_proxy_service_proto_msgTypes[24]
+ mi := &file_proxy_service_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1926,7 +2060,7 @@ func (x *ValidateSessionResponse) String() string {
func (*ValidateSessionResponse) ProtoMessage() {}
func (x *ValidateSessionResponse) ProtoReflect() protoreflect.Message {
- mi := &file_proxy_service_proto_msgTypes[24]
+ mi := &file_proxy_service_proto_msgTypes[25]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1939,7 +2073,7 @@ func (x *ValidateSessionResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ValidateSessionResponse.ProtoReflect.Descriptor instead.
func (*ValidateSessionResponse) Descriptor() ([]byte, []int) {
- return file_proxy_service_proto_rawDescGZIP(), []int{24}
+ return file_proxy_service_proto_rawDescGZIP(), []int{25}
}
func (x *ValidateSessionResponse) GetValid() bool {
@@ -1970,6 +2104,456 @@ func (x *ValidateSessionResponse) GetDeniedReason() string {
return ""
}
+func (x *ValidateSessionResponse) GetPeerGroupIds() []string {
+ if x != nil {
+ return x.PeerGroupIds
+ }
+ return nil
+}
+
+func (x *ValidateSessionResponse) GetPeerGroupNames() []string {
+ if x != nil {
+ return x.PeerGroupNames
+ }
+ return nil
+}
+
+// ValidateTunnelPeerRequest carries the inbound peer's tunnel IP and the
+// service domain whose group requirements should gate access. The calling
+// account is inferred from the proxy's gRPC metadata (ProxyToken).
+type ValidateTunnelPeerRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ TunnelIp string `protobuf:"bytes,1,opt,name=tunnel_ip,json=tunnelIp,proto3" json:"tunnel_ip,omitempty"`
+ Domain string `protobuf:"bytes,2,opt,name=domain,proto3" json:"domain,omitempty"`
+}
+
+func (x *ValidateTunnelPeerRequest) Reset() {
+ *x = ValidateTunnelPeerRequest{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_proxy_service_proto_msgTypes[26]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *ValidateTunnelPeerRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ValidateTunnelPeerRequest) ProtoMessage() {}
+
+func (x *ValidateTunnelPeerRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_proxy_service_proto_msgTypes[26]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ValidateTunnelPeerRequest.ProtoReflect.Descriptor instead.
+func (*ValidateTunnelPeerRequest) Descriptor() ([]byte, []int) {
+ return file_proxy_service_proto_rawDescGZIP(), []int{26}
+}
+
+func (x *ValidateTunnelPeerRequest) GetTunnelIp() string {
+ if x != nil {
+ return x.TunnelIp
+ }
+ return ""
+}
+
+func (x *ValidateTunnelPeerRequest) GetDomain() string {
+ if x != nil {
+ return x.Domain
+ }
+ return ""
+}
+
+// ValidateTunnelPeerResponse mirrors ValidateSessionResponse plus a freshly
+// minted session_token: when valid is true, the proxy installs the token as
+// a session cookie so subsequent requests skip the management round-trip,
+// matching the OIDC flow's UX. denied_reason values:
+//
+// "peer_not_found" — no peer with that tunnel IP in the calling account
+// "no_user" — peer exists but is not bound to a user
+// "service_not_found"
+// "account_mismatch"
+// "not_in_group" — peer resolved but not in service.access_groups
+type ValidateTunnelPeerResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"`
+ UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
+ UserEmail string `protobuf:"bytes,3,opt,name=user_email,json=userEmail,proto3" json:"user_email,omitempty"`
+ DeniedReason string `protobuf:"bytes,4,opt,name=denied_reason,json=deniedReason,proto3" json:"denied_reason,omitempty"`
+ // session_token is set only when valid is true. Same shape as the JWT
+ // the OIDC flow produces — proxy installs it via setSessionCookie so the
+ // tunnel fast-path is indistinguishable from OIDC for subsequent requests.
+ SessionToken string `protobuf:"bytes,5,opt,name=session_token,json=sessionToken,proto3" json:"session_token,omitempty"`
+ // peer_group_ids carries the resolved peer's user group memberships so
+ // the proxy can authorise policy-aware middlewares without an additional
+ // management round-trip.
+ PeerGroupIds []string `protobuf:"bytes,6,rep,name=peer_group_ids,json=peerGroupIds,proto3" json:"peer_group_ids,omitempty"`
+ // peer_group_names carries the human-readable display names for the
+ // ids in peer_group_ids, ordered identically (positional pairing).
+ // Stamped onto upstream requests as X-NetBird-Groups so downstream
+ // services can read names rather than opaque ids.
+ PeerGroupNames []string `protobuf:"bytes,7,rep,name=peer_group_names,json=peerGroupNames,proto3" json:"peer_group_names,omitempty"`
+}
+
+func (x *ValidateTunnelPeerResponse) Reset() {
+ *x = ValidateTunnelPeerResponse{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_proxy_service_proto_msgTypes[27]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *ValidateTunnelPeerResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ValidateTunnelPeerResponse) ProtoMessage() {}
+
+func (x *ValidateTunnelPeerResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_proxy_service_proto_msgTypes[27]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ValidateTunnelPeerResponse.ProtoReflect.Descriptor instead.
+func (*ValidateTunnelPeerResponse) Descriptor() ([]byte, []int) {
+ return file_proxy_service_proto_rawDescGZIP(), []int{27}
+}
+
+func (x *ValidateTunnelPeerResponse) GetValid() bool {
+ if x != nil {
+ return x.Valid
+ }
+ return false
+}
+
+func (x *ValidateTunnelPeerResponse) GetUserId() string {
+ if x != nil {
+ return x.UserId
+ }
+ return ""
+}
+
+func (x *ValidateTunnelPeerResponse) GetUserEmail() string {
+ if x != nil {
+ return x.UserEmail
+ }
+ return ""
+}
+
+func (x *ValidateTunnelPeerResponse) GetDeniedReason() string {
+ if x != nil {
+ return x.DeniedReason
+ }
+ return ""
+}
+
+func (x *ValidateTunnelPeerResponse) GetSessionToken() string {
+ if x != nil {
+ return x.SessionToken
+ }
+ return ""
+}
+
+func (x *ValidateTunnelPeerResponse) GetPeerGroupIds() []string {
+ if x != nil {
+ return x.PeerGroupIds
+ }
+ return nil
+}
+
+func (x *ValidateTunnelPeerResponse) GetPeerGroupNames() []string {
+ if x != nil {
+ return x.PeerGroupNames
+ }
+ return nil
+}
+
+// SyncMappingsRequest is sent by the proxy on the bidirectional SyncMappings
+// stream. The first message MUST be an init; all subsequent messages MUST be
+// acks.
+type SyncMappingsRequest struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ // Types that are assignable to Msg:
+ //
+ // *SyncMappingsRequest_Init
+ // *SyncMappingsRequest_Ack
+ Msg isSyncMappingsRequest_Msg `protobuf_oneof:"msg"`
+}
+
+func (x *SyncMappingsRequest) Reset() {
+ *x = SyncMappingsRequest{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_proxy_service_proto_msgTypes[28]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *SyncMappingsRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SyncMappingsRequest) ProtoMessage() {}
+
+func (x *SyncMappingsRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_proxy_service_proto_msgTypes[28]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SyncMappingsRequest.ProtoReflect.Descriptor instead.
+func (*SyncMappingsRequest) Descriptor() ([]byte, []int) {
+ return file_proxy_service_proto_rawDescGZIP(), []int{28}
+}
+
+func (m *SyncMappingsRequest) GetMsg() isSyncMappingsRequest_Msg {
+ if m != nil {
+ return m.Msg
+ }
+ return nil
+}
+
+func (x *SyncMappingsRequest) GetInit() *SyncMappingsInit {
+ if x, ok := x.GetMsg().(*SyncMappingsRequest_Init); ok {
+ return x.Init
+ }
+ return nil
+}
+
+func (x *SyncMappingsRequest) GetAck() *SyncMappingsAck {
+ if x, ok := x.GetMsg().(*SyncMappingsRequest_Ack); ok {
+ return x.Ack
+ }
+ return nil
+}
+
+type isSyncMappingsRequest_Msg interface {
+ isSyncMappingsRequest_Msg()
+}
+
+type SyncMappingsRequest_Init struct {
+ Init *SyncMappingsInit `protobuf:"bytes,1,opt,name=init,proto3,oneof"`
+}
+
+type SyncMappingsRequest_Ack struct {
+ Ack *SyncMappingsAck `protobuf:"bytes,2,opt,name=ack,proto3,oneof"`
+}
+
+func (*SyncMappingsRequest_Init) isSyncMappingsRequest_Msg() {}
+
+func (*SyncMappingsRequest_Ack) isSyncMappingsRequest_Msg() {}
+
+// SyncMappingsInit is the first message on the stream, carrying the same
+// identification fields as GetMappingUpdateRequest.
+type SyncMappingsInit struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ ProxyId string `protobuf:"bytes,1,opt,name=proxy_id,json=proxyId,proto3" json:"proxy_id,omitempty"`
+ Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"`
+ StartedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"`
+ Address string `protobuf:"bytes,4,opt,name=address,proto3" json:"address,omitempty"`
+ Capabilities *ProxyCapabilities `protobuf:"bytes,5,opt,name=capabilities,proto3" json:"capabilities,omitempty"`
+}
+
+func (x *SyncMappingsInit) Reset() {
+ *x = SyncMappingsInit{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_proxy_service_proto_msgTypes[29]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *SyncMappingsInit) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SyncMappingsInit) ProtoMessage() {}
+
+func (x *SyncMappingsInit) ProtoReflect() protoreflect.Message {
+ mi := &file_proxy_service_proto_msgTypes[29]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SyncMappingsInit.ProtoReflect.Descriptor instead.
+func (*SyncMappingsInit) Descriptor() ([]byte, []int) {
+ return file_proxy_service_proto_rawDescGZIP(), []int{29}
+}
+
+func (x *SyncMappingsInit) GetProxyId() string {
+ if x != nil {
+ return x.ProxyId
+ }
+ return ""
+}
+
+func (x *SyncMappingsInit) GetVersion() string {
+ if x != nil {
+ return x.Version
+ }
+ return ""
+}
+
+func (x *SyncMappingsInit) GetStartedAt() *timestamppb.Timestamp {
+ if x != nil {
+ return x.StartedAt
+ }
+ return nil
+}
+
+func (x *SyncMappingsInit) GetAddress() string {
+ if x != nil {
+ return x.Address
+ }
+ return ""
+}
+
+func (x *SyncMappingsInit) GetCapabilities() *ProxyCapabilities {
+ if x != nil {
+ return x.Capabilities
+ }
+ return nil
+}
+
+// SyncMappingsAck is sent by the proxy after it has fully processed a batch.
+// Management waits for this before sending the next batch.
+type SyncMappingsAck struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+}
+
+func (x *SyncMappingsAck) Reset() {
+ *x = SyncMappingsAck{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_proxy_service_proto_msgTypes[30]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *SyncMappingsAck) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SyncMappingsAck) ProtoMessage() {}
+
+func (x *SyncMappingsAck) ProtoReflect() protoreflect.Message {
+ mi := &file_proxy_service_proto_msgTypes[30]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SyncMappingsAck.ProtoReflect.Descriptor instead.
+func (*SyncMappingsAck) Descriptor() ([]byte, []int) {
+ return file_proxy_service_proto_rawDescGZIP(), []int{30}
+}
+
+// SyncMappingsResponse is a batch of mappings sent by management.
+// Identical semantics to GetMappingUpdateResponse.
+type SyncMappingsResponse struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Mapping []*ProxyMapping `protobuf:"bytes,1,rep,name=mapping,proto3" json:"mapping,omitempty"`
+ // initial_sync_complete is set on the last message of the initial snapshot.
+ InitialSyncComplete bool `protobuf:"varint,2,opt,name=initial_sync_complete,json=initialSyncComplete,proto3" json:"initial_sync_complete,omitempty"`
+}
+
+func (x *SyncMappingsResponse) Reset() {
+ *x = SyncMappingsResponse{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_proxy_service_proto_msgTypes[31]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *SyncMappingsResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SyncMappingsResponse) ProtoMessage() {}
+
+func (x *SyncMappingsResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_proxy_service_proto_msgTypes[31]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SyncMappingsResponse.ProtoReflect.Descriptor instead.
+func (*SyncMappingsResponse) Descriptor() ([]byte, []int) {
+ return file_proxy_service_proto_rawDescGZIP(), []int{31}
+}
+
+func (x *SyncMappingsResponse) GetMapping() []*ProxyMapping {
+ if x != nil {
+ return x.Mapping
+ }
+ return nil
+}
+
+func (x *SyncMappingsResponse) GetInitialSyncComplete() bool {
+ if x != nil {
+ return x.InitialSyncComplete
+ }
+ return false
+}
+
var File_proxy_service_proto protoreflect.FileDescriptor
var file_proxy_service_proto_rawDesc = []byte{
@@ -1979,7 +2563,7 @@ var file_proxy_service_proto_rawDesc = []byte{
0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f,
- 0x74, 0x6f, 0x22, 0xf6, 0x01, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, 0x61,
+ 0x74, 0x6f, 0x22, 0xfd, 0x02, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, 0x61,
0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x37, 0x0a, 0x15, 0x73, 0x75, 0x70, 0x70,
0x6f, 0x72, 0x74, 0x73, 0x5f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x70, 0x6f, 0x72, 0x74,
0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x13, 0x73, 0x75, 0x70, 0x70, 0x6f,
@@ -1990,59 +2574,70 @@ var file_proxy_service_proto_rawDesc = []byte{
0x88, 0x01, 0x01, 0x12, 0x30, 0x0a, 0x11, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x5f,
0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x48, 0x02,
0x52, 0x10, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x43, 0x72, 0x6f, 0x77, 0x64, 0x73,
- 0x65, 0x63, 0x88, 0x01, 0x01, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72,
- 0x74, 0x73, 0x5f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x42,
- 0x14, 0x0a, 0x12, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x5f, 0x73, 0x75, 0x62, 0x64,
- 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72,
- 0x74, 0x73, 0x5f, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x22, 0xe6, 0x01, 0x0a, 0x17,
+ 0x65, 0x63, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65,
+ 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x48, 0x03, 0x52, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74,
+ 0x65, 0x88, 0x01, 0x01, 0x12, 0x3d, 0x0a, 0x18, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73,
+ 0x5f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
+ 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x48, 0x04, 0x52, 0x16, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72,
+ 0x74, 0x73, 0x50, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
+ 0x88, 0x01, 0x01, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73,
+ 0x5f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x73, 0x42, 0x14, 0x0a,
+ 0x12, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x5f, 0x73, 0x75, 0x62, 0x64, 0x6f, 0x6d,
+ 0x61, 0x69, 0x6e, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x73,
+ 0x5f, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x70, 0x72,
+ 0x69, 0x76, 0x61, 0x74, 0x65, 0x42, 0x1b, 0x0a, 0x19, 0x5f, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72,
+ 0x74, 0x73, 0x5f, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x69,
+ 0x63, 0x65, 0x22, 0xe6, 0x01, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e,
+ 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19,
+ 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72,
+ 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73,
+ 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61,
+ 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+ 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74,
+ 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18,
+ 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61,
+ 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d,
+ 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78,
+ 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63,
+ 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x82, 0x01, 0x0a, 0x18,
0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
- 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79,
- 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79,
- 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a,
- 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b,
- 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
- 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74,
- 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65,
- 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73,
- 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65,
- 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
- 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69,
- 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69,
- 0x74, 0x69, 0x65, 0x73, 0x22, 0x82, 0x01, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70,
- 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
- 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, 0x03,
- 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d, 0x61,
- 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c,
- 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02,
- 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, 0x6e,
- 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0xce, 0x03, 0x0a, 0x11, 0x50, 0x61,
- 0x74, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12,
- 0x26, 0x0a, 0x0f, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x74, 0x6c, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x69,
- 0x66, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x54, 0x6c,
- 0x73, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x12, 0x42, 0x0a, 0x0f, 0x72, 0x65, 0x71, 0x75, 0x65,
- 0x73, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
+ 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70,
+ 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70,
+ 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15,
+ 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d,
+ 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69,
+ 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65,
+ 0x22, 0xf7, 0x03, 0x0a, 0x11, 0x50, 0x61, 0x74, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x4f,
+ 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x74,
+ 0x6c, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
+ 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x54, 0x6c, 0x73, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x12, 0x42,
+ 0x0a, 0x0f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75,
+ 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+ 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69,
+ 0x6f, 0x6e, 0x52, 0x0e, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x6f,
+ 0x75, 0x74, 0x12, 0x3e, 0x0a, 0x0c, 0x70, 0x61, 0x74, 0x68, 0x5f, 0x72, 0x65, 0x77, 0x72, 0x69,
+ 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74,
+ 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x0b, 0x70, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69,
+ 0x74, 0x65, 0x12, 0x57, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x68, 0x65, 0x61,
+ 0x64, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x30, 0x2e, 0x6d, 0x61, 0x6e,
+ 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x54, 0x61, 0x72, 0x67,
+ 0x65, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d,
+ 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0d, 0x63, 0x75,
+ 0x73, 0x74, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x70,
+ 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x05, 0x20,
+ 0x01, 0x28, 0x08, 0x52, 0x0d, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63,
+ 0x6f, 0x6c, 0x12, 0x4b, 0x0a, 0x14, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64,
+ 0x6c, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
- 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0e, 0x72, 0x65, 0x71,
- 0x75, 0x65, 0x73, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x12, 0x3e, 0x0a, 0x0c, 0x70,
- 0x61, 0x74, 0x68, 0x5f, 0x72, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
- 0x0e, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50,
- 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x0b,
- 0x70, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x12, 0x57, 0x0a, 0x0e, 0x63,
- 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20,
- 0x03, 0x28, 0x0b, 0x32, 0x30, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
- 0x2e, 0x50, 0x61, 0x74, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f,
- 0x6e, 0x73, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73,
- 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x48, 0x65, 0x61,
- 0x64, 0x65, 0x72, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x72,
- 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x70, 0x72,
- 0x6f, 0x78, 0x79, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x4b, 0x0a, 0x14, 0x73,
- 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x6c, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65,
- 0x6f, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
- 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61,
- 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x12, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x6c,
- 0x65, 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x1a, 0x40, 0x0a, 0x12, 0x43, 0x75, 0x73, 0x74,
+ 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x12, 0x73, 0x65, 0x73,
+ 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x6c, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x12,
+ 0x27, 0x0a, 0x0f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x70, 0x73, 0x74, 0x72, 0x65,
+ 0x61, 0x6d, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74,
+ 0x55, 0x70, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x1a, 0x40, 0x0a, 0x12, 0x43, 0x75, 0x73, 0x74,
0x6f, 0x6d, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10,
0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79,
0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
@@ -2087,7 +2682,7 @@ var file_proxy_service_proto_rawDesc = []byte{
0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x72,
0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0c, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x4d, 0x6f, 0x64, 0x65, 0x22,
- 0xe6, 0x03, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67,
+ 0x80, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67,
0x12, 0x36, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22,
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78,
0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79,
@@ -2117,207 +2712,292 @@ var file_proxy_service_proto_rawDesc = []byte{
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74,
0x69, 0x6f, 0x6e, 0x73, 0x52, 0x12, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74,
- 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x3f, 0x0a, 0x14, 0x53, 0x65, 0x6e, 0x64,
- 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
- 0x12, 0x27, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73,
- 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x22, 0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e,
- 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
- 0x73, 0x65, 0x22, 0x84, 0x05, 0x0a, 0x09, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67,
- 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
- 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52,
- 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x15, 0x0a, 0x06, 0x6c, 0x6f,
- 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x6f, 0x67, 0x49,
- 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18,
- 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64,
- 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04,
- 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12,
- 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68,
- 0x6f, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74,
- 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75,
- 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68,
- 0x6f, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64,
- 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x6f, 0x64,
- 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
- 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f,
- 0x69, 0x70, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
- 0x49, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x63, 0x68, 0x61,
- 0x6e, 0x69, 0x73, 0x6d, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68,
- 0x4d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65,
- 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72,
- 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65,
- 0x73, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x53, 0x75,
- 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x75,
- 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x62, 0x79, 0x74,
- 0x65, 0x73, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65,
- 0x73, 0x5f, 0x64, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03,
- 0x52, 0x0d, 0x62, 0x79, 0x74, 0x65, 0x73, 0x44, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x12,
- 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3f, 0x0a, 0x08, 0x6d,
- 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73,
- 0x73, 0x4c, 0x6f, 0x67, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74,
- 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d,
- 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a,
- 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12,
- 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
- 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xf8, 0x01, 0x0a, 0x13, 0x41, 0x75,
- 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
- 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69,
+ 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x76,
+ 0x61, 0x74, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61,
+ 0x74, 0x65, 0x22, 0x3f, 0x0a, 0x14, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73,
+ 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6c, 0x6f,
+ 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x03,
+ 0x6c, 0x6f, 0x67, 0x22, 0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73,
+ 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x84, 0x05, 0x0a,
+ 0x09, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69,
+ 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
+ 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
+ 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73,
+ 0x74, 0x61, 0x6d, 0x70, 0x12, 0x15, 0x0a, 0x06, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61,
+ 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65,
+ 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
+ 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73,
+ 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x12, 0x0a,
+ 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74,
+ 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73,
+ 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+ 0x4d, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x08, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65,
+ 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28,
+ 0x05, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12,
+ 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, 0x0a, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, 0x25, 0x0a, 0x0e,
+ 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x18, 0x0b,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x63, 0x68, 0x61, 0x6e,
+ 0x69, 0x73, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0c,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c,
+ 0x61, 0x75, 0x74, 0x68, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x0d, 0x20, 0x01,
+ 0x28, 0x08, 0x52, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12,
+ 0x21, 0x0a, 0x0c, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18,
+ 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x62, 0x79, 0x74, 0x65, 0x73, 0x55, 0x70, 0x6c, 0x6f,
+ 0x61, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x64, 0x6f, 0x77, 0x6e,
+ 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, 0x79, 0x74, 0x65,
+ 0x73, 0x44, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f,
+ 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f,
+ 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3f, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
+ 0x61, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x2e, 0x4d,
+ 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65,
+ 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61,
+ 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c,
+ 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a,
+ 0x02, 0x38, 0x01, 0x22, 0xf8, 0x01, 0x0a, 0x13, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69,
+ 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69,
+ 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61,
+ 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x39, 0x0a, 0x08, 0x70, 0x61,
+ 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d,
+ 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f,
+ 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x08, 0x70, 0x61, 0x73,
+ 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01,
+ 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+ 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x03, 0x70, 0x69,
+ 0x6e, 0x12, 0x40, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68,
+ 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65,
+ 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41,
+ 0x75, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x57,
+ 0x0a, 0x11, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75,
+ 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x76, 0x61,
+ 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65,
+ 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72,
+ 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x68, 0x65, 0x61,
+ 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x2d, 0x0a, 0x0f, 0x50, 0x61, 0x73, 0x73, 0x77,
+ 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61,
+ 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61,
+ 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x1e, 0x0a, 0x0a, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71,
+ 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28,
+ 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x22, 0x55, 0x0a, 0x14, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e,
+ 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18,
+ 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
+ 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73,
+ 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xda, 0x02,
+ 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61,
+ 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72,
+ 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73,
+ 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f,
+ 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63,
+ 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75,
+ 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
+ 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x65, 0x72, 0x74,
+ 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x64, 0x18, 0x04,
+ 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74,
+ 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72,
+ 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00,
+ 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01,
+ 0x01, 0x12, 0x50, 0x0a, 0x10, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6c, 0x69, 0x73,
+ 0x74, 0x65, 0x6e, 0x65, 0x72, 0x18, 0x32, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e,
+ 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x48, 0x01, 0x52,
+ 0x0f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72,
+ 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65,
+ 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e,
+ 0x64, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x14, 0x50, 0x72,
+ 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e,
+ 0x65, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18,
+ 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12,
+ 0x1d, 0x0a, 0x0a, 0x68, 0x74, 0x74, 0x70, 0x73, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20,
+ 0x01, 0x28, 0x0d, 0x52, 0x09, 0x68, 0x74, 0x74, 0x70, 0x73, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1b,
+ 0x0a, 0x09, 0x68, 0x74, 0x74, 0x70, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28,
+ 0x0d, 0x52, 0x08, 0x68, 0x74, 0x74, 0x70, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x1a, 0x0a, 0x18, 0x53,
+ 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52,
+ 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x01, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61,
+ 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
+ 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64,
+ 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49,
0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64,
- 0x12, 0x39, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01,
- 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48,
- 0x00, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a, 0x03, 0x70,
- 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
- 0x48, 0x00, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x40, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65,
- 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72,
- 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x68,
- 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71,
- 0x75, 0x65, 0x73, 0x74, 0x22, 0x57, 0x0a, 0x11, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75,
- 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x68, 0x65, 0x61,
- 0x64, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
- 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b,
- 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x2d, 0x0a,
- 0x0f, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
- 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x1e, 0x0a, 0x0a,
- 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69,
- 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x22, 0x55, 0x0a, 0x14,
- 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70,
- 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18,
- 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23,
- 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18,
- 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f,
- 0x6b, 0x65, 0x6e, 0x22, 0xf3, 0x01, 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74,
- 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
- 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d,
- 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a,
- 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e,
- 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79,
- 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2d,
- 0x0a, 0x12, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x73,
- 0x73, 0x75, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65, 0x72, 0x74,
- 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, 0x28, 0x0a,
- 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05,
- 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73,
- 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f,
- 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x1a, 0x0a, 0x18, 0x53, 0x65, 0x6e,
- 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73,
- 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x01, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
- 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
- 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01,
- 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12,
- 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x14,
- 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74,
- 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72,
- 0x64, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50, 0x75, 0x62,
- 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65,
- 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
- 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50,
- 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73,
- 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75,
- 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d,
- 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c,
- 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42,
- 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
- 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52,
- 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e,
- 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f,
- 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63,
- 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64,
- 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4f,
- 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10,
- 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c,
- 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73,
- 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f,
- 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61,
- 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f,
- 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69,
- 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x8c, 0x01, 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69,
- 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
- 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65,
- 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72,
- 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c,
- 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69,
- 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73,
- 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64,
- 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d,
- 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65,
- 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f,
- 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44,
- 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45,
- 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59,
- 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, 0x2a, 0x46, 0x0a, 0x0f,
- 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x12,
- 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f,
- 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x41, 0x54,
- 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52, 0x45, 0x53, 0x45, 0x52,
- 0x56, 0x45, 0x10, 0x01, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74,
- 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54,
- 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x17,
- 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x41,
- 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59,
- 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4e,
- 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x24, 0x0a, 0x20,
- 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52,
- 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47,
- 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54,
- 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x46,
- 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x58, 0x59,
- 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x32,
- 0xfc, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
- 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70,
- 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61,
- 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e,
- 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30,
- 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c,
- 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71,
- 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
- 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52,
- 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65,
- 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
- 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74,
- 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
- 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61,
- 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65,
- 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23,
+ 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
+ 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75,
+ 0x61, 0x72, 0x64, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50,
+ 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6c, 0x75, 0x73,
+ 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74,
+ 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78,
+ 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a,
+ 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07,
+ 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72,
+ 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00,
+ 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01,
+ 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73,
+ 0x61, 0x67, 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52,
+ 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f,
+ 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63,
+ 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72,
+ 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72,
+ 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65,
+ 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+ 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75,
+ 0x72, 0x6c, 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65,
+ 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06,
+ 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f,
+ 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f,
+ 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73,
+ 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xdc, 0x01, 0x0a, 0x17, 0x56, 0x61,
+ 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73,
+ 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01,
+ 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75,
+ 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73,
+ 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61,
+ 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d,
+ 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65,
+ 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69,
+ 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72,
+ 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09,
+ 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28,
+ 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d,
+ 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72,
+ 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x50, 0x0a, 0x19, 0x56, 0x61, 0x6c, 0x69,
+ 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65,
+ 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f,
+ 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c,
+ 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x84, 0x02, 0x0a, 0x1a, 0x56,
+ 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65,
+ 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c,
+ 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12,
+ 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72,
+ 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73,
+ 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65,
+ 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c,
+ 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d,
+ 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65,
+ 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f,
+ 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47,
+ 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f,
+ 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28,
+ 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65,
+ 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e,
+ 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x04, 0x69, 0x6e, 0x69,
+ 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67,
+ 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x12, 0x2f, 0x0a,
+ 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e,
+ 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70,
+ 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x05,
+ 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61,
+ 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x70, 0x72,
+ 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72,
+ 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
+ 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12,
+ 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20,
+ 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
+ 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52,
+ 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64,
+ 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64,
+ 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69,
+ 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e,
+ 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70,
+ 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62,
+ 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d,
+ 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x22, 0x7e, 0x0a, 0x14, 0x53, 0x79,
+ 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+ 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20,
+ 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+ 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d,
+ 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61,
+ 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18,
+ 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79,
+ 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72,
+ 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
+ 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54,
+ 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a,
+ 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44,
+ 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54,
+ 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02,
+ 0x2a, 0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d,
+ 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52,
+ 0x49, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a,
+ 0x15, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52,
+ 0x45, 0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f,
+ 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58,
+ 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47,
+ 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54,
+ 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50,
+ 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e,
+ 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02,
+ 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53,
+ 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e,
+ 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f,
+ 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41,
+ 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50,
+ 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f,
+ 0x52, 0x10, 0x05, 0x32, 0xb8, 0x06, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72,
+ 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69,
+ 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+ 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67,
+ 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e,
+ 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61,
+ 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f,
+ 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70,
+ 0x70, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
+ 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52,
+ 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73,
+ 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d,
+ 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e,
+ 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41,
+ 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
+ 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e,
+ 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+ 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61,
+ 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
+ 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75,
+ 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
+ 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73,
+ 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61,
+ 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
+ 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75,
+ 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24,
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64,
- 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75,
- 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
- 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74,
- 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65,
- 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d,
- 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
- 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
- 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72,
- 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73,
- 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43,
- 0x55, 0x52, 0x4c, 0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
- 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65,
- 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
- 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
- 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65,
- 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
- 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69,
- 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
- 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53,
- 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08,
+ 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70,
+ 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72,
+ 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+ 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79,
+ 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61,
+ 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50,
+ 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+ 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d,
+ 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f,
+ 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e,
+ 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49,
+ 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a,
+ 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
+ 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61,
+ 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71,
+ 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
+ 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f,
+ 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x12, 0x56, 0x61, 0x6c,
+ 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x12,
+ 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c,
+ 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52,
+ 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
+ 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e,
+ 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08,
0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
@@ -2334,49 +3014,56 @@ func file_proxy_service_proto_rawDescGZIP() []byte {
}
var file_proxy_service_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
-var file_proxy_service_proto_msgTypes = make([]protoimpl.MessageInfo, 27)
+var file_proxy_service_proto_msgTypes = make([]protoimpl.MessageInfo, 34)
var file_proxy_service_proto_goTypes = []interface{}{
- (ProxyMappingUpdateType)(0), // 0: management.ProxyMappingUpdateType
- (PathRewriteMode)(0), // 1: management.PathRewriteMode
- (ProxyStatus)(0), // 2: management.ProxyStatus
- (*ProxyCapabilities)(nil), // 3: management.ProxyCapabilities
- (*GetMappingUpdateRequest)(nil), // 4: management.GetMappingUpdateRequest
- (*GetMappingUpdateResponse)(nil), // 5: management.GetMappingUpdateResponse
- (*PathTargetOptions)(nil), // 6: management.PathTargetOptions
- (*PathMapping)(nil), // 7: management.PathMapping
- (*HeaderAuth)(nil), // 8: management.HeaderAuth
- (*Authentication)(nil), // 9: management.Authentication
- (*AccessRestrictions)(nil), // 10: management.AccessRestrictions
- (*ProxyMapping)(nil), // 11: management.ProxyMapping
- (*SendAccessLogRequest)(nil), // 12: management.SendAccessLogRequest
- (*SendAccessLogResponse)(nil), // 13: management.SendAccessLogResponse
- (*AccessLog)(nil), // 14: management.AccessLog
- (*AuthenticateRequest)(nil), // 15: management.AuthenticateRequest
- (*HeaderAuthRequest)(nil), // 16: management.HeaderAuthRequest
- (*PasswordRequest)(nil), // 17: management.PasswordRequest
- (*PinRequest)(nil), // 18: management.PinRequest
- (*AuthenticateResponse)(nil), // 19: management.AuthenticateResponse
- (*SendStatusUpdateRequest)(nil), // 20: management.SendStatusUpdateRequest
- (*SendStatusUpdateResponse)(nil), // 21: management.SendStatusUpdateResponse
- (*CreateProxyPeerRequest)(nil), // 22: management.CreateProxyPeerRequest
- (*CreateProxyPeerResponse)(nil), // 23: management.CreateProxyPeerResponse
- (*GetOIDCURLRequest)(nil), // 24: management.GetOIDCURLRequest
- (*GetOIDCURLResponse)(nil), // 25: management.GetOIDCURLResponse
- (*ValidateSessionRequest)(nil), // 26: management.ValidateSessionRequest
- (*ValidateSessionResponse)(nil), // 27: management.ValidateSessionResponse
- nil, // 28: management.PathTargetOptions.CustomHeadersEntry
- nil, // 29: management.AccessLog.MetadataEntry
- (*timestamppb.Timestamp)(nil), // 30: google.protobuf.Timestamp
- (*durationpb.Duration)(nil), // 31: google.protobuf.Duration
+ (ProxyMappingUpdateType)(0), // 0: management.ProxyMappingUpdateType
+ (PathRewriteMode)(0), // 1: management.PathRewriteMode
+ (ProxyStatus)(0), // 2: management.ProxyStatus
+ (*ProxyCapabilities)(nil), // 3: management.ProxyCapabilities
+ (*GetMappingUpdateRequest)(nil), // 4: management.GetMappingUpdateRequest
+ (*GetMappingUpdateResponse)(nil), // 5: management.GetMappingUpdateResponse
+ (*PathTargetOptions)(nil), // 6: management.PathTargetOptions
+ (*PathMapping)(nil), // 7: management.PathMapping
+ (*HeaderAuth)(nil), // 8: management.HeaderAuth
+ (*Authentication)(nil), // 9: management.Authentication
+ (*AccessRestrictions)(nil), // 10: management.AccessRestrictions
+ (*ProxyMapping)(nil), // 11: management.ProxyMapping
+ (*SendAccessLogRequest)(nil), // 12: management.SendAccessLogRequest
+ (*SendAccessLogResponse)(nil), // 13: management.SendAccessLogResponse
+ (*AccessLog)(nil), // 14: management.AccessLog
+ (*AuthenticateRequest)(nil), // 15: management.AuthenticateRequest
+ (*HeaderAuthRequest)(nil), // 16: management.HeaderAuthRequest
+ (*PasswordRequest)(nil), // 17: management.PasswordRequest
+ (*PinRequest)(nil), // 18: management.PinRequest
+ (*AuthenticateResponse)(nil), // 19: management.AuthenticateResponse
+ (*SendStatusUpdateRequest)(nil), // 20: management.SendStatusUpdateRequest
+ (*ProxyInboundListener)(nil), // 21: management.ProxyInboundListener
+ (*SendStatusUpdateResponse)(nil), // 22: management.SendStatusUpdateResponse
+ (*CreateProxyPeerRequest)(nil), // 23: management.CreateProxyPeerRequest
+ (*CreateProxyPeerResponse)(nil), // 24: management.CreateProxyPeerResponse
+ (*GetOIDCURLRequest)(nil), // 25: management.GetOIDCURLRequest
+ (*GetOIDCURLResponse)(nil), // 26: management.GetOIDCURLResponse
+ (*ValidateSessionRequest)(nil), // 27: management.ValidateSessionRequest
+ (*ValidateSessionResponse)(nil), // 28: management.ValidateSessionResponse
+ (*ValidateTunnelPeerRequest)(nil), // 29: management.ValidateTunnelPeerRequest
+ (*ValidateTunnelPeerResponse)(nil), // 30: management.ValidateTunnelPeerResponse
+ (*SyncMappingsRequest)(nil), // 31: management.SyncMappingsRequest
+ (*SyncMappingsInit)(nil), // 32: management.SyncMappingsInit
+ (*SyncMappingsAck)(nil), // 33: management.SyncMappingsAck
+ (*SyncMappingsResponse)(nil), // 34: management.SyncMappingsResponse
+ nil, // 35: management.PathTargetOptions.CustomHeadersEntry
+ nil, // 36: management.AccessLog.MetadataEntry
+ (*timestamppb.Timestamp)(nil), // 37: google.protobuf.Timestamp
+ (*durationpb.Duration)(nil), // 38: google.protobuf.Duration
}
var file_proxy_service_proto_depIdxs = []int32{
- 30, // 0: management.GetMappingUpdateRequest.started_at:type_name -> google.protobuf.Timestamp
+ 37, // 0: management.GetMappingUpdateRequest.started_at:type_name -> google.protobuf.Timestamp
3, // 1: management.GetMappingUpdateRequest.capabilities:type_name -> management.ProxyCapabilities
11, // 2: management.GetMappingUpdateResponse.mapping:type_name -> management.ProxyMapping
- 31, // 3: management.PathTargetOptions.request_timeout:type_name -> google.protobuf.Duration
+ 38, // 3: management.PathTargetOptions.request_timeout:type_name -> google.protobuf.Duration
1, // 4: management.PathTargetOptions.path_rewrite:type_name -> management.PathRewriteMode
- 28, // 5: management.PathTargetOptions.custom_headers:type_name -> management.PathTargetOptions.CustomHeadersEntry
- 31, // 6: management.PathTargetOptions.session_idle_timeout:type_name -> google.protobuf.Duration
+ 35, // 5: management.PathTargetOptions.custom_headers:type_name -> management.PathTargetOptions.CustomHeadersEntry
+ 38, // 6: management.PathTargetOptions.session_idle_timeout:type_name -> google.protobuf.Duration
6, // 7: management.PathMapping.options:type_name -> management.PathTargetOptions
8, // 8: management.Authentication.header_auths:type_name -> management.HeaderAuth
0, // 9: management.ProxyMapping.type:type_name -> management.ProxyMappingUpdateType
@@ -2384,31 +3071,41 @@ var file_proxy_service_proto_depIdxs = []int32{
9, // 11: management.ProxyMapping.auth:type_name -> management.Authentication
10, // 12: management.ProxyMapping.access_restrictions:type_name -> management.AccessRestrictions
14, // 13: management.SendAccessLogRequest.log:type_name -> management.AccessLog
- 30, // 14: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp
- 29, // 15: management.AccessLog.metadata:type_name -> management.AccessLog.MetadataEntry
+ 37, // 14: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp
+ 36, // 15: management.AccessLog.metadata:type_name -> management.AccessLog.MetadataEntry
17, // 16: management.AuthenticateRequest.password:type_name -> management.PasswordRequest
18, // 17: management.AuthenticateRequest.pin:type_name -> management.PinRequest
16, // 18: management.AuthenticateRequest.header_auth:type_name -> management.HeaderAuthRequest
2, // 19: management.SendStatusUpdateRequest.status:type_name -> management.ProxyStatus
- 4, // 20: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest
- 12, // 21: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest
- 15, // 22: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest
- 20, // 23: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest
- 22, // 24: management.ProxyService.CreateProxyPeer:input_type -> management.CreateProxyPeerRequest
- 24, // 25: management.ProxyService.GetOIDCURL:input_type -> management.GetOIDCURLRequest
- 26, // 26: management.ProxyService.ValidateSession:input_type -> management.ValidateSessionRequest
- 5, // 27: management.ProxyService.GetMappingUpdate:output_type -> management.GetMappingUpdateResponse
- 13, // 28: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse
- 19, // 29: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse
- 21, // 30: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse
- 23, // 31: management.ProxyService.CreateProxyPeer:output_type -> management.CreateProxyPeerResponse
- 25, // 32: management.ProxyService.GetOIDCURL:output_type -> management.GetOIDCURLResponse
- 27, // 33: management.ProxyService.ValidateSession:output_type -> management.ValidateSessionResponse
- 27, // [27:34] is the sub-list for method output_type
- 20, // [20:27] is the sub-list for method input_type
- 20, // [20:20] is the sub-list for extension type_name
- 20, // [20:20] is the sub-list for extension extendee
- 0, // [0:20] is the sub-list for field type_name
+ 21, // 20: management.SendStatusUpdateRequest.inbound_listener:type_name -> management.ProxyInboundListener
+ 32, // 21: management.SyncMappingsRequest.init:type_name -> management.SyncMappingsInit
+ 33, // 22: management.SyncMappingsRequest.ack:type_name -> management.SyncMappingsAck
+ 37, // 23: management.SyncMappingsInit.started_at:type_name -> google.protobuf.Timestamp
+ 3, // 24: management.SyncMappingsInit.capabilities:type_name -> management.ProxyCapabilities
+ 11, // 25: management.SyncMappingsResponse.mapping:type_name -> management.ProxyMapping
+ 4, // 26: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest
+ 31, // 27: management.ProxyService.SyncMappings:input_type -> management.SyncMappingsRequest
+ 12, // 28: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest
+ 15, // 29: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest
+ 20, // 30: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest
+ 23, // 31: management.ProxyService.CreateProxyPeer:input_type -> management.CreateProxyPeerRequest
+ 25, // 32: management.ProxyService.GetOIDCURL:input_type -> management.GetOIDCURLRequest
+ 27, // 33: management.ProxyService.ValidateSession:input_type -> management.ValidateSessionRequest
+ 29, // 34: management.ProxyService.ValidateTunnelPeer:input_type -> management.ValidateTunnelPeerRequest
+ 5, // 35: management.ProxyService.GetMappingUpdate:output_type -> management.GetMappingUpdateResponse
+ 34, // 36: management.ProxyService.SyncMappings:output_type -> management.SyncMappingsResponse
+ 13, // 37: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse
+ 19, // 38: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse
+ 22, // 39: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse
+ 24, // 40: management.ProxyService.CreateProxyPeer:output_type -> management.CreateProxyPeerResponse
+ 26, // 41: management.ProxyService.GetOIDCURL:output_type -> management.GetOIDCURLResponse
+ 28, // 42: management.ProxyService.ValidateSession:output_type -> management.ValidateSessionResponse
+ 30, // 43: management.ProxyService.ValidateTunnelPeer:output_type -> management.ValidateTunnelPeerResponse
+ 35, // [35:44] is the sub-list for method output_type
+ 26, // [26:35] is the sub-list for method input_type
+ 26, // [26:26] is the sub-list for extension type_name
+ 26, // [26:26] is the sub-list for extension extendee
+ 0, // [0:26] is the sub-list for field type_name
}
func init() { file_proxy_service_proto_init() }
@@ -2634,7 +3331,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*SendStatusUpdateResponse); i {
+ switch v := v.(*ProxyInboundListener); i {
case 0:
return &v.state
case 1:
@@ -2646,7 +3343,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*CreateProxyPeerRequest); i {
+ switch v := v.(*SendStatusUpdateResponse); i {
case 0:
return &v.state
case 1:
@@ -2658,7 +3355,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*CreateProxyPeerResponse); i {
+ switch v := v.(*CreateProxyPeerRequest); i {
case 0:
return &v.state
case 1:
@@ -2670,7 +3367,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*GetOIDCURLRequest); i {
+ switch v := v.(*CreateProxyPeerResponse); i {
case 0:
return &v.state
case 1:
@@ -2682,7 +3379,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*GetOIDCURLResponse); i {
+ switch v := v.(*GetOIDCURLRequest); i {
case 0:
return &v.state
case 1:
@@ -2694,7 +3391,7 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} {
- switch v := v.(*ValidateSessionRequest); i {
+ switch v := v.(*GetOIDCURLResponse); i {
case 0:
return &v.state
case 1:
@@ -2706,6 +3403,18 @@ func file_proxy_service_proto_init() {
}
}
file_proxy_service_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*ValidateSessionRequest); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_proxy_service_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*ValidateSessionResponse); i {
case 0:
return &v.state
@@ -2717,6 +3426,78 @@ func file_proxy_service_proto_init() {
return nil
}
}
+ file_proxy_service_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*ValidateTunnelPeerRequest); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_proxy_service_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*ValidateTunnelPeerResponse); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_proxy_service_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*SyncMappingsRequest); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_proxy_service_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*SyncMappingsInit); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_proxy_service_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*SyncMappingsAck); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_proxy_service_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*SyncMappingsResponse); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
}
file_proxy_service_proto_msgTypes[0].OneofWrappers = []interface{}{}
file_proxy_service_proto_msgTypes[12].OneofWrappers = []interface{}{
@@ -2725,14 +3506,18 @@ func file_proxy_service_proto_init() {
(*AuthenticateRequest_HeaderAuth)(nil),
}
file_proxy_service_proto_msgTypes[17].OneofWrappers = []interface{}{}
- file_proxy_service_proto_msgTypes[20].OneofWrappers = []interface{}{}
+ file_proxy_service_proto_msgTypes[21].OneofWrappers = []interface{}{}
+ file_proxy_service_proto_msgTypes[28].OneofWrappers = []interface{}{
+ (*SyncMappingsRequest_Init)(nil),
+ (*SyncMappingsRequest_Ack)(nil),
+ }
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_proxy_service_proto_rawDesc,
NumEnums: 3,
- NumMessages: 27,
+ NumMessages: 34,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/shared/management/proto/proxy_service.proto b/shared/management/proto/proxy_service.proto
index e359f0cbd..14d188877 100644
--- a/shared/management/proto/proxy_service.proto
+++ b/shared/management/proto/proxy_service.proto
@@ -12,6 +12,15 @@ import "google/protobuf/timestamp.proto";
service ProxyService {
rpc GetMappingUpdate(GetMappingUpdateRequest) returns (stream GetMappingUpdateResponse);
+ // SyncMappings is a bidirectional stream that replaces GetMappingUpdate for
+ // new proxies. The proxy sends an initial SyncMappingsRequest to start the
+ // stream and then sends an ack after each batch is fully processed.
+ // Management waits for the ack before sending the next batch, providing
+ // application-level back-pressure during large initial syncs.
+ // Old proxies continue using GetMappingUpdate; old management servers
+ // return Unimplemented for this RPC and proxies fall back.
+ rpc SyncMappings(stream SyncMappingsRequest) returns (stream SyncMappingsResponse);
+
rpc SendAccessLog(SendAccessLogRequest) returns (SendAccessLogResponse);
rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse);
@@ -25,6 +34,15 @@ service ProxyService {
// ValidateSession validates a session token and checks user access permissions.
// Called by the proxy after receiving a session token from OIDC callback.
rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse);
+
+ // ValidateTunnelPeer resolves an inbound peer by its WireGuard tunnel IP and
+ // checks the resolved user's access against the service's access_groups.
+ // Acts as a fast-path equivalent of OIDC for requests originating on the
+ // netbird mesh: when the source IP maps to a known peer in the calling
+ // account and that peer is in the service's access_groups, the proxy can
+ // issue a session cookie without redirecting through the OIDC flow.
+ // Mirrors ValidateSession's response shape.
+ rpc ValidateTunnelPeer(ValidateTunnelPeerRequest) returns (ValidateTunnelPeerResponse);
}
// ProxyCapabilities describes what a proxy can handle.
@@ -36,6 +54,13 @@ message ProxyCapabilities {
optional bool require_subdomain = 2;
// Whether the proxy has CrowdSec configured and can enforce IP reputation checks.
optional bool supports_crowdsec = 3;
+ // Whether the proxy is running embedded in the netbird client and serving
+ // exclusively over the WireGuard tunnel (i.e. `netbird proxy` rather than
+ // the standalone netbird-proxy binary). Surfaces upstream so dashboards can
+ // distinguish per-peer / private clusters from centralised ones.
+ optional bool private = 4;
+ // Whether the proxy enforces ProxyMapping.private (fails closed on ValidateTunnelPeer failure). Management MUST NOT stream private mappings to proxies that don't claim this.
+ optional bool supports_private_service = 5;
}
// GetMappingUpdateRequest is sent to initialise a mapping stream.
@@ -77,6 +102,11 @@ message PathTargetOptions {
bool proxy_protocol = 5;
// Idle timeout before a UDP session is reaped.
google.protobuf.Duration session_idle_timeout = 6;
+ // When true, the proxy dials this target via the host's network stack
+ // instead of through the embedded NetBird client. Useful for upstreams
+ // reachable without WireGuard (public APIs, LAN services, localhost
+ // sidecars). Defaults to false — embedded client is the standard path.
+ bool direct_upstream = 7;
}
message PathMapping {
@@ -129,6 +159,8 @@ message ProxyMapping {
// For L4/TLS: the port the proxy listens on.
int32 listen_port = 11;
AccessRestrictions access_restrictions = 12;
+ // NetBird-only: the proxy MUST call ValidateTunnelPeer and fail closed; operator auth schemes are bypassed.
+ bool private = 13;
}
// SendAccessLogRequest consists of one or more AccessLogs from a Proxy.
@@ -204,6 +236,25 @@ message SendStatusUpdateRequest {
ProxyStatus status = 3;
bool certificate_issued = 4;
optional string error_message = 5;
+ // Per-account inbound listener state for the account that owns
+ // service_id. Populated only when --private is enabled and the
+ // embedded client for the account is up. Field numbers >=50 reserved
+ // for observability extensions.
+ optional ProxyInboundListener inbound_listener = 50;
+}
+
+// ProxyInboundListener describes a per-account inbound listener that the
+// proxy has bound on the embedded netstack of the account's WireGuard
+// client. Surfaced so dashboards can render "this account is reachable
+// at : on this proxy".
+message ProxyInboundListener {
+ // Tunnel IP the embedded netstack listens on. Same address other peers
+ // in the account see for the proxy peer.
+ string tunnel_ip = 1;
+ // TLS port served on tunnel_ip (auto-detected, default 443).
+ uint32 https_port = 2;
+ // Plain-HTTP port served on tunnel_ip (auto-detected, default 80).
+ uint32 http_port = 3;
}
// SendStatusUpdateResponse is intentionally empty to allow for future expansion
@@ -245,4 +296,83 @@ message ValidateSessionResponse {
string user_id = 2;
string user_email = 3;
string denied_reason = 4;
+ // peer_group_ids carries the calling user's group memberships so the
+ // proxy can authorise policy-aware middlewares without an additional
+ // management round-trip.
+ repeated string peer_group_ids = 5;
+ // peer_group_names carries the human-readable display names for the
+ // ids in peer_group_ids, ordered identically (positional pairing).
+ // Stamped onto upstream requests as X-NetBird-Groups so downstream
+ // services can read names rather than opaque ids.
+ repeated string peer_group_names = 6;
}
+
+// ValidateTunnelPeerRequest carries the inbound peer's tunnel IP and the
+// service domain whose group requirements should gate access. The calling
+// account is inferred from the proxy's gRPC metadata (ProxyToken).
+message ValidateTunnelPeerRequest {
+ string tunnel_ip = 1;
+ string domain = 2;
+}
+
+// ValidateTunnelPeerResponse mirrors ValidateSessionResponse plus a freshly
+// minted session_token: when valid is true, the proxy installs the token as
+// a session cookie so subsequent requests skip the management round-trip,
+// matching the OIDC flow's UX. denied_reason values:
+// "peer_not_found" — no peer with that tunnel IP in the calling account
+// "no_user" — peer exists but is not bound to a user
+// "service_not_found"
+// "account_mismatch"
+// "not_in_group" — peer resolved but not in service.access_groups
+message ValidateTunnelPeerResponse {
+ bool valid = 1;
+ string user_id = 2;
+ string user_email = 3;
+ string denied_reason = 4;
+ // session_token is set only when valid is true. Same shape as the JWT
+ // the OIDC flow produces — proxy installs it via setSessionCookie so the
+ // tunnel fast-path is indistinguishable from OIDC for subsequent requests.
+ string session_token = 5;
+ // peer_group_ids carries the resolved peer's user group memberships so
+ // the proxy can authorise policy-aware middlewares without an additional
+ // management round-trip.
+ repeated string peer_group_ids = 6;
+ // peer_group_names carries the human-readable display names for the
+ // ids in peer_group_ids, ordered identically (positional pairing).
+ // Stamped onto upstream requests as X-NetBird-Groups so downstream
+ // services can read names rather than opaque ids.
+ repeated string peer_group_names = 7;
+}
+
+// SyncMappingsRequest is sent by the proxy on the bidirectional SyncMappings
+// stream. The first message MUST be an init; all subsequent messages MUST be
+// acks.
+message SyncMappingsRequest {
+ oneof msg {
+ SyncMappingsInit init = 1;
+ SyncMappingsAck ack = 2;
+ }
+}
+
+// SyncMappingsInit is the first message on the stream, carrying the same
+// identification fields as GetMappingUpdateRequest.
+message SyncMappingsInit {
+ string proxy_id = 1;
+ string version = 2;
+ google.protobuf.Timestamp started_at = 3;
+ string address = 4;
+ ProxyCapabilities capabilities = 5;
+}
+
+// SyncMappingsAck is sent by the proxy after it has fully processed a batch.
+// Management waits for this before sending the next batch.
+message SyncMappingsAck {}
+
+// SyncMappingsResponse is a batch of mappings sent by management.
+// Identical semantics to GetMappingUpdateResponse.
+message SyncMappingsResponse {
+ repeated ProxyMapping mapping = 1;
+ // initial_sync_complete is set on the last message of the initial snapshot.
+ bool initial_sync_complete = 2;
+}
+
diff --git a/shared/management/proto/proxy_service_grpc.pb.go b/shared/management/proto/proxy_service_grpc.pb.go
index 627b217d8..40064fe61 100644
--- a/shared/management/proto/proxy_service_grpc.pb.go
+++ b/shared/management/proto/proxy_service_grpc.pb.go
@@ -19,6 +19,14 @@ const _ = grpc.SupportPackageIsVersion7
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type ProxyServiceClient interface {
GetMappingUpdate(ctx context.Context, in *GetMappingUpdateRequest, opts ...grpc.CallOption) (ProxyService_GetMappingUpdateClient, error)
+ // SyncMappings is a bidirectional stream that replaces GetMappingUpdate for
+ // new proxies. The proxy sends an initial SyncMappingsRequest to start the
+ // stream and then sends an ack after each batch is fully processed.
+ // Management waits for the ack before sending the next batch, providing
+ // application-level back-pressure during large initial syncs.
+ // Old proxies continue using GetMappingUpdate; old management servers
+ // return Unimplemented for this RPC and proxies fall back.
+ SyncMappings(ctx context.Context, opts ...grpc.CallOption) (ProxyService_SyncMappingsClient, error)
SendAccessLog(ctx context.Context, in *SendAccessLogRequest, opts ...grpc.CallOption) (*SendAccessLogResponse, error)
Authenticate(ctx context.Context, in *AuthenticateRequest, opts ...grpc.CallOption) (*AuthenticateResponse, error)
SendStatusUpdate(ctx context.Context, in *SendStatusUpdateRequest, opts ...grpc.CallOption) (*SendStatusUpdateResponse, error)
@@ -27,6 +35,14 @@ type ProxyServiceClient interface {
// ValidateSession validates a session token and checks user access permissions.
// Called by the proxy after receiving a session token from OIDC callback.
ValidateSession(ctx context.Context, in *ValidateSessionRequest, opts ...grpc.CallOption) (*ValidateSessionResponse, error)
+ // ValidateTunnelPeer resolves an inbound peer by its WireGuard tunnel IP and
+ // checks the resolved user's access against the service's access_groups.
+ // Acts as a fast-path equivalent of OIDC for requests originating on the
+ // netbird mesh: when the source IP maps to a known peer in the calling
+ // account and that peer is in the service's access_groups, the proxy can
+ // issue a session cookie without redirecting through the OIDC flow.
+ // Mirrors ValidateSession's response shape.
+ ValidateTunnelPeer(ctx context.Context, in *ValidateTunnelPeerRequest, opts ...grpc.CallOption) (*ValidateTunnelPeerResponse, error)
}
type proxyServiceClient struct {
@@ -69,6 +85,37 @@ func (x *proxyServiceGetMappingUpdateClient) Recv() (*GetMappingUpdateResponse,
return m, nil
}
+func (c *proxyServiceClient) SyncMappings(ctx context.Context, opts ...grpc.CallOption) (ProxyService_SyncMappingsClient, error) {
+ stream, err := c.cc.NewStream(ctx, &ProxyService_ServiceDesc.Streams[1], "/management.ProxyService/SyncMappings", opts...)
+ if err != nil {
+ return nil, err
+ }
+ x := &proxyServiceSyncMappingsClient{stream}
+ return x, nil
+}
+
+type ProxyService_SyncMappingsClient interface {
+ Send(*SyncMappingsRequest) error
+ Recv() (*SyncMappingsResponse, error)
+ grpc.ClientStream
+}
+
+type proxyServiceSyncMappingsClient struct {
+ grpc.ClientStream
+}
+
+func (x *proxyServiceSyncMappingsClient) Send(m *SyncMappingsRequest) error {
+ return x.ClientStream.SendMsg(m)
+}
+
+func (x *proxyServiceSyncMappingsClient) Recv() (*SyncMappingsResponse, error) {
+ m := new(SyncMappingsResponse)
+ if err := x.ClientStream.RecvMsg(m); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
func (c *proxyServiceClient) SendAccessLog(ctx context.Context, in *SendAccessLogRequest, opts ...grpc.CallOption) (*SendAccessLogResponse, error) {
out := new(SendAccessLogResponse)
err := c.cc.Invoke(ctx, "/management.ProxyService/SendAccessLog", in, out, opts...)
@@ -123,11 +170,28 @@ func (c *proxyServiceClient) ValidateSession(ctx context.Context, in *ValidateSe
return out, nil
}
+func (c *proxyServiceClient) ValidateTunnelPeer(ctx context.Context, in *ValidateTunnelPeerRequest, opts ...grpc.CallOption) (*ValidateTunnelPeerResponse, error) {
+ out := new(ValidateTunnelPeerResponse)
+ err := c.cc.Invoke(ctx, "/management.ProxyService/ValidateTunnelPeer", in, out, opts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
// ProxyServiceServer is the server API for ProxyService service.
// All implementations must embed UnimplementedProxyServiceServer
// for forward compatibility
type ProxyServiceServer interface {
GetMappingUpdate(*GetMappingUpdateRequest, ProxyService_GetMappingUpdateServer) error
+ // SyncMappings is a bidirectional stream that replaces GetMappingUpdate for
+ // new proxies. The proxy sends an initial SyncMappingsRequest to start the
+ // stream and then sends an ack after each batch is fully processed.
+ // Management waits for the ack before sending the next batch, providing
+ // application-level back-pressure during large initial syncs.
+ // Old proxies continue using GetMappingUpdate; old management servers
+ // return Unimplemented for this RPC and proxies fall back.
+ SyncMappings(ProxyService_SyncMappingsServer) error
SendAccessLog(context.Context, *SendAccessLogRequest) (*SendAccessLogResponse, error)
Authenticate(context.Context, *AuthenticateRequest) (*AuthenticateResponse, error)
SendStatusUpdate(context.Context, *SendStatusUpdateRequest) (*SendStatusUpdateResponse, error)
@@ -136,6 +200,14 @@ type ProxyServiceServer interface {
// ValidateSession validates a session token and checks user access permissions.
// Called by the proxy after receiving a session token from OIDC callback.
ValidateSession(context.Context, *ValidateSessionRequest) (*ValidateSessionResponse, error)
+ // ValidateTunnelPeer resolves an inbound peer by its WireGuard tunnel IP and
+ // checks the resolved user's access against the service's access_groups.
+ // Acts as a fast-path equivalent of OIDC for requests originating on the
+ // netbird mesh: when the source IP maps to a known peer in the calling
+ // account and that peer is in the service's access_groups, the proxy can
+ // issue a session cookie without redirecting through the OIDC flow.
+ // Mirrors ValidateSession's response shape.
+ ValidateTunnelPeer(context.Context, *ValidateTunnelPeerRequest) (*ValidateTunnelPeerResponse, error)
mustEmbedUnimplementedProxyServiceServer()
}
@@ -146,6 +218,9 @@ type UnimplementedProxyServiceServer struct {
func (UnimplementedProxyServiceServer) GetMappingUpdate(*GetMappingUpdateRequest, ProxyService_GetMappingUpdateServer) error {
return status.Errorf(codes.Unimplemented, "method GetMappingUpdate not implemented")
}
+func (UnimplementedProxyServiceServer) SyncMappings(ProxyService_SyncMappingsServer) error {
+ return status.Errorf(codes.Unimplemented, "method SyncMappings not implemented")
+}
func (UnimplementedProxyServiceServer) SendAccessLog(context.Context, *SendAccessLogRequest) (*SendAccessLogResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SendAccessLog not implemented")
}
@@ -164,6 +239,9 @@ func (UnimplementedProxyServiceServer) GetOIDCURL(context.Context, *GetOIDCURLRe
func (UnimplementedProxyServiceServer) ValidateSession(context.Context, *ValidateSessionRequest) (*ValidateSessionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ValidateSession not implemented")
}
+func (UnimplementedProxyServiceServer) ValidateTunnelPeer(context.Context, *ValidateTunnelPeerRequest) (*ValidateTunnelPeerResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method ValidateTunnelPeer not implemented")
+}
func (UnimplementedProxyServiceServer) mustEmbedUnimplementedProxyServiceServer() {}
// UnsafeProxyServiceServer may be embedded to opt out of forward compatibility for this service.
@@ -198,6 +276,32 @@ func (x *proxyServiceGetMappingUpdateServer) Send(m *GetMappingUpdateResponse) e
return x.ServerStream.SendMsg(m)
}
+func _ProxyService_SyncMappings_Handler(srv interface{}, stream grpc.ServerStream) error {
+ return srv.(ProxyServiceServer).SyncMappings(&proxyServiceSyncMappingsServer{stream})
+}
+
+type ProxyService_SyncMappingsServer interface {
+ Send(*SyncMappingsResponse) error
+ Recv() (*SyncMappingsRequest, error)
+ grpc.ServerStream
+}
+
+type proxyServiceSyncMappingsServer struct {
+ grpc.ServerStream
+}
+
+func (x *proxyServiceSyncMappingsServer) Send(m *SyncMappingsResponse) error {
+ return x.ServerStream.SendMsg(m)
+}
+
+func (x *proxyServiceSyncMappingsServer) Recv() (*SyncMappingsRequest, error) {
+ m := new(SyncMappingsRequest)
+ if err := x.ServerStream.RecvMsg(m); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
func _ProxyService_SendAccessLog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SendAccessLogRequest)
if err := dec(in); err != nil {
@@ -306,6 +410,24 @@ func _ProxyService_ValidateSession_Handler(srv interface{}, ctx context.Context,
return interceptor(ctx, in, info, handler)
}
+func _ProxyService_ValidateTunnelPeer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(ValidateTunnelPeerRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(ProxyServiceServer).ValidateTunnelPeer(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: "/management.ProxyService/ValidateTunnelPeer",
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(ProxyServiceServer).ValidateTunnelPeer(ctx, req.(*ValidateTunnelPeerRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
// ProxyService_ServiceDesc is the grpc.ServiceDesc for ProxyService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -337,6 +459,10 @@ var ProxyService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ValidateSession",
Handler: _ProxyService_ValidateSession_Handler,
},
+ {
+ MethodName: "ValidateTunnelPeer",
+ Handler: _ProxyService_ValidateTunnelPeer_Handler,
+ },
},
Streams: []grpc.StreamDesc{
{
@@ -344,6 +470,12 @@ var ProxyService_ServiceDesc = grpc.ServiceDesc{
Handler: _ProxyService_GetMappingUpdate_Handler,
ServerStreams: true,
},
+ {
+ StreamName: "SyncMappings",
+ Handler: _ProxyService_SyncMappings_Handler,
+ ServerStreams: true,
+ ClientStreams: true,
+ },
},
Metadata: "proxy_service.proto",
}
diff --git a/shared/relay/client/client.go b/shared/relay/client/client.go
index 1800bddb2..002b8d134 100644
--- a/shared/relay/client/client.go
+++ b/shared/relay/client/client.go
@@ -9,12 +9,14 @@ import (
"net/url"
"strings"
"sync"
+ "sync/atomic"
"time"
log "github.com/sirupsen/logrus"
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
"github.com/netbirdio/netbird/shared/relay/client/dialer"
+ netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
"github.com/netbirdio/netbird/shared/relay/healthcheck"
"github.com/netbirdio/netbird/shared/relay/messages"
)
@@ -172,6 +174,19 @@ type Client struct {
stateSubscription *PeersStateSubscription
mtu uint16
+
+ // transportFallback, when set, records datagram-too-large failures so a
+ // datagram-sized transport is avoided on subsequent connects. Shared via
+ // the manager.
+ transportFallback *transportFallback
+ // datagramFallbackTriggered guards a single fallback per connection so a
+ // burst of oversized datagrams triggers one reconnect, not many.
+ datagramFallbackTriggered atomic.Bool
+}
+
+// SetTransportFallback wires the shared datagram-transport fallback tracker.
+func (c *Client) SetTransportFallback(tf *transportFallback) {
+ c.transportFallback = tf
}
// NewClient creates a new client for the relay server. The client is not connected to the server until the Connect
@@ -361,12 +376,13 @@ func (c *Client) Close() error {
}
func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
- dialers := c.getDialers()
+ mode := transportModeFromEnv()
+ dialers := c.getDialers(mode)
var conn net.Conn
if c.serverIP.IsValid() {
var err error
- conn, err = c.dialRaceDirect(ctx, dialers)
+ conn, err = c.dialRaceDirect(ctx, mode, dialers)
if err != nil {
c.log.Infof("dial via server IP %s failed, falling back to FQDN: %v", c.serverIP, err)
conn = nil
@@ -375,6 +391,9 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
if conn == nil {
rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, c.connectionURL, dialers...)
+ if mode.sequential() {
+ rd.WithSequential()
+ }
var err error
conn, err = rd.Dial(ctx)
if err != nil {
@@ -382,6 +401,7 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
}
}
c.relayConn = conn
+ c.datagramFallbackTriggered.Store(false)
instanceURL, err := c.handShake(ctx)
if err != nil {
@@ -396,7 +416,7 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
}
// dialRaceDirect dials c.serverIP, preserving the original FQDN as the TLS ServerName for SNI.
-func (c *Client) dialRaceDirect(ctx context.Context, dialers []dialer.DialeFn) (net.Conn, error) {
+func (c *Client) dialRaceDirect(ctx context.Context, mode TransportMode, dialers []dialer.DialeFn) (net.Conn, error) {
directURL, serverName, err := substituteHost(c.connectionURL, c.serverIP)
if err != nil {
return nil, fmt.Errorf("substitute host: %w", err)
@@ -406,6 +426,9 @@ func (c *Client) dialRaceDirect(ctx context.Context, dialers []dialer.DialeFn) (
rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, directURL, dialers...).
WithServerName(serverName)
+ if mode.sequential() {
+ rd.WithSequential()
+ }
return rd.Dial(ctx)
}
@@ -631,13 +654,53 @@ func (c *Client) writeTo(containerRef *connContainer, dstID messages.PeerID, pay
}
// the write always return with 0 length because the underling does not support the size feedback.
- _, err = c.relayConn.Write(msg)
+ conn := c.relayConn
+ _, err = conn.Write(msg)
if err != nil {
- c.log.Errorf("failed to write transport message: %s", err)
+ if errors.Is(err, netErr.ErrDatagramTooLarge) {
+ c.onDatagramTooLarge(conn, err)
+ } else {
+ c.log.Errorf("failed to write transport message: %s", err)
+ }
}
return len(payload), err
}
+// onDatagramTooLarge reacts to a datagram rejected as too large for the path.
+// When a non-datagram transport is available, it records a fallback for this
+// server and closes the connection so the reconnect avoids datagram-sized
+// transports. A single fallback is triggered per connection regardless of how
+// many oversized datagrams arrive. cause carries the datagram size and budget.
+func (c *Client) onDatagramTooLarge(conn net.Conn, cause error) {
+ // Handle one oversized datagram per connection; a burst triggers a single
+ // fallback (and a single log line), not many.
+ if !c.datagramFallbackTriggered.CompareAndSwap(false, true) {
+ return
+ }
+
+ // If the selected mode offers no non-datagram transport (e.g. pinned to a
+ // datagram-sized transport), reconnecting would just re-fail, so leave the
+ // connection up rather than loop.
+ if len(nonDatagramSized(c.baseDialers(transportModeFromEnv()))) == 0 {
+ c.log.Warnf("%s, but no non-datagram transport is available, not falling back", cause)
+ return
+ }
+
+ // Without the shared tracker a reconnect would just select the same
+ // transport again and re-fail, so leave the connection up rather than loop.
+ if c.transportFallback == nil {
+ c.log.Debugf("%s, but no transport fallback configured, leaving connection up", cause)
+ return
+ }
+
+ window := c.transportFallback.recordFailure(c.connectionURL)
+ c.log.Warnf("%s, avoiding datagram-sized transport for %s", cause, window)
+
+ if err := conn.Close(); err != nil {
+ c.log.Debugf("close relay connection for transport fallback: %s", err)
+ }
+}
+
func (c *Client) listenForStopEvents(ctx context.Context, hc *healthcheck.Receiver, conn net.Conn, internalStopFlag *internalStopFlag) {
for {
select {
diff --git a/shared/relay/client/dialer/capability.go b/shared/relay/client/dialer/capability.go
new file mode 100644
index 000000000..511cb2ac7
--- /dev/null
+++ b/shared/relay/client/dialer/capability.go
@@ -0,0 +1,18 @@
+package dialer
+
+// DatagramSized is implemented by dialers whose connections carry each write in
+// a single datagram, so a write can be rejected when it exceeds the path's
+// datagram budget (e.g. QUIC). Transports without this capability (e.g.
+// WebSocket over TCP) impose no per-write size limit, so the relay client can
+// fall back to them when a datagram-sized transport rejects a write as too
+// large. The capability is advertised per dialer rather than hardcoded, so a
+// new transport only needs to declare whether it is datagram-sized.
+type DatagramSized interface {
+ DatagramSized()
+}
+
+// IsDatagramSized reports whether d produces datagram-sized connections.
+func IsDatagramSized(d DialeFn) bool {
+ _, ok := d.(DatagramSized)
+ return ok
+}
diff --git a/shared/relay/client/dialer/net/err.go b/shared/relay/client/dialer/net/err.go
index fee844963..c622420dc 100644
--- a/shared/relay/client/dialer/net/err.go
+++ b/shared/relay/client/dialer/net/err.go
@@ -4,4 +4,9 @@ import "errors"
var (
ErrClosedByServer = errors.New("closed by server")
+
+ // ErrDatagramTooLarge is returned when a transport message exceeds the
+ // QUIC datagram size the path to the relay can carry. The relay client
+ // treats it as a signal to fall back to a non-datagram transport.
+ ErrDatagramTooLarge = errors.New("datagram frame too large")
)
diff --git a/shared/relay/client/dialer/quic/conn.go b/shared/relay/client/dialer/quic/conn.go
index 1d90d7139..a5c982551 100644
--- a/shared/relay/client/dialer/quic/conn.go
+++ b/shared/relay/client/dialer/quic/conn.go
@@ -8,7 +8,6 @@ import (
"time"
"github.com/quic-go/quic-go"
- log "github.com/sirupsen/logrus"
netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
)
@@ -52,11 +51,8 @@ func (c *Conn) Read(b []byte) (n int, err error) {
}
func (c *Conn) Write(b []byte) (int, error) {
- err := c.session.SendDatagram(b)
- if err != nil {
- err = c.remoteCloseErrHandling(err)
- log.Errorf("failed to write to QUIC stream: %v", err)
- return 0, err
+ if err := c.session.SendDatagram(b); err != nil {
+ return 0, c.writeErrHandling(err, len(b))
}
return len(b), nil
}
@@ -95,3 +91,15 @@ func (c *Conn) remoteCloseErrHandling(err error) error {
}
return err
}
+
+// writeErrHandling normalizes SendDatagram errors. A datagram that exceeds the
+// path's QUIC packet budget is mapped to ErrDatagramTooLarge (annotated with the
+// datagram size and path budget) so the relay client can fall back to a
+// non-datagram transport.
+func (c *Conn) writeErrHandling(err error, size int) error {
+ var tooLarge *quic.DatagramTooLargeError
+ if errors.As(err, &tooLarge) {
+ return fmt.Errorf("%w: %d byte datagram over path budget %d", netErr.ErrDatagramTooLarge, size, tooLarge.MaxDatagramPayloadSize)
+ }
+ return c.remoteCloseErrHandling(err)
+}
diff --git a/shared/relay/client/dialer/quic/quic.go b/shared/relay/client/dialer/quic/quic.go
index 86f6f178d..5e1758a1c 100644
--- a/shared/relay/client/dialer/quic/quic.go
+++ b/shared/relay/client/dialer/quic/quic.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/quic-go/quic-go"
+ "github.com/quic-go/quic-go/logging"
log "github.com/sirupsen/logrus"
nbnet "github.com/netbirdio/netbird/client/net"
@@ -23,6 +24,12 @@ func (d Dialer) Protocol() string {
return Network
}
+// DatagramSized marks QUIC as a datagram-sized transport: relay traffic is
+// carried in QUIC DATAGRAM frames, which must fit a single packet.
+func (d Dialer) DatagramSized() {
+ // Intentional marker method; presence is the capability signal.
+}
+
func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, error) {
quicURL, err := prepareURL(address)
if err != nil {
@@ -47,6 +54,7 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
MaxIdleTimeout: 4 * time.Minute,
EnableDatagrams: true,
InitialPacketSize: nbRelay.QUICInitialPacketSize,
+ Tracer: connectionTracer(quicURL),
}
udpConn, err := nbnet.ListenUDP("udp", &net.UDPAddr{Port: 0})
@@ -74,6 +82,28 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
return conn, nil
}
+// connectionTracer returns a QUIC tracer that logs the DPLPMTUD result and the
+// reason a relay connection closed, so the path MTU settled on and teardown
+// cause are visible in logs. Lines carry the relay address as a structured
+// field, matching the rest of the relay client logging.
+func connectionTracer(addr string) func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer {
+ relayLog := log.WithField("relay", addr)
+ return func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer {
+ return &logging.ConnectionTracer{
+ UpdatedMTU: func(mtu logging.ByteCount, done bool) {
+ if done {
+ relayLog.Infof("QUIC path MTU settled at %d", mtu)
+ return
+ }
+ relayLog.Debugf("QUIC path MTU probing at %d", mtu)
+ },
+ ClosedConnection: func(err error) {
+ relayLog.Debugf("QUIC connection closed: %v", err)
+ },
+ }
+ }
+}
+
func prepareURL(address string) (string, error) {
var host string
var defaultPort string
diff --git a/shared/relay/client/dialer/race_dialer.go b/shared/relay/client/dialer/race_dialer.go
index 15208b858..aef1ef464 100644
--- a/shared/relay/client/dialer/race_dialer.go
+++ b/shared/relay/client/dialer/race_dialer.go
@@ -32,6 +32,7 @@ type RaceDial struct {
serverName string
dialerFns []DialeFn
connectionTimeout time.Duration
+ sequential bool
}
func NewRaceDial(log *log.Entry, connectionTimeout time.Duration, serverURL string, dialerFns ...DialeFn) *RaceDial {
@@ -53,7 +54,21 @@ func (r *RaceDial) WithServerName(serverName string) *RaceDial {
return r
}
+// WithSequential makes Dial try the dialers in order, falling back to the next
+// only when one fails to connect, instead of racing them concurrently.
+//
+// Mutates the receiver and is not safe for concurrent reconfiguration; a
+// RaceDial is intended to be constructed per dial and discarded.
+func (r *RaceDial) WithSequential() *RaceDial {
+ r.sequential = true
+ return r
+}
+
func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) {
+ if r.sequential {
+ return r.dialSequential(ctx)
+ }
+
connChan := make(chan dialResult, len(r.dialerFns))
winnerConn := make(chan net.Conn, 1)
abortCtx, abort := context.WithCancel(ctx)
@@ -72,6 +87,30 @@ func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) {
return conn, nil
}
+// dialSequential tries each dialer in order, returning the first connection and
+// falling back to the next on failure.
+func (r *RaceDial) dialSequential(ctx context.Context) (net.Conn, error) {
+ for _, dfn := range r.dialerFns {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ attemptCtx, cancel := context.WithTimeout(ctx, r.connectionTimeout)
+ r.log.Infof("dialing Relay server via %s", dfn.Protocol())
+ conn, err := dfn.Dial(attemptCtx, r.serverURL, r.serverName)
+ cancel()
+ if err != nil {
+ if errors.Is(err, context.Canceled) {
+ return nil, err
+ }
+ r.log.Errorf("failed to dial via %s: %s", dfn.Protocol(), err)
+ continue
+ }
+ r.log.Infof("successfully dialed via: %s", dfn.Protocol())
+ return conn, nil
+ }
+ return nil, errors.New("failed to dial to Relay server on any protocol")
+}
+
func (r *RaceDial) dial(dfn DialeFn, abortCtx context.Context, connChan chan dialResult) {
ctx, cancel := context.WithTimeout(abortCtx, r.connectionTimeout)
defer cancel()
diff --git a/shared/relay/client/dialer/race_dialer_test.go b/shared/relay/client/dialer/race_dialer_test.go
index a53edc00e..bd2f4bb85 100644
--- a/shared/relay/client/dialer/race_dialer_test.go
+++ b/shared/relay/client/dialer/race_dialer_test.go
@@ -250,3 +250,66 @@ func TestRaceDialFirstSuccessfulDialerWins(t *testing.T) {
}
}
}
+
+func TestRaceDialSequentialFallback(t *testing.T) {
+ logger := logrus.NewEntry(logrus.New())
+ serverURL := "test.server.com"
+
+ var firstDialed, secondDialed bool
+ preferred := &MockDialer{
+ protocolStr: "quic",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ firstDialed = true
+ return nil, errors.New("quic unreachable")
+ },
+ }
+ fallbackConn := &MockConn{remoteAddr: &MockAddr{network: "ws"}}
+ fallback := &MockDialer{
+ protocolStr: "ws",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ secondDialed = true
+ return fallbackConn, nil
+ },
+ }
+
+ rd := NewRaceDial(logger, DefaultConnectionTimeout, serverURL, preferred, fallback).WithSequential()
+ conn, err := rd.Dial(context.Background())
+ if err != nil {
+ t.Fatalf("expected fallback to succeed, got %v", err)
+ }
+ if conn != fallbackConn {
+ t.Errorf("expected fallback connection, got %v", conn)
+ }
+ if !firstDialed || !secondDialed {
+ t.Errorf("expected both dialers attempted in order, first=%v second=%v", firstDialed, secondDialed)
+ }
+}
+
+func TestRaceDialSequentialPreferredWins(t *testing.T) {
+ logger := logrus.NewEntry(logrus.New())
+ serverURL := "test.server.com"
+
+ preferredConn := &MockConn{remoteAddr: &MockAddr{network: "quic"}}
+ preferred := &MockDialer{
+ protocolStr: "quic",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ return preferredConn, nil
+ },
+ }
+ fallback := &MockDialer{
+ protocolStr: "ws",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ t.Errorf("fallback dialer must not be tried when preferred succeeds")
+ return nil, errors.New("should not happen")
+ },
+ }
+
+ rd := NewRaceDial(logger, DefaultConnectionTimeout, serverURL, preferred, fallback).WithSequential()
+ conn, err := rd.Dial(context.Background())
+ if err != nil {
+ t.Fatalf("expected preferred to succeed, got %v", err)
+ }
+ if conn != preferredConn {
+ t.Errorf("expected preferred connection, got %v", conn)
+ }
+}
diff --git a/shared/relay/client/dialers_generic.go b/shared/relay/client/dialers_generic.go
index a8ed79961..95e319338 100644
--- a/shared/relay/client/dialers_generic.go
+++ b/shared/relay/client/dialers_generic.go
@@ -9,11 +9,42 @@ import (
"github.com/netbirdio/netbird/shared/relay/client/dialer/ws"
)
-// getDialers returns the list of dialers to use for connecting to the relay server.
-func (c *Client) getDialers() []dialer.DialeFn {
- if c.mtu > 0 && c.mtu > iface.DefaultMTU {
- c.log.Infof("MTU %d exceeds default (%d), forcing WebSocket transport to avoid DATAGRAM frame size issues", c.mtu, iface.DefaultMTU)
- return []dialer.DialeFn{ws.Dialer{}}
+// getDialers returns the ordered dialers for connecting to the relay server. It
+// applies the datagram fallback generically: if this server recently rejected a
+// datagram-sized transport, those dialers are dropped, leaving the rest.
+func (c *Client) getDialers(mode TransportMode) []dialer.DialeFn {
+ dialers := c.baseDialers(mode)
+
+ if c.transportFallback != nil && c.transportFallback.avoidDatagramSized(c.connectionURL) {
+ if filtered := nonDatagramSized(dialers); len(filtered) > 0 {
+ c.log.Infof("relay recently rejected a datagram-sized transport, avoiding it")
+ return filtered
+ }
}
- return []dialer.DialeFn{quic.Dialer{}, ws.Dialer{}}
+ return dialers
+}
+
+// baseDialers returns the ordered dialers for the mode, before any datagram
+// fallback filtering. For racing modes (auto) the order is irrelevant; for
+// prefer modes the first entry is tried before falling back to the second.
+func (c *Client) baseDialers(mode TransportMode) []dialer.DialeFn {
+ switch mode {
+ case TransportModeWS:
+ c.log.Infof("%s=ws, using WebSocket transport", EnvRelayTransport)
+ return []dialer.DialeFn{ws.Dialer{}}
+ case TransportModeQUIC:
+ c.log.Infof("%s=quic, using QUIC transport", EnvRelayTransport)
+ return []dialer.DialeFn{quic.Dialer{}}
+ }
+
+ all := []dialer.DialeFn{quic.Dialer{}, ws.Dialer{}}
+ if mode == TransportModePreferWS {
+ all = []dialer.DialeFn{ws.Dialer{}, quic.Dialer{}}
+ }
+
+ if c.mtu > 0 && c.mtu > iface.DefaultMTU {
+ c.log.Infof("MTU %d exceeds default (%d), avoiding datagram-sized transports", c.mtu, iface.DefaultMTU)
+ return nonDatagramSized(all)
+ }
+ return all
}
diff --git a/shared/relay/client/dialers_generic_test.go b/shared/relay/client/dialers_generic_test.go
new file mode 100644
index 000000000..c4ef9cc59
--- /dev/null
+++ b/shared/relay/client/dialers_generic_test.go
@@ -0,0 +1,101 @@
+//go:build !js
+
+package client
+
+import (
+ "os"
+ "testing"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+
+ "github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/shared/relay/client/dialer"
+ netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
+ "github.com/netbirdio/netbird/shared/relay/client/dialer/quic"
+ "github.com/netbirdio/netbird/shared/relay/client/dialer/ws"
+)
+
+// TestDatagramSizedCapability locks the capability the generic fallback relies
+// on: QUIC is datagram-sized, WebSocket is not.
+func TestDatagramSizedCapability(t *testing.T) {
+ assert.True(t, dialer.IsDatagramSized(quic.Dialer{}), "QUIC must advertise datagram-sized")
+ assert.False(t, dialer.IsDatagramSized(ws.Dialer{}), "WebSocket must not advertise datagram-sized")
+}
+
+func protocols(dialers []dialer.DialeFn) []string {
+ out := make([]string, len(dialers))
+ for i, d := range dialers {
+ out[i] = d.Protocol()
+ }
+ return out
+}
+
+func TestGetDialers(t *testing.T) {
+ const url = "rels://relay.example:443"
+
+ tests := []struct {
+ name string
+ mode string
+ mtu uint16
+ preferWS bool
+ want []string
+ }{
+ {name: "auto races quic and ws", mode: "auto", mtu: iface.DefaultMTU, want: []string{"quic", "WS"}},
+ {name: "ws pinned", mode: "ws", mtu: iface.DefaultMTU, want: []string{"WS"}},
+ {name: "quic pinned", mode: "quic", mtu: iface.DefaultMTU, want: []string{"quic"}},
+ {name: "prefer-quic orders quic first", mode: "prefer-quic", mtu: iface.DefaultMTU, want: []string{"quic", "WS"}},
+ {name: "prefer-ws orders ws first", mode: "prefer-ws", mtu: iface.DefaultMTU, want: []string{"WS", "quic"}},
+ {name: "mtu above default forces ws", mode: "auto", mtu: iface.DefaultMTU + 100, want: []string{"WS"}},
+ {name: "sticky fallback forces ws in auto", mode: "auto", mtu: iface.DefaultMTU, preferWS: true, want: []string{"WS"}},
+ {name: "sticky fallback forces ws in prefer-quic", mode: "prefer-quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"WS"}},
+ {name: "quic pin overrides sticky fallback", mode: "quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"quic"}},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(EnvRelayTransport, tc.mode)
+ if tc.mode == "" {
+ os.Unsetenv(EnvRelayTransport)
+ }
+
+ tf := newTransportFallback()
+ if tc.preferWS {
+ tf.recordFailure(url)
+ }
+
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ mtu: tc.mtu,
+ transportFallback: tf,
+ }
+
+ assert.Equal(t, tc.want, protocols(c.getDialers(transportModeFromEnv())))
+ })
+ }
+}
+
+// TestStickyFallbackAfterDatagramTooLarge verifies the full chain: an oversized
+// datagram records a fallback that makes the next dial pick WebSocket, the way a
+// reconnect would after the connection is closed.
+func TestStickyFallbackAfterDatagramTooLarge(t *testing.T) {
+ const url = "rels://relay.example:443"
+ t.Setenv(EnvRelayTransport, string(TransportModeAuto))
+
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ mtu: iface.DefaultMTU,
+ transportFallback: newTransportFallback(),
+ }
+
+ // First dial races both transports.
+ assert.Equal(t, []string{"quic", "WS"}, protocols(c.getDialers(transportModeFromEnv())))
+
+ // An oversized datagram records the fallback for this server.
+ c.onDatagramTooLarge(&closeTrackingConn{}, netErr.ErrDatagramTooLarge)
+
+ // The reconnect now sticks to WebSocket.
+ assert.Equal(t, []string{"WS"}, protocols(c.getDialers(transportModeFromEnv())))
+}
diff --git a/shared/relay/client/dialers_js.go b/shared/relay/client/dialers_js.go
index 6bd0e6696..c93787729 100644
--- a/shared/relay/client/dialers_js.go
+++ b/shared/relay/client/dialers_js.go
@@ -7,7 +7,11 @@ import (
"github.com/netbirdio/netbird/shared/relay/client/dialer/ws"
)
-func (c *Client) getDialers() []dialer.DialeFn {
+func (c *Client) getDialers(_ TransportMode) []dialer.DialeFn {
// JS/WASM build only uses WebSocket transport
return []dialer.DialeFn{ws.Dialer{}}
}
+
+func (c *Client) baseDialers(_ TransportMode) []dialer.DialeFn {
+ return []dialer.DialeFn{ws.Dialer{}}
+}
diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go
index 3858b3c83..f87da15de 100644
--- a/shared/relay/client/manager.go
+++ b/shared/relay/client/manager.go
@@ -79,23 +79,30 @@ type Manager struct {
cleanupInterval time.Duration
keepUnusedServerTime time.Duration
+
+ // transportFallback is shared across home and foreign relay clients so a
+ // datagram-too-large failure makes that server avoid datagram-sized transports across reconnects.
+ transportFallback *transportFallback
}
// NewManager creates a new manager instance.
// The serverURL address can be empty. In this case, the manager will not serve.
func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uint16, opts ...ManagerOption) *Manager {
tokenStore := &relayAuth.TokenStore{}
+ tf := newTransportFallback()
m := &Manager{
- ctx: ctx,
- peerID: peerID,
- tokenStore: tokenStore,
- mtu: mtu,
+ ctx: ctx,
+ peerID: peerID,
+ tokenStore: tokenStore,
+ mtu: mtu,
+ transportFallback: tf,
serverPicker: &ServerPicker{
TokenStore: tokenStore,
PeerID: peerID,
MTU: mtu,
ConnectionTimeout: defaultConnectionTimeout,
+ TransportFallback: tf,
},
relayClients: make(map[string]*RelayTrack),
onDisconnectedListeners: make(map[string]*list.List),
@@ -287,6 +294,7 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string
m.relayClientsMutex.Unlock()
relayClient := NewClientWithServerIP(serverAddress, serverIP, m.tokenStore, m.peerID, m.mtu)
+ relayClient.SetTransportFallback(m.transportFallback)
err := relayClient.Connect(m.ctx)
if err != nil {
rt.err = err
diff --git a/shared/relay/client/picker.go b/shared/relay/client/picker.go
index 39d0ba072..992e48114 100644
--- a/shared/relay/client/picker.go
+++ b/shared/relay/client/picker.go
@@ -29,6 +29,7 @@ type ServerPicker struct {
PeerID string
MTU uint16
ConnectionTimeout time.Duration
+ TransportFallback *transportFallback
}
func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
@@ -70,6 +71,7 @@ func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan connResult, url string) {
log.Infof("try to connecting to relay server: %s", url)
relayClient := NewClient(url, sp.TokenStore, sp.PeerID, sp.MTU)
+ relayClient.SetTransportFallback(sp.TransportFallback)
err := relayClient.Connect(ctx)
resultChan <- connResult{
RelayClient: relayClient,
diff --git a/shared/relay/client/transport.go b/shared/relay/client/transport.go
new file mode 100644
index 000000000..002707401
--- /dev/null
+++ b/shared/relay/client/transport.go
@@ -0,0 +1,129 @@
+package client
+
+import (
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/shared/relay/client/dialer"
+)
+
+// EnvRelayTransport pins the relay transport. Valid values: "auto" (default,
+// race QUIC and WebSocket), "quic" (QUIC only), "ws" (WebSocket only),
+// "prefer-quic" / "prefer-ws" (try the preferred transport first, fall back to
+// the other only if it fails to connect; no race). The prefer modes trade a
+// slower connect when the preferred transport is blackholed for deterministic
+// transport selection.
+const EnvRelayTransport = "NB_RELAY_TRANSPORT"
+
+const (
+ // transportFallbackBase is the initial window a relay server avoids
+ // datagram-sized transports after a datagram is rejected as too large.
+ transportFallbackBase = 10 * time.Minute
+ // transportFallbackMax caps the pinned window when failures repeat.
+ transportFallbackMax = 60 * time.Minute
+)
+
+// TransportMode selects which relay dialers are used.
+type TransportMode string
+
+const (
+ TransportModeAuto TransportMode = "auto"
+ TransportModeQUIC TransportMode = "quic"
+ TransportModeWS TransportMode = "ws"
+ TransportModePreferQUIC TransportMode = "prefer-quic"
+ TransportModePreferWS TransportMode = "prefer-ws"
+)
+
+// transportModeFromEnv reads EnvRelayTransport, defaulting to auto for an empty
+// or unrecognized value.
+func transportModeFromEnv() TransportMode {
+ switch TransportMode(strings.ToLower(strings.TrimSpace(os.Getenv(EnvRelayTransport)))) {
+ case "", TransportModeAuto:
+ return TransportModeAuto
+ case TransportModeQUIC:
+ return TransportModeQUIC
+ case TransportModeWS:
+ return TransportModeWS
+ case TransportModePreferQUIC:
+ return TransportModePreferQUIC
+ case TransportModePreferWS:
+ return TransportModePreferWS
+ default:
+ log.Warnf("invalid %s value %q, using %q", EnvRelayTransport, os.Getenv(EnvRelayTransport), TransportModeAuto)
+ return TransportModeAuto
+ }
+}
+
+// sequential reports whether the mode tries dialers in order with fallback
+// instead of racing them concurrently.
+func (m TransportMode) sequential() bool {
+ return m == TransportModePreferQUIC || m == TransportModePreferWS
+}
+
+// transportFallback tracks relay servers that have rejected a datagram-sized
+// transport (a write too large for the path) and should temporarily avoid such
+// transports. It is shared across the relay manager so the preference survives
+// client recreation (foreign relay clients are evicted and rebuilt on
+// disconnect). Entries are keyed by server URL and expire after a window that
+// grows on repeated failures.
+type transportFallback struct {
+ mu sync.Mutex
+ entries map[string]*fallbackEntry
+}
+
+type fallbackEntry struct {
+ until time.Time
+ duration time.Duration
+}
+
+func newTransportFallback() *transportFallback {
+ return &transportFallback{entries: make(map[string]*fallbackEntry)}
+}
+
+// avoidDatagramSized reports whether serverURL is currently within a window
+// where datagram-sized transports should be avoided.
+func (f *transportFallback) avoidDatagramSized(serverURL string) bool {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ e := f.entries[serverURL]
+ return e != nil && time.Now().Before(e.until)
+}
+
+// recordFailure makes serverURL avoid datagram-sized transports for a window:
+// transportFallbackBase on the first failure, doubling up to transportFallbackMax
+// when a datagram transport fails again after a previous window expired. It
+// returns the active window duration.
+func (f *transportFallback) recordFailure(serverURL string) time.Duration {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ now := time.Now()
+ e := f.entries[serverURL]
+ switch {
+ case e == nil:
+ e = &fallbackEntry{duration: transportFallbackBase}
+ f.entries[serverURL] = e
+ case now.Before(e.until):
+ return time.Until(e.until)
+ default:
+ e.duration = min(e.duration*2, transportFallbackMax)
+ }
+ e.until = now.Add(e.duration)
+ return e.duration
+}
+
+// nonDatagramSized returns the dialers from in that are not datagram-sized,
+// preserving order.
+func nonDatagramSized(in []dialer.DialeFn) []dialer.DialeFn {
+ out := make([]dialer.DialeFn, 0, len(in))
+ for _, d := range in {
+ if !dialer.IsDatagramSized(d) {
+ out = append(out, d)
+ }
+ }
+ return out
+}
diff --git a/shared/relay/client/transport_test.go b/shared/relay/client/transport_test.go
new file mode 100644
index 000000000..8e10c8d42
--- /dev/null
+++ b/shared/relay/client/transport_test.go
@@ -0,0 +1,140 @@
+package client
+
+import (
+ "net"
+ "os"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
+)
+
+// closeTrackingConn records whether Close was called; only Close is exercised.
+type closeTrackingConn struct {
+ net.Conn
+ closed bool
+}
+
+func (c *closeTrackingConn) Close() error {
+ c.closed = true
+ return nil
+}
+
+func TestTransportModeFromEnv(t *testing.T) {
+ tests := []struct {
+ value string
+ want TransportMode
+ }{
+ {"", TransportModeAuto},
+ {"auto", TransportModeAuto},
+ {"quic", TransportModeQUIC},
+ {"QUIC", TransportModeQUIC},
+ {"ws", TransportModeWS},
+ {" Ws ", TransportModeWS},
+ {"prefer-quic", TransportModePreferQUIC},
+ {"prefer-ws", TransportModePreferWS},
+ {"garbage", TransportModeAuto},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.value, func(t *testing.T) {
+ t.Setenv(EnvRelayTransport, tc.value)
+ if tc.value == "" {
+ os.Unsetenv(EnvRelayTransport)
+ }
+ assert.Equal(t, tc.want, transportModeFromEnv())
+ })
+ }
+}
+
+func TestTransportFallbackRecordAndExpiry(t *testing.T) {
+ const url = "rels://relay.example:443"
+ f := newTransportFallback()
+
+ assert.False(t, f.avoidDatagramSized(url), "no fallback recorded yet")
+
+ d := f.recordFailure(url)
+ assert.Equal(t, transportFallbackBase, d, "first failure pins for the base window")
+ assert.True(t, f.avoidDatagramSized(url), "datagram-sized transport avoided within the window")
+
+ // A second failure while still inside the window must not grow the window.
+ d = f.recordFailure(url)
+ assert.LessOrEqual(t, d, transportFallbackBase, "still within the active window")
+ require.NotNil(t, f.entries[url])
+ assert.Equal(t, transportFallbackBase, f.entries[url].duration, "duration unchanged inside window")
+
+ // Expire the window: datagram-sized transport allowed again.
+ f.entries[url].until = time.Now().Add(-time.Second)
+ assert.False(t, f.avoidDatagramSized(url), "window expired, datagram-sized transport allowed")
+}
+
+func TestTransportFallbackGrowsOnRepeat(t *testing.T) {
+ const url = "rels://relay.example:443"
+ f := newTransportFallback()
+
+ want := transportFallbackBase
+ for i := range 6 {
+ d := f.recordFailure(url)
+ assert.Equal(t, want, d, "window after %d expiries", i)
+
+ // expire the window so the next failure is treated as a repeat
+ f.entries[url].until = time.Now().Add(-time.Second)
+
+ want = min(want*2, transportFallbackMax)
+ }
+
+ assert.Equal(t, transportFallbackMax, f.entries[url].duration, "window caps at the max")
+}
+
+func TestOnDatagramTooLargeAuto(t *testing.T) {
+ const url = "rels://relay.example:443"
+ t.Setenv(EnvRelayTransport, string(TransportModeAuto))
+
+ tf := newTransportFallback()
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ transportFallback: tf,
+ }
+ conn := &closeTrackingConn{}
+
+ c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge)
+
+ assert.True(t, conn.closed, "connection closed to force reconnect")
+ assert.True(t, tf.avoidDatagramSized(url), "fallback recorded for the server")
+
+ // A second oversized datagram on the same connection must not re-close.
+ conn.closed = false
+ c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge)
+ assert.False(t, conn.closed, "single fallback per connection")
+}
+
+func TestOnDatagramTooLargeQUICPinned(t *testing.T) {
+ const url = "rels://relay.example:443"
+ t.Setenv(EnvRelayTransport, string(TransportModeQUIC))
+
+ tf := newTransportFallback()
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ transportFallback: tf,
+ }
+ conn := &closeTrackingConn{}
+
+ c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge)
+
+ assert.False(t, conn.closed, "QUIC pin keeps the connection, no fallback redial")
+ assert.False(t, tf.avoidDatagramSized(url), "QUIC pin records no fallback")
+}
+
+func TestTransportFallbackPerServer(t *testing.T) {
+ f := newTransportFallback()
+ f.recordFailure("rels://a.example:443")
+
+ assert.True(t, f.avoidDatagramSized("rels://a.example:443"))
+ assert.False(t, f.avoidDatagramSized("rels://b.example:443"), "fallback is scoped to one server")
+}
diff --git a/util/capture/text.go b/util/capture/text.go
index d830d3ab3..95068136a 100644
--- a/util/capture/text.go
+++ b/util/capture/text.go
@@ -13,6 +13,8 @@ import (
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/miekg/dns"
+
+ nbdns "github.com/netbirdio/netbird/dns"
)
// TextWriter writes human-readable one-line-per-packet summaries.
@@ -150,7 +152,7 @@ func (tw *TextWriter) writeUDP(timeStr string, dir Direction, info *packetInfo,
plen := len(udp.Payload)
// DNS replaces the entire line format
- if plen > 0 && isDNSPort(info.srcPort, info.dstPort) {
+ if plen > 0 && (isDNSPort(info.srcPort) || isDNSPort(info.dstPort)) {
if s := formatDNSPayload(udp.Payload); s != "" {
var verbose string
if tw.verbose {
@@ -724,8 +726,12 @@ func findSNIExtension(body []byte, pos int) string {
return ""
}
-func isDNSPort(src, dst uint16) bool {
- return src == 53 || dst == 53 || src == 5353 || dst == 5353
+func isDNSPort(p uint16) bool {
+ switch p {
+ case nbdns.DefaultDNSPort, nbdns.ForwarderClientPort, nbdns.ForwarderServerPort:
+ return true
+ }
+ return false
}
// formatDNSPayload parses DNS and returns a tcpdump-style summary.
diff --git a/util/log.go b/util/log.go
index b1de2d999..3896ff6bc 100644
--- a/util/log.go
+++ b/util/log.go
@@ -1,15 +1,16 @@
package util
import (
+ "fmt"
"io"
"os"
"path/filepath"
"slices"
"strconv"
+ "github.com/DeRuina/timberjack"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/grpclog"
- "gopkg.in/natefinch/lumberjack.v2"
"github.com/netbirdio/netbird/formatter"
)
@@ -37,8 +38,7 @@ func InitLog(logLevel string, logs ...string) error {
func InitLogger(logger *log.Logger, logLevel string, logs ...string) error {
level, err := log.ParseLevel(logLevel)
if err != nil {
- logger.Errorf("Failed parsing log-level %s: %s", logLevel, err)
- return err
+ return fmt.Errorf("failed parsing log-level %s: %w", logLevel, err)
}
var writers []io.Writer
logFmt := os.Getenv("NB_LOG_FORMAT")
@@ -59,7 +59,11 @@ func InitLogger(logger *log.Logger, logLevel string, logs ...string) error {
case "":
logger.Warnf("empty log path received: %#v", logPath)
default:
- writers = append(writers, newRotatedOutput(logPath))
+ writer, err := setupLogFile(logPath, isRotationDisabled(logger))
+ if err != nil {
+ return fmt.Errorf("failed setting up log file: %s, %w", logPath, err)
+ }
+ writers = append(writers, writer)
}
}
@@ -94,17 +98,43 @@ func FindFirstLogPath(logs []string) string {
return ""
}
+func isRotationDisabled(logger *log.Logger) bool {
+ v, _ := os.LookupEnv("NB_LOG_DISABLE_ROTATION")
+ disabled, _ := strconv.ParseBool(v)
+ if disabled {
+ logger.Warnf("log rotation is disabled by env flag")
+ return true
+ }
+ conflict, configPath := FindFirstLogrotateConflict()
+ if conflict {
+ logger.Warnf("log rotation conflict detected in: %#v, rotation is disabled", configPath)
+ return true
+ }
+ return false
+}
+
+func setupLogFile(logPath string, disableRotation bool) (io.Writer, error) {
+ if disableRotation {
+ file, err := os.OpenFile(logPath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600)
+ if err != nil {
+ return nil, err
+ }
+ return file, nil
+ }
+ return newRotatedOutput(logPath), nil
+}
+
func newRotatedOutput(logPath string) io.Writer {
maxLogSize := getLogMaxSize()
- lumberjackLogger := &lumberjack.Logger{
+ timberjackLogger := &timberjack.Logger{
// Log file absolute path, os agnostic
- Filename: filepath.ToSlash(logPath),
- MaxSize: maxLogSize, // MB
- MaxBackups: 10,
- MaxAge: 30, // days
- Compress: true,
+ Filename: filepath.ToSlash(logPath),
+ MaxSize: maxLogSize, // MB
+ MaxBackups: 10,
+ MaxAge: 30, // days
+ Compression: "gzip",
}
- return lumberjackLogger
+ return timberjackLogger
}
func setGRPCLibLogger(logger *log.Logger) {
@@ -127,7 +157,7 @@ func getLogMaxSize() int {
if sizeVar, ok := os.LookupEnv("NB_LOG_MAX_SIZE_MB"); ok {
size, err := strconv.ParseInt(sizeVar, 10, 64)
if err != nil {
- log.Errorf("Failed parsing log-size %s: %s. Should be just an integer", sizeVar, err)
+ log.Errorf("failed parsing log-size %s: %s. Should be just an integer", sizeVar, err)
return defaultLogSize
}
diff --git a/util/log_test.go b/util/log_test.go
new file mode 100644
index 000000000..e9933e479
--- /dev/null
+++ b/util/log_test.go
@@ -0,0 +1,96 @@
+package util
+
+import (
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/require"
+)
+
+// TestSetupLogFile_RotatesOnSize drives >MaxSize bytes through the writer
+// returned by setupLogFile and asserts a backup file appears.
+func TestSetupLogFile_RotatesOnSize(t *testing.T) {
+ t.Setenv("NB_LOG_MAX_SIZE_MB", "1")
+
+ dir := t.TempDir()
+ logPath := filepath.Join(dir, "netbird.log")
+
+ w, err := setupLogFile(logPath, false)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ if c, ok := w.(io.Closer); ok {
+ _ = c.Close()
+ }
+ })
+
+ chunk := []byte(strings.Repeat("x", 64*1024) + "\n")
+ for range 20 {
+ _, err := w.Write(chunk)
+ require.NoError(t, err)
+ }
+
+ info, err := os.Stat(logPath)
+ require.NoError(t, err)
+ require.Less(t, info.Size(), int64(1<<20),
+ "active log should be < 1 MB after rotation, got %d", info.Size())
+
+ require.Eventually(t, func() bool {
+ entries, _ := os.ReadDir(dir)
+ for _, e := range entries {
+ name := e.Name()
+ if name == filepath.Base(logPath) {
+ continue
+ }
+ if strings.HasPrefix(name, "netbird-") && strings.HasSuffix(name, ".log.gz") {
+ return true
+ }
+ }
+ return false
+ }, 5*time.Second, 50*time.Millisecond, "expected a rotated backup file in %s", dir)
+}
+
+// TestSetupLogFile_RotationDisabled verifies that with rotation off, the file
+// grows past MaxSize and no backups are created.
+func TestSetupLogFile_RotationDisabled(t *testing.T) {
+ t.Setenv("NB_LOG_MAX_SIZE_MB", "1")
+
+ dir := t.TempDir()
+ logPath := filepath.Join(dir, "netbird.log")
+
+ w, err := setupLogFile(logPath, true)
+ require.NoError(t, err)
+
+ f, ok := w.(*os.File)
+ require.True(t, ok, "expected plain *os.File when rotation is disabled, got %T", w)
+ t.Cleanup(func() { _ = f.Close() })
+
+ chunk := []byte(strings.Repeat("y", 64*1024) + "\n")
+ for range 20 {
+ _, err := w.Write(chunk)
+ require.NoError(t, err)
+ }
+
+ info, err := os.Stat(logPath)
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, info.Size(), int64(1<<20),
+ "file should exceed MaxSize when rotation is disabled, got %d", info.Size())
+
+ entries, err := os.ReadDir(dir)
+ require.NoError(t, err)
+ require.Len(t, entries, 1, "no backup files should exist when rotation is disabled, got %v", entries)
+}
+
+// TestIsRotationDisabled_EnvFlag covers the NB_LOG_DISABLE_ROTATION env path.
+// The logrotate-conflict branch is exercised separately on linux.
+func TestIsRotationDisabled_EnvFlag(t *testing.T) {
+ logger := log.New()
+ logger.SetOutput(io.Discard)
+
+ t.Setenv("NB_LOG_DISABLE_ROTATION", "true")
+ require.True(t, isRotationDisabled(logger))
+}
diff --git a/util/logrotate_linux.go b/util/logrotate_linux.go
new file mode 100644
index 000000000..7d2173ea8
--- /dev/null
+++ b/util/logrotate_linux.go
@@ -0,0 +1,93 @@
+//go:build linux
+
+package util
+
+import (
+ "bufio"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+)
+
+const (
+ defaultLogrotateConfPath = "/etc/logrotate.conf"
+ defaultLogrotateConfDir = "/etc/logrotate.d"
+ netbirdString = "netbird"
+)
+
+// FindLogrotateConflicts scans the standard logrotate locations for
+// indications of conflict with netbird. It returns true and the config file
+// path if a conflict was found.
+func FindFirstLogrotateConflict() (bool, string) {
+ return findFirstLogrotateConflictIn(defaultLogrotateConfPath, defaultLogrotateConfDir)
+}
+
+func findFirstLogrotateConflictIn(confPath, confDir string) (bool, string) {
+ for _, f := range listLogrotateConfigs(confPath, confDir) {
+ present, err := scanLogrotateFile(f, netbirdString)
+ if err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ log.Debugf("scan %s: %v", f, err)
+ }
+ continue
+ }
+ if present {
+ return present, f
+ }
+ }
+ return false, ""
+}
+
+// listLogrotateConfigs returns all config files for logrotate.
+func listLogrotateConfigs(confPath, confDir string) []string {
+ files := []string{confPath}
+ entries, err := os.ReadDir(confDir)
+ if err != nil {
+ return files
+ }
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ files = append(files, filepath.Join(confDir, e.Name()))
+ }
+ return files
+}
+
+// scanLogrotateFile reads a config and reports if a non-comment line
+// contains the given substring.
+func scanLogrotateFile(path string, substring string) (bool, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return false, err
+ }
+ defer func() {
+ if err := f.Close(); err != nil {
+ log.Debugf("close %s: %v", path, err)
+ }
+ }()
+
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(stripLogrotateComment(scanner.Text()))
+ if line == "" {
+ continue
+ }
+ if strings.Contains(line, substring) {
+ return true, nil
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return false, err
+ }
+ return false, nil
+}
+
+func stripLogrotateComment(line string) string {
+ before, _, _ := strings.Cut(line, "#")
+ return before
+}
diff --git a/util/logrotate_linux_test.go b/util/logrotate_linux_test.go
new file mode 100644
index 000000000..92d7e410b
--- /dev/null
+++ b/util/logrotate_linux_test.go
@@ -0,0 +1,95 @@
+//go:build linux
+
+package util
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestFindFirstLogrotateConflict(t *testing.T) {
+ t.Run("conflict in confDir", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ conflictPath := filepath.Join(confDir, "netbird")
+ writeLogrotateConfig(t, conflictPath, `/var/log/netbird/*.log {
+ daily
+ rotate 7
+}`)
+ writeLogrotateConfig(t, filepath.Join(confDir, "nginx"), `/var/log/nginx/*.log { daily }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.True(t, got)
+ require.Equal(t, conflictPath, path)
+ })
+
+ t.Run("conflict in main conf file", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ writeLogrotateConfig(t, confPath, `weekly
+rotate 4
+include /etc/logrotate.d
+/var/log/netbird/client.log { rotate 5 }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.True(t, got)
+ require.Equal(t, confPath, path)
+ })
+
+ t.Run("no conflict when netbird is absent", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ writeLogrotateConfig(t, filepath.Join(confDir, "nginx"), `/var/log/nginx/*.log { daily }`)
+ writeLogrotateConfig(t, filepath.Join(confDir, "syslog"), `/var/log/syslog { weekly }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.False(t, got)
+ require.Empty(t, path)
+ })
+
+ t.Run("commented-out netbird line is ignored", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ writeLogrotateConfig(t, filepath.Join(confDir, "misc"), `# /var/log/netbird/*.log { daily }
+/var/log/other.log { weekly }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.False(t, got)
+ require.Empty(t, path)
+ })
+
+ t.Run("subdirectories in confDir are ignored", func(t *testing.T) {
+ confPath, confDir := newLogrotateLayout(t)
+ sub := filepath.Join(confDir, "nested")
+ require.NoError(t, os.MkdirAll(sub, 0o755))
+ writeLogrotateConfig(t, filepath.Join(sub, "netbird"), `/var/log/netbird/*.log { daily }`)
+
+ got, path := findFirstLogrotateConflictIn(confPath, confDir)
+ require.False(t, got)
+ require.Empty(t, path)
+ })
+
+ t.Run("missing paths return no conflict", func(t *testing.T) {
+ dir := t.TempDir()
+ got, path := findFirstLogrotateConflictIn(
+ filepath.Join(dir, "does-not-exist.conf"),
+ filepath.Join(dir, "does-not-exist.d"),
+ )
+ require.False(t, got)
+ require.Empty(t, path)
+ })
+}
+
+// newLogrotateLayout creates a temp logrotate.conf path and logrotate.d dir,
+// returning their paths. The conf file itself is not created.
+func newLogrotateLayout(t *testing.T) (confPath, confDir string) {
+ t.Helper()
+ root := t.TempDir()
+ confDir = filepath.Join(root, "logrotate.d")
+ require.NoError(t, os.MkdirAll(confDir, 0o755))
+ return filepath.Join(root, "logrotate.conf"), confDir
+}
+
+func writeLogrotateConfig(t *testing.T, path, body string) {
+ t.Helper()
+ require.NoError(t, os.WriteFile(path, []byte(body), 0o644))
+}
diff --git a/util/logrotate_nonlinux.go b/util/logrotate_nonlinux.go
new file mode 100644
index 000000000..0a188b864
--- /dev/null
+++ b/util/logrotate_nonlinux.go
@@ -0,0 +1,10 @@
+//go:build !linux
+
+package util
+
+// FindLogrotateConflicts scans the standard logrotate locations for
+// indications of conflict with netbird. It will always return false for
+// non-linux devices.
+func FindFirstLogrotateConflict() (bool, string) {
+ return false, ""
+}
diff --git a/version/version.go b/version/version.go
index d70a5effa..f33ff133c 100644
--- a/version/version.go
+++ b/version/version.go
@@ -2,19 +2,75 @@ package version
import (
"regexp"
+ "runtime/debug"
+ "strings"
v "github.com/hashicorp/go-version"
)
+// DevelopmentVersion is the value of NetbirdVersion() for non-release builds.
+// Wire-format consumers (management server, dashboard) match against this
+// string, so it must not change without coordinating those consumers.
+const DevelopmentVersion = "development"
+
// will be replaced with the release version when using goreleaser
-var version = "development"
+var version = DevelopmentVersion
var (
VersionRegexp = regexp.MustCompile("^" + v.VersionRegexpRaw + "$")
SemverRegexp = regexp.MustCompile("^" + v.SemverRegexpRaw + "$")
)
-// NetbirdVersion returns the Netbird version
+// NetbirdVersion returns the Netbird version. For non-release builds the
+// value is the literal DevelopmentVersion constant; the VCS revision is
+// exposed separately via NetbirdCommit so the wire format stays stable.
func NetbirdVersion() string {
return version
}
+
+// NetbirdCommit returns the VCS revision (truncated to 12 chars) of the
+// build, with a "-dirty" suffix when the working tree was modified.
+// Returns an empty string when no build info is embedded (e.g. release
+// builds compiled by goreleaser without -buildvcs).
+func NetbirdCommit() string {
+ info, ok := debug.ReadBuildInfo()
+ if !ok {
+ return ""
+ }
+
+ var revision string
+ var modified bool
+ for _, s := range info.Settings {
+ switch s.Key {
+ case "vcs.revision":
+ revision = s.Value
+ case "vcs.modified":
+ modified = s.Value == "true"
+ }
+ }
+
+ if revision == "" {
+ return ""
+ }
+
+ if len(revision) > 12 {
+ revision = revision[:12]
+ }
+
+ if modified {
+ revision += "-dirty"
+ }
+ return revision
+}
+
+// IsDevelopmentVersion reports whether the given version string identifies
+// a non-release / development build. It is the single source of truth for
+// "is this a dev build" checks across the codebase; use it instead of
+// comparing against the "development" literal or ad-hoc substring checks.
+//
+// Matches the bare DevelopmentVersion constant as well as any future
+// extension such as "development-" or "development--dirty",
+// while excluding tagged prereleases like "v0.31.1-dev".
+func IsDevelopmentVersion(v string) bool {
+ return strings.HasPrefix(v, DevelopmentVersion)
+}
diff --git a/version/version_test.go b/version/version_test.go
new file mode 100644
index 000000000..47b77b50d
--- /dev/null
+++ b/version/version_test.go
@@ -0,0 +1,26 @@
+package version
+
+import "testing"
+
+func TestIsDevelopmentVersion(t *testing.T) {
+ tests := []struct {
+ version string
+ want bool
+ }{
+ {"development", true},
+ {"development-0823f3ff9ab1", true},
+ {"development-0823f3ff9ab1-dirty", true},
+ {"0.50.0", false},
+ {"v0.31.1-dev", false},
+ {"1.0.0-dev", false},
+ {"dev", false},
+ {"", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.version, func(t *testing.T) {
+ if got := IsDevelopmentVersion(tt.version); got != tt.want {
+ t.Errorf("IsDevelopmentVersion(%q) = %v, want %v", tt.version, got, tt.want)
+ }
+ })
+ }
+}