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..200e888ba 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') }} @@ -44,4 +46,3 @@ jobs: - 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) - 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..fc4187b8f 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 }} @@ -158,14 +162,16 @@ jobs: 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 +183,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 +237,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 +254,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 }} @@ -277,14 +285,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 @@ -298,7 +308,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 }} @@ -324,14 +334,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 +355,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 }} @@ -370,19 +382,21 @@ jobs: 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 +404,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 +424,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,7 +441,7 @@ 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 \ @@ -437,13 +451,13 @@ jobs: 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 +488,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 +501,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 +521,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 +545,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 +582,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 +595,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 +615,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 +641,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 +664,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 }} 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..cae6aa873 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,7 +24,9 @@ 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 @@ -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 81ae36e78..dd39d979d 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 56MB limit!" exit 1 fi - 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/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/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 72f81c782..9645d793a 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" @@ -348,6 +349,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/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..7a35e56d8 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) @@ -1386,3 +1391,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 3bd0d4621..048ff5fcc 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" @@ -56,6 +55,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" @@ -148,6 +148,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. @@ -226,10 +230,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 @@ -292,6 +301,7 @@ func NewEngine( jobExecutor: jobexec.NewExecutor(), clientMetrics: services.ClientMetrics, updateManager: services.UpdateManager, + syncStoreDir: config.StateDir, } log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String()) @@ -913,19 +923,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 { @@ -1813,6 +1822,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) { @@ -1967,6 +1988,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 { @@ -2119,45 +2163,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 @@ -2193,7 +2234,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 df746fa13..c5fa4e9f9 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -185,9 +185,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 +286,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 +297,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 +308,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 +733,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 +940,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 +949,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 +982,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 +998,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 +1009,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 +1039,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 +1049,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 +1074,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 @@ -1219,8 +1250,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") } 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/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/statemanager/manager.go b/client/internal/statemanager/manager.go index 2c9e46290..566905985 100644 --- a/client/internal/statemanager/manager.go +++ b/client/internal/statemanager/manager.go @@ -96,17 +96,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/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/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/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/go.mod b/go.mod index 7c1a95e79..caf9cb689 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,8 +19,8 @@ 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 @@ -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 @@ -60,7 +60,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 @@ -335,7 +335,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 53789f49d..7f0081425 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= @@ -111,8 +111,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= @@ -225,8 +225,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= @@ -307,8 +307,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= @@ -390,6 +390,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= @@ -499,8 +501,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= @@ -900,8 +902,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= 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..43ee2126d 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)) { @@ -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) } diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index 12402b420..b4a0d8b28 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -6,9 +6,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" @@ -185,9 +187,38 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb response.NetworkMap.SshAuth = &proto.SSHAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim} } + // 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 } +// 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 4abeb8e7c..e7155ae09 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -351,6 +351,7 @@ func (s *ProxyServiceServer) registerProxyConnection(ctx context.Context, params SupportsCustomPorts: c.SupportsCustomPorts, RequireSubdomain: c.RequireSubdomain, SupportsCrowdsec: c.SupportsCrowdsec, + Private: c.Private, } } @@ -754,6 +755,11 @@ func (s *ProxyServiceServer) SendServiceUpdate(update *proto.GetMappingUpdateRes 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) @@ -882,16 +888,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 } @@ -900,6 +910,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. @@ -961,7 +994,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 } @@ -1050,7 +1086,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 } @@ -1058,8 +1094,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 { @@ -1070,6 +1109,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 { @@ -1334,7 +1393,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 { @@ -1345,11 +1406,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, ) } @@ -1453,7 +1532,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, @@ -1466,7 +1545,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, @@ -1500,12 +1579,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 } @@ -1515,10 +1597,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 } @@ -1551,3 +1636,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_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 1d8234304..2d19ca32b 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 } @@ -821,6 +821,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 @@ -844,6 +918,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/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..7a65afbbd 100644 --- a/management/server/context/keys.go +++ b/management/server/context/keys.go @@ -1,10 +1,27 @@ 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 ) + +// 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 91026a374..1d4af95e9 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -405,48 +405,48 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) { return } - allowed, err := h.permissionsManager.ValidateUserPermissions(r.Context(), accountID, userID, modules.Peers, operations.Read) + allowed, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), accountID, userID, modules.Peers, operations.Read) if err != nil { - util.WriteError(r.Context(), status.NewPermissionValidationError(err), w) + util.WriteError(ctx, status.NewPermissionValidationError(err), w) return } - account, err := h.accountManager.GetAccountByID(r.Context(), accountID, activity.SystemInitiator) + account, err := h.accountManager.GetAccountByID(ctx, accountID, activity.SystemInitiator) if err != nil { - util.WriteError(r.Context(), err, w) + util.WriteError(ctx, err, w) return } if !allowed && !userAuth.IsChild { if account.Settings.RegularUsersViewBlocked { - util.WriteJSONObject(r.Context(), w, []api.AccessiblePeer{}) + util.WriteJSONObject(ctx, w, []api.AccessiblePeer{}) return } peer, ok := account.Peers[peerID] if !ok { - util.WriteError(r.Context(), status.Errorf(status.NotFound, "peer not found"), w) + util.WriteError(ctx, status.Errorf(status.NotFound, "peer not found"), w) return } if peer.UserID != user.Id { - util.WriteJSONObject(r.Context(), w, []api.AccessiblePeer{}) + util.WriteJSONObject(ctx, w, []api.AccessiblePeer{}) return } } - validPeers, _, err := h.accountManager.GetValidatedPeers(r.Context(), accountID) + validPeers, _, err := h.accountManager.GetValidatedPeers(ctx, accountID) if err != nil { - log.WithContext(r.Context()).Errorf("failed to list approved peers: %v", err) - util.WriteError(r.Context(), fmt.Errorf("internal error"), w) + log.WithContext(ctx).Errorf("failed to list approved peers: %v", err) + util.WriteError(ctx, fmt.Errorf("internal error"), w) return } dnsDomain := h.networkMapController.GetDNSDomain(account.Settings) - netMap := account.GetPeerNetworkMapFromComponents(r.Context(), peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers()) + netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers()) - util.WriteJSONObject(r.Context(), w, toAccessiblePeers(netMap, dnsDomain)) + util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, dnsDomain)) } func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) { 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/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/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 ed5b0e558..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) } @@ -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) } @@ -199,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/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 6294d1c0a..e5475c07d 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -367,6 +367,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 279c0e21f..b6691ac79 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) @@ -2178,7 +2210,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 +2226,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 +2253,8 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv &source, &sourcePeer, &terminated, + &private, + &accessGroups, ) if err != nil { return nil, err @@ -2230,6 +2266,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 @@ -4688,7 +4734,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") } @@ -5826,6 +5878,7 @@ var validCapabilityColumns = map[string]struct{}{ "supports_custom_ports": {}, "require_subdomain": {}, "supports_crowdsec": {}, + "private": {}, } // GetClusterSupportsCustomPorts returns whether any active proxy in the cluster @@ -5840,6 +5893,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 @@ -5909,6 +5968,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_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 41e3290b6..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) diff --git a/management/server/store/store.go b/management/server/store/store.go index 39b1c0ed3..746207f27 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -312,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) @@ -320,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 c7e86c2db..dfd5af78d 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -1461,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() @@ -2076,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() 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/types/account.go b/management/server/types/account.go index 870333a60..0d0893e28 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 @@ -254,6 +257,117 @@ 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) + zonesByCluster := 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 + } + + zone, exists := zonesByCluster[svc.ProxyCluster] + if !exists { + // NonAuthoritative makes this a match-only zone: queries for + // names without an explicit record fall through to the + // upstream resolver instead of returning NXDOMAIN. Without + // it, adding a single private service would black-hole every + // other name under the cluster apex. + zone = &nbdns.CustomZone{ + Domain: dns.Fqdn(svc.ProxyCluster), + Records: []nbdns.SimpleRecord{}, + NonAuthoritative: true, + } + zonesByCluster[svc.ProxyCluster] = 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(zonesByCluster)) + for _, zone := range zonesByCluster { + 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 +} + +// 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 @@ -1498,6 +1612,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) { @@ -1644,7 +1805,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..1d4f720b7 --- /dev/null +++ b/management/server/types/account_private_zones_test.go @@ -0,0 +1,256 @@ +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" + "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_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") +} diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index a1a616882..d8e2e1f8c 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/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..d0e11517e 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,24 @@ 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, GeoDataDir: geoDataDir, CrowdSecAPIURL: crowdsecAPIURL, CrowdSecAPIKey: crowdsecAPIKey, - } - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) - defer stop() + }) return srv.ListenAndServe(ctx, addr) } diff --git a/proxy/inbound.go b/proxy/inbound.go 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/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 28eba7810..1d1e68f4a 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" @@ -76,11 +77,11 @@ type clientEntry struct { services map[ServiceKey]serviceInfo createdAt time.Time started bool - // ready is closed once the client has been fully initialized. - // Callers that find a pending entry wait on this channel before - // accessing the client. A nil initErr means success. - ready chan struct{} - initErr error + // 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 @@ -88,6 +89,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) { @@ -117,6 +131,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 { @@ -131,6 +152,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 @@ -142,6 +164,14 @@ type NetBird struct { clients map[types.AccountID]*clientEntry 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 @@ -167,9 +197,6 @@ type skipTLSVerifyContextKey struct{} // AddPeer registers a service for an account. If the account doesn't have a client yet, // one is created by authenticating with the management server using the provided token. // Multiple services can share the same client. -// -// Client creation (WG keygen, gRPC, embed.New) runs without holding clientsMux -// so that concurrent AddPeer calls for different accounts execute in parallel. func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, serviceID types.ServiceID) error { si := serviceInfo{serviceID: serviceID} @@ -177,30 +204,21 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se entry, exists := n.clients[accountID] if exists { - ready := entry.ready entry.services[key] = si started := entry.started n.clientsMux.Unlock() - // If the entry is still being initialized by another goroutine, wait. - if ready != nil { - select { - case <-ready: - case <-ctx.Done(): - return ctx.Err() - } - if entry.initErr != nil { - return fmt.Errorf("peer initialization failed: %w", entry.initErr) - } - } - 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 { + // Use a background context, not the caller's: the management + // connection notification must land even if the request / + // stream that triggered this registration is cancelled. + // Mirrors the async runClientStartup path. + if err := n.statusNotifier.NotifyStatus(context.Background(), accountID, serviceID, true); err != nil { n.logger.WithFields(log.Fields{ "account_id": accountID, "service_key": key, @@ -210,57 +228,35 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se return nil } - // Insert a placeholder so other goroutines calling AddPeer for the same - // account will wait on the ready channel instead of starting a second - // client creation. - entry = &clientEntry{ - services: map[ServiceKey]serviceInfo{key: si}, - ready: make(chan struct{}), - } - n.clients[accountID] = entry - n.clientsMux.Unlock() - createStart := time.Now() - created, err := n.createClientEntry(ctx, accountID, key, authToken, si) + entry, err := n.createClientEntry(ctx, accountID, key, authToken, si) if n.OnAddPeer != nil { n.OnAddPeer(time.Since(createStart), err) } if err != nil { - entry.initErr = err - close(entry.ready) - - n.clientsMux.Lock() - delete(n.clients, accountID) n.clientsMux.Unlock() return err } - // Transfer any services that were registered by concurrent AddPeer calls - // while we were creating the client. - n.clientsMux.Lock() - for k, v := range entry.services { - created.services[k] = v - } - created.ready = nil - n.clients[accountID] = created + n.clients[accountID] = entry n.clientsMux.Unlock() - close(entry.ready) - n.logger.WithFields(log.Fields{ "account_id": accountID, "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, created.client) + // retry on the first request via RoundTrip. runClientStartup uses its + // own background context so the caller's request-scoped ctx can't + // cancel the inbound bring-up. + go n.runClientStartup(accountID, entry.client) return nil } // createClientEntry generates a WireGuard keypair, authenticates with management, -// and creates an embedded NetBird client. +// and creates an embedded NetBird client. Must be called with clientsMux 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{ @@ -318,9 +314,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) @@ -359,8 +362,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() @@ -373,7 +382,17 @@ func (n *NetBird) runClientStartup(ctx context.Context, accountID types.AccountI return } - // Mark client as started and collect services to notify outside the lock. + n.notifyClientReady(accountID, client) +} + +// notifyClientReady marks the account's client as started, fires the +// readyHandler hook, and notifies management of the new tunnel +// connection for every registered service. It is split out of +// runClientStartup so a regression test can drive the post-Start tail +// without needing a live embedded client. The contract that the +// hooks/notifier see context.Background() — never the AddPeer caller's +// ctx — lives here. +func (n *NetBird) notifyClientReady(accountID types.AccountID, client *embed.Client) { n.clientsMux.Lock() entry, exists := n.clients[accountID] if exists { @@ -385,13 +404,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, @@ -432,11 +468,15 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key stopClient := len(entry.services) == 0 var client *embed.Client var transport, insecureTransport *http.Transport + var inbound any + var stopHandler func(types.AccountID, any) if stopClient { n.logger.WithField("account_id", accountID).Info("stopping client, no more services") client = entry.client transport = entry.transport insecureTransport = entry.insecureTransport + inbound = entry.inbound + stopHandler = n.stopHandler delete(n.clients, accountID) } else { n.logger.WithFields(log.Fields{ @@ -450,6 +490,9 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key n.notifyDisconnect(ctx, accountID, key, si.serviceID) if stopClient { + if inbound != nil && stopHandler != nil { + stopHandler(accountID, inbound) + } transport.CloseIdleConnections() insecureTransport.CloseIdleConnections() if err := client.Stop(ctx); err != nil { @@ -536,8 +579,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 { @@ -590,6 +637,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() @@ -629,11 +689,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, @@ -645,6 +706,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. @@ -687,3 +760,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..b1c36b465 100644 --- a/proxy/internal/roundtrip/netbird_test.go +++ b/proxy/internal/roundtrip/netbird_test.go @@ -3,6 +3,7 @@ package roundtrip import ( "context" "net/http" + "net/netip" "sync" "testing" @@ -10,6 +11,7 @@ import ( "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" ) @@ -29,12 +31,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,7 +52,7 @@ 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{ + return NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{ MgmtAddr: "http://invalid.test:9999", WGPort: 0, PreSharedKey: "", @@ -278,7 +283,7 @@ 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: "", @@ -294,8 +299,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 +312,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 +371,53 @@ func TestNetBird_RemovePeer_NotifiesDisconnection(t *testing.T) { assert.Equal(t, types.ServiceID("svc-1"), calls[0].serviceID) assert.False(t, calls[0].connected) } + +// 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..41d4bc496 --- /dev/null +++ b/proxy/lifecycle.go @@ -0,0 +1,171 @@ +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 + + // 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, + 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/process_mappings_bench_test.go b/proxy/process_mappings_bench_test.go index ca0792590..919cab95c 100644 --- a/proxy/process_mappings_bench_test.go +++ b/proxy/process_mappings_bench_test.go @@ -73,7 +73,7 @@ func benchServerWithLatency(b *testing.B, createPeerDelay, statusDelay time.Dura statusUpdateDelay: statusDelay, } - nb := roundtrip.NewNetBird("bench-proxy", "bench.test", + nb := roundtrip.NewNetBird(b.Context(), "bench-proxy", "bench.test", roundtrip.ClientConfig{MgmtAddr: "http://bench.test:9999"}, logger, nil, mgmtClient) diff --git a/proxy/server.go b/proxy/server.go index 6ccfa3e9a..1f5e0abd6 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -37,8 +37,11 @@ import ( "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" @@ -72,6 +75,7 @@ type portRouter struct { } type Server struct { + ctx context.Context mgmtClient proto.ProxyServiceClient proxy *proxy.ReverseProxy netbird *roundtrip.NetBird @@ -114,9 +118,28 @@ type Server struct { // The mapping worker waits on this before processing updates. routerReady chan struct{} + // 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 @@ -164,6 +187,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. @@ -177,6 +203,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. @@ -222,12 +256,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 } @@ -238,54 +276,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.netbird.OnAddPeer = s.meter.RecordAddPeerDuration + 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) @@ -300,34 +352,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() @@ -336,35 +379,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, @@ -374,35 +403,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. @@ -434,6 +630,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, @@ -447,16 +646,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) { @@ -507,8 +708,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. @@ -661,8 +863,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. @@ -809,6 +1013,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) { @@ -942,7 +1158,8 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr } // syncSupported tracks whether management supports SyncMappings. - // Starts true; set to false on first Unimplemented error. + // Starts true; set to false on the first Unimplemented error so + // subsequent retries skip straight to GetMappingUpdate. syncSupported := true initialSyncDone := false @@ -992,10 +1209,15 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr 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, + SupportsCustomPorts: &s.SupportsCustomPorts, + RequireSubdomain: &s.RequireSubdomain, + SupportsCrowdsec: &supportsCrowdSec, + Private: &privateCapability, + SupportsPrivateService: &supportsPrivateService, } } @@ -1027,7 +1249,6 @@ func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceC return fmt.Errorf("create sync stream: %w", err) } - // Send init message. if err := stream.Send(&proto.SyncMappingsRequest{ Msg: &proto.SyncMappingsRequest_Init{ Init: &proto.SyncMappingsInit{ @@ -1058,6 +1279,10 @@ func isSyncUnimplemented(err error) bool { 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: @@ -1095,39 +1320,10 @@ func (s *Server) handleSyncMappingsStream(ctx context.Context, stream proto.Prox } } -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 { - select { - case <-ctx.Done(): - return ctx.Err() - default: - msg, err := mappingClient.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") - s.processMappings(ctx, msg.GetMapping()) - s.Logger.Debug("Processing mapping update completed") - tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart) - } - } -} - // snapshotTracker accumulates service IDs during the initial snapshot and -// finalises sync state when the complete flag arrives. +// 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 @@ -1171,6 +1367,37 @@ func (t *snapshotTracker) recordBatch(ctx context.Context, s *Server, mappings [ 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 { + select { + case <-ctx.Done(): + return ctx.Err() + default: + msg, err := mappingClient.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") + s.processMappings(ctx, msg.GetMapping()) + s.Logger.Debug("Processing mapping update completed") + tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart) + } + } +} + // reconcileSnapshot removes local mappings that are absent from the snapshot. // This ensures services deleted while the proxy was disconnected get cleaned up. func (s *Server) reconcileSnapshot(ctx context.Context, snapshotIDs map[types.ServiceID]struct{}) { @@ -1192,17 +1419,58 @@ func (s *Server) reconcileSnapshot(ctx context.Context, snapshotIDs map[types.Se } } -func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMapping) { - s.ensurePeers(ctx, mappings) +// 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 +} + +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 { @@ -1228,60 +1496,6 @@ func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMap } } -// ensurePeers pre-creates NetBird peers for all unique accounts referenced by -// CREATED mappings. Peers for different accounts are created concurrently, -// which avoids serializing N×100ms gRPC round-trips during large initial syncs. -func (s *Server) ensurePeers(ctx context.Context, mappings []*proto.ProxyMapping) { - // Collect one representative mapping per account that needs a new peer. - type peerReq struct { - accountID types.AccountID - svcKey roundtrip.ServiceKey - authToken string - svcID types.ServiceID - } - seen := make(map[types.AccountID]struct{}) - var reqs []peerReq - for _, m := range mappings { - if m.GetType() != proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED { - continue - } - accountID := types.AccountID(m.GetAccountId()) - if _, ok := seen[accountID]; ok { - continue - } - seen[accountID] = struct{}{} - if s.netbird.HasClient(accountID) { - continue - } - reqs = append(reqs, peerReq{ - accountID: accountID, - svcKey: s.serviceKeyForMapping(m), - authToken: m.GetAuthToken(), - svcID: types.ServiceID(m.GetId()), - }) - } - - if len(reqs) <= 1 { - return - } - - var wg sync.WaitGroup - wg.Add(len(reqs)) - for _, r := range reqs { - go func() { - defer wg.Done() - if err := s.netbird.AddPeer(ctx, r.accountID, r.svcKey, r.authToken, r.svcID); err != nil { - s.Logger.WithFields(log.Fields{ - "account_id": r.accountID, - "service_id": r.svcID, - "error": err, - }).Warn("failed to pre-create peer for account") - } - }() - } - wg.Wait() -} - // addMapping registers a service mapping and starts the appropriate relay or routes. func (s *Server) addMapping(ctx context.Context, mapping *proto.ProxyMapping) error { accountID := types.AccountID(mapping.GetAccountId()) @@ -1353,12 +1567,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) } @@ -1718,7 +1936,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) @@ -1774,6 +1992,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. @@ -1861,6 +2082,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/shared/context/keys.go b/shared/context/keys.go index c5b5da044..ca56be67e 100644 --- a/shared/context/keys.go +++ b/shared/context/keys.go @@ -3,6 +3,7 @@ package context const ( RequestIDKey = "requestID" AccountIDKey = "accountID" + RoleKey = "role" UserIDKey = "userID" PeerIDKey = "peerID" ) 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 58895b7c2..016cde68a 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 353aff72d..03e30e6b7 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -3067,6 +3067,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 @@ -3075,6 +3086,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: @@ -3147,10 +3176,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: @@ -3185,6 +3242,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: @@ -3195,7 +3261,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 @@ -3439,6 +3505,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 @@ -3494,6 +3564,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 diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 16e765f8c..d7945e448 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: @@ -3819,6 +3822,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"` @@ -3896,6 +3902,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"` @@ -4085,6 +4094,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"` @@ -4114,6 +4126,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"` @@ -4156,6 +4171,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"` @@ -4178,6 +4196,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"` @@ -4224,6 +4245,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 13f4fbc8d..5dd529407 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{31, 0} + return file_management_proto_rawDescGZIP(), []int{33, 0} } type EncryptedMessage struct { @@ -843,6 +843,11 @@ 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"` + // Absolute UTC instant at which the peer's SSO session expires. + // Unset when the peer is not SSO-registered or login expiration is disabled. + // Carried on every Sync snapshot so admin-side changes propagate live without + // a client reconnect. + SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"` } func (x *SyncResponse) Reset() { @@ -919,6 +924,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 @@ -1596,6 +1608,9 @@ 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"` + // Absolute UTC instant at which the peer's SSO session expires. + // Unset when the peer is not SSO-registered or login expiration is disabled. + SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"` } func (x *LoginResponse) Reset() { @@ -1651,6 +1666,122 @@ 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 + + // Absolute UTC instant at which the peer's SSO session now expires. + 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 @@ -1667,7 +1798,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) } @@ -1680,7 +1811,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 { @@ -1693,7 +1824,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 { @@ -1726,7 +1857,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) } @@ -1739,7 +1870,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 { @@ -1752,7 +1883,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 @@ -1774,7 +1905,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) } @@ -1787,7 +1918,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 { @@ -1800,7 +1931,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 { @@ -1852,7 +1983,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) } @@ -1865,7 +1996,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 { @@ -1878,7 +2009,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 { @@ -1908,7 +2039,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) } @@ -1921,7 +2052,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 { @@ -1934,7 +2065,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 { @@ -1979,7 +2110,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) } @@ -1992,7 +2123,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 { @@ -2005,7 +2136,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 { @@ -2083,7 +2214,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) } @@ -2096,7 +2227,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 { @@ -2109,7 +2240,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 { @@ -2162,7 +2293,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) } @@ -2175,7 +2306,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 { @@ -2188,7 +2319,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 { @@ -2239,7 +2370,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) } @@ -2252,7 +2383,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 { @@ -2265,7 +2396,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 { @@ -2345,7 +2476,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) } @@ -2358,7 +2489,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 { @@ -2371,7 +2502,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 { @@ -2426,7 +2557,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) } @@ -2439,7 +2570,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 { @@ -2452,7 +2583,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 { @@ -2562,7 +2693,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) } @@ -2575,7 +2706,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 { @@ -2588,7 +2719,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 { @@ -2623,7 +2754,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) } @@ -2636,7 +2767,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 { @@ -2649,7 +2780,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 { @@ -2680,7 +2811,7 @@ type RemotePeerConfig struct { func (x *RemotePeerConfig) Reset() { *x = RemotePeerConfig{} 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) } @@ -2693,7 +2824,7 @@ func (x *RemotePeerConfig) String() string { func (*RemotePeerConfig) ProtoMessage() {} func (x *RemotePeerConfig) 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 { @@ -2706,7 +2837,7 @@ func (x *RemotePeerConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use RemotePeerConfig.ProtoReflect.Descriptor instead. func (*RemotePeerConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{28} + return file_management_proto_rawDescGZIP(), []int{30} } func (x *RemotePeerConfig) GetWgPubKey() string { @@ -2761,7 +2892,7 @@ type SSHConfig struct { func (x *SSHConfig) Reset() { *x = SSHConfig{} 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) } @@ -2774,7 +2905,7 @@ func (x *SSHConfig) String() string { func (*SSHConfig) ProtoMessage() {} func (x *SSHConfig) 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 { @@ -2787,7 +2918,7 @@ func (x *SSHConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use SSHConfig.ProtoReflect.Descriptor instead. func (*SSHConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{29} + return file_management_proto_rawDescGZIP(), []int{31} } func (x *SSHConfig) GetSshEnabled() bool { @@ -2821,7 +2952,7 @@ type DeviceAuthorizationFlowRequest struct { func (x *DeviceAuthorizationFlowRequest) Reset() { *x = DeviceAuthorizationFlowRequest{} 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) } @@ -2834,7 +2965,7 @@ func (x *DeviceAuthorizationFlowRequest) String() string { func (*DeviceAuthorizationFlowRequest) ProtoMessage() {} func (x *DeviceAuthorizationFlowRequest) 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 { @@ -2847,7 +2978,7 @@ func (x *DeviceAuthorizationFlowRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeviceAuthorizationFlowRequest.ProtoReflect.Descriptor instead. func (*DeviceAuthorizationFlowRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{30} + return file_management_proto_rawDescGZIP(), []int{32} } // DeviceAuthorizationFlow represents Device Authorization Flow information @@ -2866,7 +2997,7 @@ type DeviceAuthorizationFlow struct { func (x *DeviceAuthorizationFlow) Reset() { *x = DeviceAuthorizationFlow{} 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) } @@ -2879,7 +3010,7 @@ func (x *DeviceAuthorizationFlow) String() string { func (*DeviceAuthorizationFlow) ProtoMessage() {} func (x *DeviceAuthorizationFlow) 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 { @@ -2892,7 +3023,7 @@ func (x *DeviceAuthorizationFlow) ProtoReflect() protoreflect.Message { // Deprecated: Use DeviceAuthorizationFlow.ProtoReflect.Descriptor instead. func (*DeviceAuthorizationFlow) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{31} + return file_management_proto_rawDescGZIP(), []int{33} } func (x *DeviceAuthorizationFlow) GetProvider() DeviceAuthorizationFlowProvider { @@ -2919,7 +3050,7 @@ type PKCEAuthorizationFlowRequest struct { func (x *PKCEAuthorizationFlowRequest) Reset() { *x = PKCEAuthorizationFlowRequest{} 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) } @@ -2932,7 +3063,7 @@ func (x *PKCEAuthorizationFlowRequest) String() string { func (*PKCEAuthorizationFlowRequest) ProtoMessage() {} func (x *PKCEAuthorizationFlowRequest) 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 { @@ -2945,7 +3076,7 @@ func (x *PKCEAuthorizationFlowRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PKCEAuthorizationFlowRequest.ProtoReflect.Descriptor instead. func (*PKCEAuthorizationFlowRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{32} + return file_management_proto_rawDescGZIP(), []int{34} } // PKCEAuthorizationFlow represents Authorization Code Flow information @@ -2962,7 +3093,7 @@ type PKCEAuthorizationFlow struct { func (x *PKCEAuthorizationFlow) Reset() { *x = PKCEAuthorizationFlow{} 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) } @@ -2975,7 +3106,7 @@ func (x *PKCEAuthorizationFlow) String() string { func (*PKCEAuthorizationFlow) ProtoMessage() {} func (x *PKCEAuthorizationFlow) 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 { @@ -2988,7 +3119,7 @@ func (x *PKCEAuthorizationFlow) ProtoReflect() protoreflect.Message { // Deprecated: Use PKCEAuthorizationFlow.ProtoReflect.Descriptor instead. func (*PKCEAuthorizationFlow) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{33} + return file_management_proto_rawDescGZIP(), []int{35} } func (x *PKCEAuthorizationFlow) GetProviderConfig() *ProviderConfig { @@ -3036,7 +3167,7 @@ type ProviderConfig struct { func (x *ProviderConfig) Reset() { *x = ProviderConfig{} 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) } @@ -3049,7 +3180,7 @@ func (x *ProviderConfig) String() string { func (*ProviderConfig) ProtoMessage() {} func (x *ProviderConfig) 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 { @@ -3062,7 +3193,7 @@ func (x *ProviderConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderConfig.ProtoReflect.Descriptor instead. func (*ProviderConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{34} + return file_management_proto_rawDescGZIP(), []int{36} } func (x *ProviderConfig) GetClientID() string { @@ -3171,7 +3302,7 @@ type Route struct { func (x *Route) Reset() { *x = Route{} 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) } @@ -3184,7 +3315,7 @@ func (x *Route) String() string { func (*Route) ProtoMessage() {} func (x *Route) 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 { @@ -3197,7 +3328,7 @@ func (x *Route) ProtoReflect() protoreflect.Message { // Deprecated: Use Route.ProtoReflect.Descriptor instead. func (*Route) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{35} + return file_management_proto_rawDescGZIP(), []int{37} } func (x *Route) GetID() string { @@ -3286,7 +3417,7 @@ type DNSConfig struct { func (x *DNSConfig) Reset() { *x = DNSConfig{} 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) } @@ -3299,7 +3430,7 @@ func (x *DNSConfig) String() string { func (*DNSConfig) ProtoMessage() {} func (x *DNSConfig) 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 { @@ -3312,7 +3443,7 @@ func (x *DNSConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use DNSConfig.ProtoReflect.Descriptor instead. func (*DNSConfig) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{36} + return file_management_proto_rawDescGZIP(), []int{38} } func (x *DNSConfig) GetServiceEnable() bool { @@ -3361,7 +3492,7 @@ type CustomZone struct { func (x *CustomZone) Reset() { *x = CustomZone{} 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) } @@ -3374,7 +3505,7 @@ func (x *CustomZone) String() string { func (*CustomZone) ProtoMessage() {} func (x *CustomZone) 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 { @@ -3387,7 +3518,7 @@ func (x *CustomZone) ProtoReflect() protoreflect.Message { // Deprecated: Use CustomZone.ProtoReflect.Descriptor instead. func (*CustomZone) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{37} + return file_management_proto_rawDescGZIP(), []int{39} } func (x *CustomZone) GetDomain() string { @@ -3434,7 +3565,7 @@ type SimpleRecord struct { func (x *SimpleRecord) Reset() { *x = SimpleRecord{} 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) } @@ -3447,7 +3578,7 @@ func (x *SimpleRecord) String() string { func (*SimpleRecord) ProtoMessage() {} func (x *SimpleRecord) 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 { @@ -3460,7 +3591,7 @@ func (x *SimpleRecord) ProtoReflect() protoreflect.Message { // Deprecated: Use SimpleRecord.ProtoReflect.Descriptor instead. func (*SimpleRecord) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{38} + return file_management_proto_rawDescGZIP(), []int{40} } func (x *SimpleRecord) GetName() string { @@ -3513,7 +3644,7 @@ type NameServerGroup struct { func (x *NameServerGroup) Reset() { *x = NameServerGroup{} 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) } @@ -3526,7 +3657,7 @@ func (x *NameServerGroup) String() string { func (*NameServerGroup) ProtoMessage() {} func (x *NameServerGroup) 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 { @@ -3539,7 +3670,7 @@ func (x *NameServerGroup) ProtoReflect() protoreflect.Message { // Deprecated: Use NameServerGroup.ProtoReflect.Descriptor instead. func (*NameServerGroup) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{39} + return file_management_proto_rawDescGZIP(), []int{41} } func (x *NameServerGroup) GetNameServers() []*NameServer { @@ -3584,7 +3715,7 @@ type NameServer struct { func (x *NameServer) Reset() { *x = NameServer{} 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) } @@ -3597,7 +3728,7 @@ func (x *NameServer) String() string { func (*NameServer) ProtoMessage() {} func (x *NameServer) 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 { @@ -3610,7 +3741,7 @@ func (x *NameServer) ProtoReflect() protoreflect.Message { // Deprecated: Use NameServer.ProtoReflect.Descriptor instead. func (*NameServer) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{40} + return file_management_proto_rawDescGZIP(), []int{42} } func (x *NameServer) GetIP() string { @@ -3661,7 +3792,7 @@ type FirewallRule struct { func (x *FirewallRule) Reset() { *x = FirewallRule{} 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) } @@ -3674,7 +3805,7 @@ func (x *FirewallRule) String() string { func (*FirewallRule) ProtoMessage() {} func (x *FirewallRule) 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 { @@ -3687,7 +3818,7 @@ func (x *FirewallRule) ProtoReflect() protoreflect.Message { // Deprecated: Use FirewallRule.ProtoReflect.Descriptor instead. func (*FirewallRule) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{41} + return file_management_proto_rawDescGZIP(), []int{43} } // Deprecated: Do not use. @@ -3766,7 +3897,7 @@ type NetworkAddress struct { func (x *NetworkAddress) Reset() { *x = NetworkAddress{} 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) } @@ -3779,7 +3910,7 @@ func (x *NetworkAddress) String() string { func (*NetworkAddress) ProtoMessage() {} func (x *NetworkAddress) 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 { @@ -3792,7 +3923,7 @@ func (x *NetworkAddress) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkAddress.ProtoReflect.Descriptor instead. func (*NetworkAddress) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{42} + return file_management_proto_rawDescGZIP(), []int{44} } func (x *NetworkAddress) GetNetIP() string { @@ -3820,7 +3951,7 @@ type Checks struct { func (x *Checks) Reset() { *x = Checks{} 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) } @@ -3833,7 +3964,7 @@ func (x *Checks) String() string { func (*Checks) ProtoMessage() {} func (x *Checks) 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 { @@ -3846,7 +3977,7 @@ func (x *Checks) ProtoReflect() protoreflect.Message { // Deprecated: Use Checks.ProtoReflect.Descriptor instead. func (*Checks) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{43} + return file_management_proto_rawDescGZIP(), []int{45} } func (x *Checks) GetFiles() []string { @@ -3871,7 +4002,7 @@ type PortInfo struct { func (x *PortInfo) Reset() { *x = PortInfo{} 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) } @@ -3884,7 +4015,7 @@ func (x *PortInfo) String() string { func (*PortInfo) ProtoMessage() {} func (x *PortInfo) 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 { @@ -3897,7 +4028,7 @@ func (x *PortInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo.ProtoReflect.Descriptor instead. func (*PortInfo) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{44} + return file_management_proto_rawDescGZIP(), []int{46} } func (m *PortInfo) GetPortSelection() isPortInfo_PortSelection { @@ -3968,7 +4099,7 @@ type RouteFirewallRule struct { func (x *RouteFirewallRule) Reset() { *x = RouteFirewallRule{} 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) } @@ -3981,7 +4112,7 @@ func (x *RouteFirewallRule) String() string { func (*RouteFirewallRule) ProtoMessage() {} func (x *RouteFirewallRule) 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 { @@ -3994,7 +4125,7 @@ func (x *RouteFirewallRule) ProtoReflect() protoreflect.Message { // Deprecated: Use RouteFirewallRule.ProtoReflect.Descriptor instead. func (*RouteFirewallRule) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{45} + return file_management_proto_rawDescGZIP(), []int{47} } func (x *RouteFirewallRule) GetSourceRanges() []string { @@ -4085,7 +4216,7 @@ type ForwardingRule struct { func (x *ForwardingRule) Reset() { *x = ForwardingRule{} 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) } @@ -4098,7 +4229,7 @@ func (x *ForwardingRule) String() string { func (*ForwardingRule) ProtoMessage() {} func (x *ForwardingRule) 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 { @@ -4111,7 +4242,7 @@ func (x *ForwardingRule) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardingRule.ProtoReflect.Descriptor instead. func (*ForwardingRule) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{46} + return file_management_proto_rawDescGZIP(), []int{48} } func (x *ForwardingRule) GetProtocol() RuleProtocol { @@ -4160,7 +4291,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} 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) } @@ -4173,7 +4304,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) 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 { @@ -4186,7 +4317,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{47} + return file_management_proto_rawDescGZIP(), []int{49} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -4259,7 +4390,7 @@ type ExposeServiceResponse struct { func (x *ExposeServiceResponse) Reset() { *x = ExposeServiceResponse{} 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) } @@ -4272,7 +4403,7 @@ func (x *ExposeServiceResponse) String() string { func (*ExposeServiceResponse) ProtoMessage() {} func (x *ExposeServiceResponse) 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 { @@ -4285,7 +4416,7 @@ func (x *ExposeServiceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceResponse.ProtoReflect.Descriptor instead. func (*ExposeServiceResponse) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{48} + return file_management_proto_rawDescGZIP(), []int{50} } func (x *ExposeServiceResponse) GetServiceName() string { @@ -4327,7 +4458,7 @@ type RenewExposeRequest struct { func (x *RenewExposeRequest) Reset() { *x = RenewExposeRequest{} 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) } @@ -4340,7 +4471,7 @@ func (x *RenewExposeRequest) String() string { func (*RenewExposeRequest) ProtoMessage() {} func (x *RenewExposeRequest) 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 { @@ -4353,7 +4484,7 @@ func (x *RenewExposeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RenewExposeRequest.ProtoReflect.Descriptor instead. func (*RenewExposeRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{49} + return file_management_proto_rawDescGZIP(), []int{51} } func (x *RenewExposeRequest) GetDomain() string { @@ -4372,7 +4503,7 @@ type RenewExposeResponse struct { func (x *RenewExposeResponse) Reset() { *x = RenewExposeResponse{} 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) } @@ -4385,7 +4516,7 @@ func (x *RenewExposeResponse) String() string { func (*RenewExposeResponse) ProtoMessage() {} func (x *RenewExposeResponse) 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 { @@ -4398,7 +4529,7 @@ func (x *RenewExposeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RenewExposeResponse.ProtoReflect.Descriptor instead. func (*RenewExposeResponse) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{50} + return file_management_proto_rawDescGZIP(), []int{52} } type StopExposeRequest struct { @@ -4412,7 +4543,7 @@ type StopExposeRequest struct { func (x *StopExposeRequest) Reset() { *x = StopExposeRequest{} 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) } @@ -4425,7 +4556,7 @@ func (x *StopExposeRequest) String() string { func (*StopExposeRequest) ProtoMessage() {} func (x *StopExposeRequest) 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 { @@ -4438,7 +4569,7 @@ func (x *StopExposeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopExposeRequest.ProtoReflect.Descriptor instead. func (*StopExposeRequest) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{51} + return file_management_proto_rawDescGZIP(), []int{53} } func (x *StopExposeRequest) GetDomain() string { @@ -4457,7 +4588,7 @@ type StopExposeResponse struct { func (x *StopExposeResponse) Reset() { *x = StopExposeResponse{} 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) } @@ -4470,7 +4601,7 @@ func (x *StopExposeResponse) String() string { func (*StopExposeResponse) ProtoMessage() {} func (x *StopExposeResponse) 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 { @@ -4483,7 +4614,7 @@ func (x *StopExposeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopExposeResponse.ProtoReflect.Descriptor instead. func (*StopExposeResponse) Descriptor() ([]byte, []int) { - return file_management_proto_rawDescGZIP(), []int{52} + return file_management_proto_rawDescGZIP(), []int{54} } type PortInfo_Range struct { @@ -4498,7 +4629,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} 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) } @@ -4511,7 +4642,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) 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 { @@ -4524,7 +4655,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{44, 0} + return file_management_proto_rawDescGZIP(), []int{46, 0} } func (x *PortInfo_Range) GetStart() uint32 { @@ -4590,7 +4721,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, @@ -4612,630 +4743,657 @@ 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, 0xe1, 0x05, 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, 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, + 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, 0xe1, + 0x05, 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, 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, 0xe8, 0x05, 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, + 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, 0xe8, 0x05, 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, - 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, 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, + 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, 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, 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, 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, + 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 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, 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, 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, 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, + 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, 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, 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, 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, 0x6e, 0x74, + 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, 0x67, 0x65, + 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, 0x65, 0x6e, + 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, + 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, @@ -5267,7 +5425,7 @@ func file_management_proto_rawDescGZIP() []byte { } var file_management_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 55) +var file_management_proto_msgTypes = make([]protoimpl.MessageInfo, 57) var file_management_proto_goTypes = []interface{}{ (JobStatus)(0), // 0: management.JobStatus (PeerCapability)(0), // 1: management.PeerCapability @@ -5292,142 +5450,150 @@ 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 - (*RemotePeerConfig)(nil), // 36: management.RemotePeerConfig - (*SSHConfig)(nil), // 37: management.SSHConfig - (*DeviceAuthorizationFlowRequest)(nil), // 38: management.DeviceAuthorizationFlowRequest - (*DeviceAuthorizationFlow)(nil), // 39: management.DeviceAuthorizationFlow - (*PKCEAuthorizationFlowRequest)(nil), // 40: management.PKCEAuthorizationFlowRequest - (*PKCEAuthorizationFlow)(nil), // 41: management.PKCEAuthorizationFlow - (*ProviderConfig)(nil), // 42: management.ProviderConfig - (*Route)(nil), // 43: management.Route - (*DNSConfig)(nil), // 44: management.DNSConfig - (*CustomZone)(nil), // 45: management.CustomZone - (*SimpleRecord)(nil), // 46: management.SimpleRecord - (*NameServerGroup)(nil), // 47: management.NameServerGroup - (*NameServer)(nil), // 48: management.NameServer - (*FirewallRule)(nil), // 49: management.FirewallRule - (*NetworkAddress)(nil), // 50: management.NetworkAddress - (*Checks)(nil), // 51: management.Checks - (*PortInfo)(nil), // 52: management.PortInfo - (*RouteFirewallRule)(nil), // 53: management.RouteFirewallRule - (*ForwardingRule)(nil), // 54: management.ForwardingRule - (*ExposeServiceRequest)(nil), // 55: management.ExposeServiceRequest - (*ExposeServiceResponse)(nil), // 56: management.ExposeServiceResponse - (*RenewExposeRequest)(nil), // 57: management.RenewExposeRequest - (*RenewExposeResponse)(nil), // 58: management.RenewExposeResponse - (*StopExposeRequest)(nil), // 59: management.StopExposeRequest - (*StopExposeResponse)(nil), // 60: management.StopExposeResponse - nil, // 61: management.SSHAuth.MachineUsersEntry - (*PortInfo_Range)(nil), // 62: management.PortInfo.Range - (*timestamppb.Timestamp)(nil), // 63: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 64: 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 + (*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 + (*PortInfo_Range)(nil), // 64: management.PortInfo.Range + (*timestamppb.Timestamp)(nil), // 65: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 66: 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 - 36, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig - 33, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap - 51, // 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 - 50, // 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 - 51, // 19: management.LoginResponse.Checks:type_name -> management.Checks - 63, // 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 - 64, // 27: management.FlowConfig.interval:type_name -> google.protobuf.Duration - 26, // 28: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig - 37, // 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 - 36, // 32: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig - 43, // 33: management.NetworkMap.Routes:type_name -> management.Route - 44, // 34: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig - 36, // 35: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig - 49, // 36: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule - 53, // 37: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule - 54, // 38: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule - 34, // 39: management.NetworkMap.sshAuth:type_name -> management.SSHAuth - 61, // 40: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry - 37, // 41: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig - 29, // 42: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig - 7, // 43: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider - 42, // 44: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig - 42, // 45: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig - 47, // 46: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup - 45, // 47: management.DNSConfig.CustomZones:type_name -> management.CustomZone - 46, // 48: management.CustomZone.Records:type_name -> management.SimpleRecord - 48, // 49: management.NameServerGroup.NameServers:type_name -> management.NameServer - 3, // 50: management.FirewallRule.Direction:type_name -> management.RuleDirection - 4, // 51: management.FirewallRule.Action:type_name -> management.RuleAction - 2, // 52: management.FirewallRule.Protocol:type_name -> management.RuleProtocol - 52, // 53: management.FirewallRule.PortInfo:type_name -> management.PortInfo - 62, // 54: management.PortInfo.range:type_name -> management.PortInfo.Range - 4, // 55: management.RouteFirewallRule.action:type_name -> management.RuleAction - 2, // 56: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol - 52, // 57: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo - 2, // 58: management.ForwardingRule.protocol:type_name -> management.RuleProtocol - 52, // 59: management.ForwardingRule.destinationPort:type_name -> management.PortInfo - 52, // 60: management.ForwardingRule.translatedPort:type_name -> management.PortInfo - 5, // 61: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol - 35, // 62: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes - 8, // 63: management.ManagementService.Login:input_type -> management.EncryptedMessage - 8, // 64: management.ManagementService.Sync:input_type -> management.EncryptedMessage - 24, // 65: management.ManagementService.GetServerKey:input_type -> management.Empty - 24, // 66: management.ManagementService.isHealthy:input_type -> management.Empty - 8, // 67: management.ManagementService.GetDeviceAuthorizationFlow:input_type -> management.EncryptedMessage - 8, // 68: management.ManagementService.GetPKCEAuthorizationFlow:input_type -> management.EncryptedMessage - 8, // 69: management.ManagementService.SyncMeta:input_type -> management.EncryptedMessage - 8, // 70: management.ManagementService.Logout:input_type -> management.EncryptedMessage - 8, // 71: management.ManagementService.Job:input_type -> management.EncryptedMessage - 8, // 72: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage - 8, // 73: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage - 8, // 74: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage - 8, // 75: management.ManagementService.Login:output_type -> management.EncryptedMessage - 8, // 76: management.ManagementService.Sync:output_type -> management.EncryptedMessage - 23, // 77: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse - 24, // 78: management.ManagementService.isHealthy:output_type -> management.Empty - 8, // 79: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage - 8, // 80: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage - 24, // 81: management.ManagementService.SyncMeta:output_type -> management.Empty - 24, // 82: management.ManagementService.Logout:output_type -> management.Empty - 8, // 83: management.ManagementService.Job:output_type -> management.EncryptedMessage - 8, // 84: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage - 8, // 85: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage - 8, // 86: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage - 75, // [75:87] is the sub-list for method output_type - 63, // [63:75] is the sub-list for method input_type - 63, // [63:63] is the sub-list for extension type_name - 63, // [63:63] is the sub-list for extension extendee - 0, // [0:63] 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 + 38, // 6: management.SyncResponse.remotePeers:type_name -> management.RemotePeerConfig + 35, // 7: management.SyncResponse.NetworkMap:type_name -> management.NetworkMap + 53, // 8: management.SyncResponse.Checks:type_name -> management.Checks + 65, // 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 + 52, // 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 + 53, // 20: management.LoginResponse.Checks:type_name -> management.Checks + 65, // 21: management.LoginResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 21, // 22: management.ExtendAuthSessionRequest.meta:type_name -> management.PeerSystemMeta + 65, // 23: management.ExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 65, // 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 + 66, // 31: management.FlowConfig.interval:type_name -> google.protobuf.Duration + 28, // 32: management.ProtectedHostConfig.hostConfig:type_name -> management.HostConfig + 39, // 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 + 38, // 36: management.NetworkMap.remotePeers:type_name -> management.RemotePeerConfig + 45, // 37: management.NetworkMap.Routes:type_name -> management.Route + 46, // 38: management.NetworkMap.DNSConfig:type_name -> management.DNSConfig + 38, // 39: management.NetworkMap.offlinePeers:type_name -> management.RemotePeerConfig + 51, // 40: management.NetworkMap.FirewallRules:type_name -> management.FirewallRule + 55, // 41: management.NetworkMap.routesFirewallRules:type_name -> management.RouteFirewallRule + 56, // 42: management.NetworkMap.forwardingRules:type_name -> management.ForwardingRule + 36, // 43: management.NetworkMap.sshAuth:type_name -> management.SSHAuth + 63, // 44: management.SSHAuth.machine_users:type_name -> management.SSHAuth.MachineUsersEntry + 39, // 45: management.RemotePeerConfig.sshConfig:type_name -> management.SSHConfig + 31, // 46: management.SSHConfig.jwtConfig:type_name -> management.JWTConfig + 7, // 47: management.DeviceAuthorizationFlow.Provider:type_name -> management.DeviceAuthorizationFlow.provider + 44, // 48: management.DeviceAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig + 44, // 49: management.PKCEAuthorizationFlow.ProviderConfig:type_name -> management.ProviderConfig + 49, // 50: management.DNSConfig.NameServerGroups:type_name -> management.NameServerGroup + 47, // 51: management.DNSConfig.CustomZones:type_name -> management.CustomZone + 48, // 52: management.CustomZone.Records:type_name -> management.SimpleRecord + 50, // 53: management.NameServerGroup.NameServers:type_name -> management.NameServer + 3, // 54: management.FirewallRule.Direction:type_name -> management.RuleDirection + 4, // 55: management.FirewallRule.Action:type_name -> management.RuleAction + 2, // 56: management.FirewallRule.Protocol:type_name -> management.RuleProtocol + 54, // 57: management.FirewallRule.PortInfo:type_name -> management.PortInfo + 64, // 58: management.PortInfo.range:type_name -> management.PortInfo.Range + 4, // 59: management.RouteFirewallRule.action:type_name -> management.RuleAction + 2, // 60: management.RouteFirewallRule.protocol:type_name -> management.RuleProtocol + 54, // 61: management.RouteFirewallRule.portInfo:type_name -> management.PortInfo + 2, // 62: management.ForwardingRule.protocol:type_name -> management.RuleProtocol + 54, // 63: management.ForwardingRule.destinationPort:type_name -> management.PortInfo + 54, // 64: management.ForwardingRule.translatedPort:type_name -> management.PortInfo + 5, // 65: management.ExposeServiceRequest.protocol:type_name -> management.ExposeProtocol + 37, // 66: management.SSHAuth.MachineUsersEntry.value:type_name -> management.MachineUserIndexes + 8, // 67: management.ManagementService.Login:input_type -> management.EncryptedMessage + 8, // 68: management.ManagementService.Sync:input_type -> management.EncryptedMessage + 26, // 69: management.ManagementService.GetServerKey:input_type -> management.Empty + 26, // 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.ExtendAuthSession:input_type -> management.EncryptedMessage + 8, // 77: management.ManagementService.CreateExpose:input_type -> management.EncryptedMessage + 8, // 78: management.ManagementService.RenewExpose:input_type -> management.EncryptedMessage + 8, // 79: management.ManagementService.StopExpose:input_type -> management.EncryptedMessage + 8, // 80: management.ManagementService.Login:output_type -> management.EncryptedMessage + 8, // 81: management.ManagementService.Sync:output_type -> management.EncryptedMessage + 25, // 82: management.ManagementService.GetServerKey:output_type -> management.ServerKeyResponse + 26, // 83: management.ManagementService.isHealthy:output_type -> management.Empty + 8, // 84: management.ManagementService.GetDeviceAuthorizationFlow:output_type -> management.EncryptedMessage + 8, // 85: management.ManagementService.GetPKCEAuthorizationFlow:output_type -> management.EncryptedMessage + 26, // 86: management.ManagementService.SyncMeta:output_type -> management.Empty + 26, // 87: management.ManagementService.Logout:output_type -> management.Empty + 8, // 88: management.ManagementService.Job:output_type -> management.EncryptedMessage + 8, // 89: management.ManagementService.ExtendAuthSession:output_type -> management.EncryptedMessage + 8, // 90: management.ManagementService.CreateExpose:output_type -> management.EncryptedMessage + 8, // 91: management.ManagementService.RenewExpose:output_type -> management.EncryptedMessage + 8, // 92: management.ManagementService.StopExpose:output_type -> management.EncryptedMessage + 80, // [80:93] is the sub-list for method output_type + 67, // [67:80] 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 } func init() { file_management_proto_init() } @@ -5617,7 +5783,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: @@ -5629,7 +5795,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: @@ -5641,7 +5807,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: @@ -5653,7 +5819,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: @@ -5665,7 +5831,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: @@ -5677,7 +5843,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: @@ -5689,7 +5855,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: @@ -5701,7 +5867,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: @@ -5713,7 +5879,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: @@ -5725,7 +5891,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: @@ -5737,7 +5903,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: @@ -5749,7 +5915,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: @@ -5761,7 +5927,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: @@ -5773,7 +5939,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RemotePeerConfig); i { + switch v := v.(*SSHAuth); i { case 0: return &v.state case 1: @@ -5785,7 +5951,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SSHConfig); i { + switch v := v.(*MachineUserIndexes); i { case 0: return &v.state case 1: @@ -5797,7 +5963,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeviceAuthorizationFlowRequest); i { + switch v := v.(*RemotePeerConfig); i { case 0: return &v.state case 1: @@ -5809,7 +5975,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeviceAuthorizationFlow); i { + switch v := v.(*SSHConfig); i { case 0: return &v.state case 1: @@ -5821,7 +5987,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[32].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PKCEAuthorizationFlowRequest); i { + switch v := v.(*DeviceAuthorizationFlowRequest); i { case 0: return &v.state case 1: @@ -5833,7 +5999,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[33].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PKCEAuthorizationFlow); i { + switch v := v.(*DeviceAuthorizationFlow); i { case 0: return &v.state case 1: @@ -5845,7 +6011,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[34].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ProviderConfig); i { + switch v := v.(*PKCEAuthorizationFlowRequest); i { case 0: return &v.state case 1: @@ -5857,7 +6023,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[35].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Route); i { + switch v := v.(*PKCEAuthorizationFlow); i { case 0: return &v.state case 1: @@ -5869,7 +6035,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[36].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DNSConfig); i { + switch v := v.(*ProviderConfig); i { case 0: return &v.state case 1: @@ -5881,7 +6047,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[37].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*CustomZone); i { + switch v := v.(*Route); i { case 0: return &v.state case 1: @@ -5893,7 +6059,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[38].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SimpleRecord); i { + switch v := v.(*DNSConfig); i { case 0: return &v.state case 1: @@ -5905,7 +6071,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[39].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NameServerGroup); i { + switch v := v.(*CustomZone); i { case 0: return &v.state case 1: @@ -5917,7 +6083,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[40].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NameServer); i { + switch v := v.(*SimpleRecord); i { case 0: return &v.state case 1: @@ -5929,7 +6095,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[41].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*FirewallRule); i { + switch v := v.(*NameServerGroup); i { case 0: return &v.state case 1: @@ -5941,7 +6107,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[42].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkAddress); i { + switch v := v.(*NameServer); i { case 0: return &v.state case 1: @@ -5953,7 +6119,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[43].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Checks); i { + switch v := v.(*FirewallRule); i { case 0: return &v.state case 1: @@ -5965,7 +6131,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[44].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*PortInfo); i { + switch v := v.(*NetworkAddress); i { case 0: return &v.state case 1: @@ -5977,7 +6143,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[45].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RouteFirewallRule); i { + switch v := v.(*Checks); i { case 0: return &v.state case 1: @@ -5989,7 +6155,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[46].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ForwardingRule); i { + switch v := v.(*PortInfo); i { case 0: return &v.state case 1: @@ -6001,7 +6167,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[47].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExposeServiceRequest); i { + switch v := v.(*RouteFirewallRule); i { case 0: return &v.state case 1: @@ -6013,7 +6179,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[48].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ExposeServiceResponse); i { + switch v := v.(*ForwardingRule); i { case 0: return &v.state case 1: @@ -6025,7 +6191,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[49].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RenewExposeRequest); i { + switch v := v.(*ExposeServiceRequest); i { case 0: return &v.state case 1: @@ -6037,7 +6203,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[50].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*RenewExposeResponse); i { + switch v := v.(*ExposeServiceResponse); i { case 0: return &v.state case 1: @@ -6049,7 +6215,7 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[51].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StopExposeRequest); i { + switch v := v.(*RenewExposeRequest); i { case 0: return &v.state case 1: @@ -6061,7 +6227,19 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[52].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StopExposeResponse); i { + 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[53].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*StopExposeRequest); i { case 0: return &v.state case 1: @@ -6073,6 +6251,18 @@ func file_management_proto_init() { } } file_management_proto_msgTypes[54].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*StopExposeResponse); 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.(*PortInfo_Range); i { case 0: return &v.state @@ -6091,7 +6281,7 @@ func file_management_proto_init() { file_management_proto_msgTypes[2].OneofWrappers = []interface{}{ (*JobResponse_Bundle)(nil), } - file_management_proto_msgTypes[44].OneofWrappers = []interface{}{ + file_management_proto_msgTypes[46].OneofWrappers = []interface{}{ (*PortInfo_Port)(nil), (*PortInfo_Range_)(nil), } @@ -6101,7 +6291,7 @@ func file_management_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_management_proto_rawDesc, NumEnums: 8, - NumMessages: 55, + NumMessages: 57, NumExtensions: 0, NumServices: 1, }, diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto index 461a614fe..990a72a63 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 { @@ -244,6 +261,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 39a342041..ce98e4019 100644 --- a/shared/management/proto/management_grpc.pb.go +++ b/shared/management/proto/management_grpc.pb.go @@ -52,6 +52,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) (ManagementService_JobClient, 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 @@ -194,6 +201,15 @@ func (x *managementServiceJobClient) Recv() (*EncryptedMessage, error) { return m, nil } +func (c *managementServiceClient) ExtendAuthSession(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { + out := new(EncryptedMessage) + err := c.cc.Invoke(ctx, "/management.ManagementService/ExtendAuthSession", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *managementServiceClient) CreateExpose(ctx context.Context, in *EncryptedMessage, opts ...grpc.CallOption) (*EncryptedMessage, error) { out := new(EncryptedMessage) err := c.cc.Invoke(ctx, "/management.ManagementService/CreateExpose", in, out, opts...) @@ -259,6 +275,13 @@ type ManagementServiceServer interface { Logout(context.Context, *EncryptedMessage) (*Empty, error) // Executes a job on a target peer (e.g., debug bundle) Job(ManagementService_JobServer) 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 @@ -299,6 +322,9 @@ func (UnimplementedManagementServiceServer) Logout(context.Context, *EncryptedMe func (UnimplementedManagementServiceServer) Job(ManagementService_JobServer) error { return status.Errorf(codes.Unimplemented, "method Job not implemented") } +func (UnimplementedManagementServiceServer) ExtendAuthSession(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { + return nil, status.Errorf(codes.Unimplemented, "method ExtendAuthSession not implemented") +} func (UnimplementedManagementServiceServer) CreateExpose(context.Context, *EncryptedMessage) (*EncryptedMessage, error) { return nil, status.Errorf(codes.Unimplemented, "method CreateExpose not implemented") } @@ -494,6 +520,24 @@ func (x *managementServiceJobServer) Recv() (*EncryptedMessage, error) { return m, nil } +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: "/management.ManagementService/ExtendAuthSession", + } + 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 { @@ -583,6 +627,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 a3a5e4588..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,193 @@ 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. @@ -1988,7 +2309,7 @@ type SyncMappingsRequest struct { func (x *SyncMappingsRequest) Reset() { *x = SyncMappingsRequest{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[25] + mi := &file_proxy_service_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2001,7 +2322,7 @@ func (x *SyncMappingsRequest) String() string { func (*SyncMappingsRequest) ProtoMessage() {} func (x *SyncMappingsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[25] + mi := &file_proxy_service_proto_msgTypes[28] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2014,7 +2335,7 @@ func (x *SyncMappingsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SyncMappingsRequest.ProtoReflect.Descriptor instead. func (*SyncMappingsRequest) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{25} + return file_proxy_service_proto_rawDescGZIP(), []int{28} } func (m *SyncMappingsRequest) GetMsg() isSyncMappingsRequest_Msg { @@ -2071,7 +2392,7 @@ type SyncMappingsInit struct { func (x *SyncMappingsInit) Reset() { *x = SyncMappingsInit{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[26] + mi := &file_proxy_service_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2084,7 +2405,7 @@ func (x *SyncMappingsInit) String() string { func (*SyncMappingsInit) ProtoMessage() {} func (x *SyncMappingsInit) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[26] + mi := &file_proxy_service_proto_msgTypes[29] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2097,7 +2418,7 @@ func (x *SyncMappingsInit) ProtoReflect() protoreflect.Message { // Deprecated: Use SyncMappingsInit.ProtoReflect.Descriptor instead. func (*SyncMappingsInit) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{26} + return file_proxy_service_proto_rawDescGZIP(), []int{29} } func (x *SyncMappingsInit) GetProxyId() string { @@ -2146,7 +2467,7 @@ type SyncMappingsAck struct { func (x *SyncMappingsAck) Reset() { *x = SyncMappingsAck{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[27] + mi := &file_proxy_service_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2159,7 +2480,7 @@ func (x *SyncMappingsAck) String() string { func (*SyncMappingsAck) ProtoMessage() {} func (x *SyncMappingsAck) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[27] + mi := &file_proxy_service_proto_msgTypes[30] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2172,7 +2493,7 @@ func (x *SyncMappingsAck) ProtoReflect() protoreflect.Message { // Deprecated: Use SyncMappingsAck.ProtoReflect.Descriptor instead. func (*SyncMappingsAck) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{27} + return file_proxy_service_proto_rawDescGZIP(), []int{30} } // SyncMappingsResponse is a batch of mappings sent by management. @@ -2190,7 +2511,7 @@ type SyncMappingsResponse struct { func (x *SyncMappingsResponse) Reset() { *x = SyncMappingsResponse{} if protoimpl.UnsafeEnabled { - mi := &file_proxy_service_proto_msgTypes[28] + mi := &file_proxy_service_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2203,7 +2524,7 @@ func (x *SyncMappingsResponse) String() string { func (*SyncMappingsResponse) ProtoMessage() {} func (x *SyncMappingsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_service_proto_msgTypes[28] + mi := &file_proxy_service_proto_msgTypes[31] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2216,7 +2537,7 @@ func (x *SyncMappingsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SyncMappingsResponse.ProtoReflect.Descriptor instead. func (*SyncMappingsResponse) Descriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{28} + return file_proxy_service_proto_rawDescGZIP(), []int{31} } func (x *SyncMappingsResponse) GetMapping() []*ProxyMapping { @@ -2242,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, @@ -2253,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, @@ -2350,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, @@ -2380,244 +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, 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, 0xd3, 0x05, 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, + 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, 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, + 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, } @@ -2634,53 +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, 31) +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 - (*SyncMappingsRequest)(nil), // 28: management.SyncMappingsRequest - (*SyncMappingsInit)(nil), // 29: management.SyncMappingsInit - (*SyncMappingsAck)(nil), // 30: management.SyncMappingsAck - (*SyncMappingsResponse)(nil), // 31: management.SyncMappingsResponse - nil, // 32: management.PathTargetOptions.CustomHeadersEntry - nil, // 33: management.AccessLog.MetadataEntry - (*timestamppb.Timestamp)(nil), // 34: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 35: 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{ - 34, // 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 - 35, // 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 - 32, // 5: management.PathTargetOptions.custom_headers:type_name -> management.PathTargetOptions.CustomHeadersEntry - 35, // 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 @@ -2688,38 +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 - 34, // 14: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp - 33, // 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 - 29, // 20: management.SyncMappingsRequest.init:type_name -> management.SyncMappingsInit - 30, // 21: management.SyncMappingsRequest.ack:type_name -> management.SyncMappingsAck - 34, // 22: management.SyncMappingsInit.started_at:type_name -> google.protobuf.Timestamp - 3, // 23: management.SyncMappingsInit.capabilities:type_name -> management.ProxyCapabilities - 11, // 24: management.SyncMappingsResponse.mapping:type_name -> management.ProxyMapping - 4, // 25: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest - 28, // 26: management.ProxyService.SyncMappings:input_type -> management.SyncMappingsRequest - 12, // 27: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest - 15, // 28: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest - 20, // 29: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest - 22, // 30: management.ProxyService.CreateProxyPeer:input_type -> management.CreateProxyPeerRequest - 24, // 31: management.ProxyService.GetOIDCURL:input_type -> management.GetOIDCURLRequest - 26, // 32: management.ProxyService.ValidateSession:input_type -> management.ValidateSessionRequest - 5, // 33: management.ProxyService.GetMappingUpdate:output_type -> management.GetMappingUpdateResponse - 31, // 34: management.ProxyService.SyncMappings:output_type -> management.SyncMappingsResponse - 13, // 35: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse - 19, // 36: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse - 21, // 37: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse - 23, // 38: management.ProxyService.CreateProxyPeer:output_type -> management.CreateProxyPeerResponse - 25, // 39: management.ProxyService.GetOIDCURL:output_type -> management.GetOIDCURLResponse - 27, // 40: management.ProxyService.ValidateSession:output_type -> management.ValidateSessionResponse - 33, // [33:41] is the sub-list for method output_type - 25, // [25:33] is the sub-list for method input_type - 25, // [25:25] is the sub-list for extension type_name - 25, // [25:25] is the sub-list for extension extendee - 0, // [0:25] 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() } @@ -2945,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: @@ -2957,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: @@ -2969,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: @@ -2981,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: @@ -2993,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: @@ -3005,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: @@ -3017,7 +3403,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ValidateSessionResponse); i { + switch v := v.(*ValidateSessionRequest); i { case 0: return &v.state case 1: @@ -3029,7 +3415,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SyncMappingsRequest); i { + switch v := v.(*ValidateSessionResponse); i { case 0: return &v.state case 1: @@ -3041,7 +3427,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SyncMappingsInit); i { + switch v := v.(*ValidateTunnelPeerRequest); i { case 0: return &v.state case 1: @@ -3053,7 +3439,7 @@ func file_proxy_service_proto_init() { } } file_proxy_service_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SyncMappingsAck); i { + switch v := v.(*ValidateTunnelPeerResponse); i { case 0: return &v.state case 1: @@ -3065,6 +3451,42 @@ func file_proxy_service_proto_init() { } } 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 @@ -3084,8 +3506,8 @@ 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[25].OneofWrappers = []interface{}{ + file_proxy_service_proto_msgTypes[21].OneofWrappers = []interface{}{} + file_proxy_service_proto_msgTypes[28].OneofWrappers = []interface{}{ (*SyncMappingsRequest_Init)(nil), (*SyncMappingsRequest_Ack)(nil), } @@ -3095,7 +3517,7 @@ func file_proxy_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_proxy_service_proto_rawDesc, NumEnums: 3, - NumMessages: 31, + NumMessages: 34, NumExtensions: 0, NumServices: 1, }, diff --git a/shared/management/proto/proxy_service.proto b/shared/management/proto/proxy_service.proto index d1171b27e..14d188877 100644 --- a/shared/management/proto/proxy_service.proto +++ b/shared/management/proto/proxy_service.proto @@ -34,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. @@ -45,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. @@ -86,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 { @@ -138,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. @@ -213,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 @@ -254,6 +296,52 @@ 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 @@ -287,3 +375,4 @@ message SyncMappingsResponse { // 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 fdc031ed7..40064fe61 100644 --- a/shared/management/proto/proxy_service_grpc.pb.go +++ b/shared/management/proto/proxy_service_grpc.pb.go @@ -35,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 { @@ -162,6 +170,15 @@ 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 @@ -183,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() } @@ -214,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. @@ -382,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) @@ -413,6 +459,10 @@ var ProxyService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ValidateSession", Handler: _ProxyService_ValidateSession_Handler, }, + { + MethodName: "ValidateTunnelPeer", + Handler: _ProxyService_ValidateTunnelPeer_Handler, + }, }, Streams: []grpc.StreamDesc{ { diff --git a/util/capture/text.go b/util/capture/text.go index a6a6dd28b..32229894e 100644 --- a/util/capture/text.go +++ b/util/capture/text.go @@ -13,6 +13,8 @@ import ( "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/miekg/dns" + + nbdns "github.com/netbirdio/netbird/dns" ) // TextWriter writes human-readable one-line-per-packet summaries. @@ -150,7 +152,7 @@ func (tw *TextWriter) writeUDP(timeStr string, dir Direction, info *packetInfo, plen := len(udp.Payload) // DNS replaces the entire line format - if plen > 0 && isDNSPort(info.srcPort, info.dstPort) { + if plen > 0 && (isDNSPort(info.srcPort) || isDNSPort(info.dstPort)) { if s := formatDNSPayload(udp.Payload); s != "" { var verbose string if tw.verbose { @@ -561,8 +563,12 @@ func findSNIExtension(body []byte, pos int) string { return "" } -func isDNSPort(src, dst uint16) bool { - return src == 53 || dst == 53 || src == 5353 || dst == 5353 +func isDNSPort(p uint16) bool { + switch p { + case nbdns.DefaultDNSPort, nbdns.ForwarderClientPort, nbdns.ForwarderServerPort: + return true + } + return false } // formatDNSPayload parses DNS and returns a tcpdump-style summary. diff --git a/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) + } + }) + } +}