diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml index 8acd645e2..50510368b 100644 --- a/.github/workflows/check-license-dependencies.yml +++ b/.github/workflows/check-license-dependencies.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -59,12 +59,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: true diff --git a/.github/workflows/git-town.yml b/.github/workflows/git-town.yml index 3f145020f..160c2ea38 100644 --- a/.github/workflows/git-town.yml +++ b/.github/workflows/git-town.yml @@ -15,7 +15,7 @@ jobs: pull-requests: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: git-town/action@3d8b878379abb1ee393fb49865a28b4a6c2cd3b0 # v1.2.1 diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml index ad84840a2..7ecec0e92 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -16,12 +16,12 @@ jobs: runs-on: macos-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -48,7 +48,7 @@ jobs: run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined) - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml index 9a81d3e4c..4243613b1 100644 --- a/.github/workflows/golang-test-freebsd.yml +++ b/.github/workflows/golang-test-freebsd.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -28,7 +28,7 @@ jobs: id: test env: GO_VERSION: ${{ steps.goversion.outputs.version }} - uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5 + uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8 with: usesh: true copyback: false diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index c17f83222..cd34d1696 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -18,7 +18,7 @@ jobs: management: ${{ steps.filter.outputs.management }} steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -30,7 +30,7 @@ jobs: - 'management/**' - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -119,12 +119,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -162,7 +162,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird @@ -175,12 +175,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -246,12 +246,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -290,7 +290,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird @@ -306,12 +306,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -347,7 +347,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird @@ -363,12 +363,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -407,7 +407,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird @@ -424,12 +424,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -484,7 +484,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird @@ -529,12 +529,12 @@ jobs: prom/prometheus - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -623,12 +623,12 @@ jobs: prom/prometheus - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -692,12 +692,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -734,7 +734,7 @@ jobs: - name: Upload coverage reports to Codecov if: matrix.arch == 'amd64' - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index 8712cc879..a6064d574 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -18,12 +18,12 @@ jobs: runs-on: windows-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 id: go with: go-version-file: "go.mod" diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 9adf2268f..d1664e609 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: codespell @@ -40,7 +40,7 @@ jobs: timeout-minutes: 25 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Check for duplicate constants @@ -48,7 +48,7 @@ jobs: run: | ! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep . - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false diff --git a/.github/workflows/install-script-test.yml b/.github/workflows/install-script-test.yml index aec9f6300..1514caedc 100644 --- a/.github/workflows/install-script-test.yml +++ b/.github/workflows/install-script-test.yml @@ -22,7 +22,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml index 8e0538104..778462a21 100644 --- a/.github/workflows/mobile-build-validation.yml +++ b/.github/workflows/mobile-build-validation.yml @@ -16,11 +16,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" - name: Setup Android SDK @@ -28,7 +28,7 @@ jobs: with: cmdline-tools-version: 8512546 - name: Setup Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 with: java-version: "11" distribution: "adopt" @@ -54,11 +54,11 @@ jobs: runs-on: macos-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" - name: install gomobile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b335aad72..4e533687b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,10 +9,13 @@ on: pull_request: env: - SIGN_PIPE_VER: "v0.1.5" - GORELEASER_VER: "v2.14.3" + SIGN_PIPE_VER: "v0.1.6" + GORELEASER_VER: "v2.16.0" PRODUCT_NAME: "NetBird" COPYRIGHT: "NetBird GmbH" + flags: "" + SKIP_PUBLISH: "true" + SKIP_DOCKER_PUSH: "false" concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} @@ -24,7 +27,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -61,7 +64,7 @@ jobs: if: steps.check_diff.outputs.diff_exists == 'true' env: GO_VERSION: ${{ steps.goversion.outputs.version }} - uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5 + uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8 with: usesh: true copyback: false @@ -130,11 +133,9 @@ jobs: windows_packages_artifact_url: ${{ steps.upload_windows_packages.outputs.artifact-url }} macos_packages_artifact_url: ${{ steps.upload_macos_packages.outputs.artifact-url }} ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }} - env: - flags: "" steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # It is required for GoReleaser to work properly persist-credentials: false @@ -143,10 +144,29 @@ jobs: id: semver_parser uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 - - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} - run: echo "flags=--snapshot" >> $GITHUB_ENV + - name: Set snapshot flag + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: | + echo "flags=--snapshot" >> $GITHUB_ENV + + - name: Set build vars + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + echo "SKIP_PUBLISH=false" >> $GITHUB_ENV + else + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + fi + + if [[ "x-${{ github.repository }}" != "x-netbirdio/netbird" ]]; then + echo "SKIP_DOCKER_PUSH=true" >> $GITHUB_ENV + fi + - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -166,9 +186,9 @@ jobs: - name: check git status run: git --no-pager diff --exit-code - name: Set up QEMU - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a #v4.0.0 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0 - name: Login to Docker hub if: github.event_name != 'pull_request' uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 @@ -201,7 +221,7 @@ jobs: run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso - name: Run GoReleaser id: goreleaser - uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: version: ${{ env.GORELEASER_VER }} args: release --clean ${{ env.flags }} @@ -212,6 +232,8 @@ jobs: UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }} NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }} + SKIP_DOCKER_PUSH: ${{ env.SKIP_DOCKER_PUSH }} - name: Verify RPM signatures run: | docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c ' @@ -325,7 +347,7 @@ jobs: release_ui_artifact_url: ${{ steps.upload_release_ui.outputs.artifact-url }} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # It is required for GoReleaser to work properly persist-credentials: false @@ -334,11 +356,25 @@ jobs: id: semver_parser uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 - - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} - run: echo "flags=--snapshot" >> $GITHUB_ENV + - name: Set snapshot flag + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: | + echo "flags=--snapshot" >> $GITHUB_ENV + + - name: Set build vars + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + echo "SKIP_PUBLISH=false" >> $GITHUB_ENV + else + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + fi - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -384,7 +420,7 @@ jobs: run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso - name: Run GoReleaser - uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: version: ${{ env.GORELEASER_VER }} args: release --config .goreleaser_ui.yaml --clean ${{ env.flags }} @@ -395,6 +431,7 @@ jobs: UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }} NFPM_NETBIRD_UI_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }} - name: Verify RPM signatures run: | docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c ' @@ -427,12 +464,12 @@ jobs: - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} run: echo "flags=--snapshot" >> $GITHUB_ENV - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # It is required for GoReleaser to work properly persist-credentials: false - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" cache: false @@ -451,7 +488,7 @@ jobs: run: git --no-pager diff --exit-code - name: Run GoReleaser id: goreleaser - uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: version: ${{ env.GORELEASER_VER }} args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }} @@ -485,7 +522,7 @@ jobs: downloadPath: '${{ github.workspace }}\temp' steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -497,13 +534,13 @@ jobs: run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: Download release artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release path: release - name: Download UI release artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-ui path: release-ui diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index 9ad1f2f67..1d7753177 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -68,12 +68,12 @@ jobs: run: sudo apt-get install -y curl - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" @@ -207,7 +207,7 @@ jobs: - name: Build management docker image working-directory: management run: | - docker build -t netbirdio/management:latest . + docker build -t netbirdio/management:latest --build-arg TARGETPLATFORM=. . - name: Build signal binary working-directory: signal @@ -216,7 +216,7 @@ jobs: - name: Build signal docker image working-directory: signal run: | - docker build -t netbirdio/signal:latest . + docker build -t netbirdio/signal:latest --build-arg TARGETPLATFORM=. . - name: Build relay binary working-directory: relay @@ -225,7 +225,7 @@ jobs: - name: Build relay docker image working-directory: relay run: | - docker build -t netbirdio/relay:latest . + docker build -t netbirdio/relay:latest --build-arg TARGETPLATFORM=. . - name: run docker compose up working-directory: infrastructure_files/artifacts @@ -256,7 +256,7 @@ jobs: run: sudo apt-get install -y jq - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml index 318a127dd..a5ae59720 100644 --- a/.github/workflows/wasm-build-validation.yml +++ b/.github/workflows/wasm-build-validation.yml @@ -19,11 +19,11 @@ jobs: GOARCH: wasm steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" - name: Install dependencies @@ -44,11 +44,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" - name: Build Wasm client diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5ea479148..a2640dc8e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,5 +1,7 @@ version: 2 - +env: + - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }} + - SKIP_DOCKER_PUSH={{ if index .Env "SKIP_DOCKER_PUSH" }}{{ .Env.SKIP_DOCKER_PUSH }}{{ else }}false{{ end }} project_name: netbird builds: - id: netbird-wasm @@ -74,6 +76,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -88,6 +92,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -102,6 +108,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -122,6 +130,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -136,6 +146,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -150,6 +162,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}} mod_timestamp: "{{ .CommitTimestamp }}" @@ -170,6 +184,8 @@ builds: - amd64 - arm64 - arm + goarm: + - 7 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -222,670 +238,192 @@ nfpms: rpm: signature: key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' -dockers: - - image_templates: - - netbirdio/netbird:{{ .Version }}-amd64 - - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64 - ids: - - netbird - goarch: amd64 - use: buildx - dockerfile: client/Dockerfile - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8 - ids: - - netbird - goarch: arm64 - use: buildx - dockerfile: client/Dockerfile - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm - ids: - - netbird - goarch: arm - goarm: 6 - use: buildx - dockerfile: client/Dockerfile - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-amd64 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64 - ids: - - netbird - goarch: amd64 - use: buildx - dockerfile: client/Dockerfile-rootless - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - ids: - - netbird - goarch: arm64 - use: buildx - dockerfile: client/Dockerfile-rootless - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm - ids: - - netbird - goarch: arm - goarm: 6 - use: buildx - dockerfile: client/Dockerfile-rootless - extra_files: - - client/netbird-entrypoint.sh - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - - image_templates: - - netbirdio/relay:{{ .Version }}-amd64 - - ghcr.io/netbirdio/relay:{{ .Version }}-amd64 - ids: - - netbird-relay - goarch: amd64 - use: buildx - dockerfile: relay/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/relay:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8 - ids: - - netbird-relay - goarch: arm64 - use: buildx - dockerfile: relay/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/relay:{{ .Version }}-arm - - ghcr.io/netbirdio/relay:{{ .Version }}-arm - ids: - - netbird-relay - goarch: arm - goarm: 6 - use: buildx - dockerfile: relay/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/signal:{{ .Version }}-amd64 - - ghcr.io/netbirdio/signal:{{ .Version }}-amd64 - ids: - - netbird-signal - goarch: amd64 - use: buildx - dockerfile: signal/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/signal:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8 - ids: - - netbird-signal - goarch: arm64 - use: buildx - dockerfile: signal/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/signal:{{ .Version }}-arm - - ghcr.io/netbirdio/signal:{{ .Version }}-arm - ids: - - netbird-signal - goarch: arm - goarm: 6 - use: buildx - dockerfile: signal/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-amd64 - - ghcr.io/netbirdio/management:{{ .Version }}-amd64 - ids: - - netbird-mgmt - goarch: amd64 - use: buildx - dockerfile: management/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8 - ids: - - netbird-mgmt - goarch: arm64 - use: buildx - dockerfile: management/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-arm - - ghcr.io/netbirdio/management:{{ .Version }}-arm - ids: - - netbird-mgmt - goarch: arm - goarm: 6 - use: buildx - dockerfile: management/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-debug-amd64 - - ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64 - ids: - - netbird-mgmt - goarch: amd64 - use: buildx - dockerfile: management/Dockerfile.debug - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/management:{{ .Version }}-debug-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8 - ids: - - netbird-mgmt - goarch: arm64 - use: buildx - dockerfile: management/Dockerfile.debug - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - - image_templates: - - netbirdio/management:{{ .Version }}-debug-arm - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm - ids: - - netbird-mgmt - goarch: arm - goarm: 6 - use: buildx - dockerfile: management/Dockerfile.debug - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/upload:{{ .Version }}-amd64 - - ghcr.io/netbirdio/upload:{{ .Version }}-amd64 - ids: - - netbird-upload - goarch: amd64 - use: buildx - dockerfile: upload-server/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/upload:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8 - ids: - - netbird-upload - goarch: arm64 - use: buildx - dockerfile: upload-server/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/upload:{{ .Version }}-arm - - ghcr.io/netbirdio/upload:{{ .Version }}-arm - ids: - - netbird-upload - goarch: arm - goarm: 6 - use: buildx - dockerfile: upload-server/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird-server:{{ .Version }}-amd64 - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 - ids: - - netbird-server - goarch: amd64 - use: buildx - dockerfile: combined/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird-server:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 - ids: - - netbird-server - goarch: arm64 - use: buildx - dockerfile: combined/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/netbird-server:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm - ids: - - netbird-server - goarch: arm - goarm: 6 - use: buildx - dockerfile: combined/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-amd64 - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64 - ids: - - netbird-proxy - goarch: amd64 - use: buildx - dockerfile: proxy/Dockerfile - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - ids: - - netbird-proxy - goarch: arm64 - use: buildx - dockerfile: proxy/Dockerfile - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" - - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-arm - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm - ids: - - netbird-proxy - goarch: arm - goarm: 6 - use: buildx - dockerfile: proxy/Dockerfile - build_flag_templates: - - "--platform=linux/arm" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}" - - "--label=maintainer=dev@netbird.io" -docker_manifests: - - name_template: netbirdio/netbird:{{ .Version }} - image_templates: - - netbirdio/netbird:{{ .Version }}-arm64v8 - - netbirdio/netbird:{{ .Version }}-arm - - netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: netbirdio/netbird:latest - image_templates: - - netbirdio/netbird:{{ .Version }}-arm64v8 - - netbirdio/netbird:{{ .Version }}-arm - - netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: netbirdio/netbird:{{ .Version }}-rootless - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - netbirdio/netbird:{{ .Version }}-rootless-arm - - netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: netbirdio/netbird:rootless-latest - image_templates: - - netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - netbirdio/netbird:{{ .Version }}-rootless-arm - - netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: netbirdio/relay:{{ .Version }} - image_templates: - - netbirdio/relay:{{ .Version }}-arm64v8 - - netbirdio/relay:{{ .Version }}-arm - - netbirdio/relay:{{ .Version }}-amd64 - - - name_template: netbirdio/relay:latest - image_templates: - - netbirdio/relay:{{ .Version }}-arm64v8 - - netbirdio/relay:{{ .Version }}-arm - - netbirdio/relay:{{ .Version }}-amd64 - - - name_template: netbirdio/signal:{{ .Version }} - image_templates: - - netbirdio/signal:{{ .Version }}-arm64v8 - - netbirdio/signal:{{ .Version }}-arm - - netbirdio/signal:{{ .Version }}-amd64 - - - name_template: netbirdio/signal:latest - image_templates: - - netbirdio/signal:{{ .Version }}-arm64v8 - - netbirdio/signal:{{ .Version }}-arm - - netbirdio/signal:{{ .Version }}-amd64 - - - name_template: netbirdio/management:{{ .Version }} - image_templates: - - netbirdio/management:{{ .Version }}-arm64v8 - - netbirdio/management:{{ .Version }}-arm - - netbirdio/management:{{ .Version }}-amd64 - - - name_template: netbirdio/management:latest - image_templates: - - netbirdio/management:{{ .Version }}-arm64v8 - - netbirdio/management:{{ .Version }}-arm - - netbirdio/management:{{ .Version }}-amd64 - - - name_template: netbirdio/management:debug-latest - image_templates: - - netbirdio/management:{{ .Version }}-debug-arm64v8 - - netbirdio/management:{{ .Version }}-debug-arm - - netbirdio/management:{{ .Version }}-debug-amd64 - - name_template: netbirdio/upload:{{ .Version }} - image_templates: - - netbirdio/upload:{{ .Version }}-arm64v8 - - netbirdio/upload:{{ .Version }}-arm - - netbirdio/upload:{{ .Version }}-amd64 - - - name_template: netbirdio/upload:latest - image_templates: - - netbirdio/upload:{{ .Version }}-arm64v8 - - netbirdio/upload:{{ .Version }}-arm - - netbirdio/upload:{{ .Version }}-amd64 - - - name_template: netbirdio/netbird-server:{{ .Version }} - image_templates: - - netbirdio/netbird-server:{{ .Version }}-arm64v8 - - netbirdio/netbird-server:{{ .Version }}-arm - - netbirdio/netbird-server:{{ .Version }}-amd64 - - - name_template: netbirdio/netbird-server:latest - image_templates: - - netbirdio/netbird-server:{{ .Version }}-arm64v8 - - netbirdio/netbird-server:{{ .Version }}-arm - - netbirdio/netbird-server:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird:latest - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird:{{ .Version }}-rootless - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: ghcr.io/netbirdio/netbird:rootless-latest - image_templates: - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8 - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm - - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64 - - - name_template: ghcr.io/netbirdio/relay:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/relay:{{ .Version }}-arm - - ghcr.io/netbirdio/relay:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/relay:latest - image_templates: - - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/relay:{{ .Version }}-arm - - ghcr.io/netbirdio/relay:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/signal:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/signal:{{ .Version }}-arm - - ghcr.io/netbirdio/signal:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/signal:latest - image_templates: - - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/signal:{{ .Version }}-arm - - ghcr.io/netbirdio/signal:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/management:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-arm - - ghcr.io/netbirdio/management:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/management:latest - image_templates: - - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-arm - - ghcr.io/netbirdio/management:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/management:debug-latest - image_templates: - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8 - - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm - - ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64 - - - name_template: ghcr.io/netbirdio/upload:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/upload:{{ .Version }}-arm - - ghcr.io/netbirdio/upload:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/upload:latest - image_templates: - - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/upload:{{ .Version }}-arm - - ghcr.io/netbirdio/upload:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird-server:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/netbird-server:latest - image_templates: - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm - - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64 - - - name_template: netbirdio/reverse-proxy:{{ .Version }} - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - netbirdio/reverse-proxy:{{ .Version }}-arm - - netbirdio/reverse-proxy:{{ .Version }}-amd64 - - - name_template: netbirdio/reverse-proxy:latest - image_templates: - - netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - netbirdio/reverse-proxy:{{ .Version }}-arm - - netbirdio/reverse-proxy:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/reverse-proxy:{{ .Version }} - image_templates: - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64 - - - name_template: ghcr.io/netbirdio/reverse-proxy:latest - image_templates: - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8 - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm - - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64 +dockers_v2: + - id: netbird + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird + images: + - netbirdio/netbird + - ghcr.io/netbirdio/netbird + tags: + - "{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: client/Dockerfile + extra_files: + - client/netbird-entrypoint.sh + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm/6 + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: netbird-rootless + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird + images: + - netbirdio/netbird + - ghcr.io/netbirdio/netbird + tags: + - "v{{ .Version }}-rootless" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: client/Dockerfile-rootless + extra_files: + - client/netbird-entrypoint.sh + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm/6 + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: relay + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-relay + images: + - netbirdio/relay + - ghcr.io/netbirdio/relay + tags: + - "{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: relay/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: signal + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-signal + images: + - netbirdio/signal + - ghcr.io/netbirdio/signal + tags: + - "{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: signal/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: management + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-mgmt + images: + - netbirdio/management + - ghcr.io/netbirdio/management + tags: + - "{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: management/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: upload + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-upload + images: + - netbirdio/upload + - ghcr.io/netbirdio/upload + tags: + - "{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: upload-server/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: netbird-server + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-server + images: + - netbirdio/netbird-server + - ghcr.io/netbirdio/netbird-server + tags: + - "{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: combined/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" + - id: netbird-proxy + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-proxy + images: + - netbirdio/reverse-proxy + - ghcr.io/netbirdio/reverse-proxy + tags: + - "{{ .Version }}" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + dockerfile: proxy/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + - linux/arm + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" brews: - ids: - default + skip_upload: "{{ .Env.SKIP_PUBLISH }}" repository: owner: netbirdio name: homebrew-tap @@ -902,6 +440,7 @@ brews: uploads: - name: debian + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_deb mode: archive @@ -910,6 +449,7 @@ uploads: method: PUT - name: yum + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_rpm mode: archive @@ -922,9 +462,13 @@ checksum: - glob: ./infrastructure_files/getting-started-with-zitadel.sh - glob: ./release_files/install.sh - glob: ./infrastructure_files/getting-started.sh + - glob: ./infrastructure_files/getting-started-enterprise.sh + - glob: ./infrastructure_files/migrate-to-enterprise.sh release: extra_files: - glob: ./infrastructure_files/getting-started-with-zitadel.sh - glob: ./release_files/install.sh - glob: ./infrastructure_files/getting-started.sh + - glob: ./infrastructure_files/getting-started-enterprise.sh + - glob: ./infrastructure_files/migrate-to-enterprise.sh diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml index 470f1deaa..6f9b7c059 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -1,5 +1,6 @@ version: 2 - +env: + - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }} project_name: netbird-ui builds: - id: netbird-ui @@ -101,6 +102,7 @@ nfpms: uploads: - name: debian + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_ui_deb mode: archive @@ -109,6 +111,7 @@ uploads: method: PUT - name: yum + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - netbird_ui_rpm mode: archive diff --git a/client/Dockerfile b/client/Dockerfile index 53e4555ef..478b2d0e2 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -4,7 +4,7 @@ # sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client . # sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest -FROM alpine:3.23.3 +FROM alpine:3.24 # iproute2: busybox doesn't display ip rules properly RUN apk add --no-cache \ bash \ @@ -21,7 +21,7 @@ ENV \ NB_ENTRYPOINT_SERVICE_TIMEOUT="30" ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] - -ARG NETBIRD_BINARY=netbird +ARG TARGETPLATFORM +ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird diff --git a/client/Dockerfile-rootless b/client/Dockerfile-rootless index 706bf40de..8141af6ed 100644 --- a/client/Dockerfile-rootless +++ b/client/Dockerfile-rootless @@ -4,7 +4,7 @@ # podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client . # podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest -FROM alpine:3.22.0 +FROM alpine:3.24 RUN apk add --no-cache \ bash \ @@ -27,7 +27,7 @@ ENV \ NB_ENTRYPOINT_SERVICE_TIMEOUT="30" ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] - -ARG NETBIRD_BINARY=netbird +ARG TARGETPLATFORM +ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go index 60e4d5c32..87c001396 100644 --- a/client/android/profile_manager.go +++ b/client/android/profile_manager.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "strings" log "github.com/sirupsen/logrus" @@ -24,6 +23,7 @@ const ( // Profile represents a profile for gomobile type Profile struct { + ID string Name string IsActive bool } @@ -53,10 +53,10 @@ func (p *ProfileArray) Get(i int) *Profile { ├── state.json ← Default profile state ├── active_profile.json ← Active profile tracker (JSON with Name + Username) └── profiles/ ← Subdirectory for non-default profiles - ├── work.json ← Work profile config - ├── work.state.json ← Work profile state - ├── personal.json ← Personal profile config - └── personal.state.json ← Personal profile state + ├── work.json ← Legacy work profile config + ├── work.state.json ← Legacy work profile state + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state */ // ProfileManager manages profiles for Android @@ -99,6 +99,7 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { var profiles []*Profile for _, p := range internalProfiles { profiles = append(profiles, &Profile{ + ID: p.ID.String(), Name: p.Name, IsActive: p.IsActive, }) @@ -108,55 +109,65 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { } // GetActiveProfile returns the currently active profile name -func (pm *ProfileManager) GetActiveProfile() (string, error) { +func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { // Use ServiceManager to stay consistent with ListProfiles // ServiceManager uses active_profile.json activeState, err := pm.serviceMgr.GetActiveProfileState() if err != nil { - return "", fmt.Errorf("failed to get active profile: %w", err) + return nil, fmt.Errorf("failed to get active profile: %w", err) } - return activeState.Name, nil + + // ActiveProfileState only stores the ID (and username), not the display + // name. Resolve the ID to the full profile so callers get the real Name. + prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername) + if err != nil { + return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err) + } + return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil } // SwitchProfile switches to a different profile -func (pm *ProfileManager) SwitchProfile(profileName string) error { +func (pm *ProfileManager) SwitchProfile(id string) error { // Use ServiceManager to stay consistent with ListProfiles // ServiceManager uses active_profile.json err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profileName, + ID: profilemanager.ID(id), Username: androidUsername, }) if err != nil { return fmt.Errorf("failed to switch profile: %w", err) } - log.Infof("switched to profile: %s", profileName) + log.Infof("switched to profile: %s", id) return nil } // AddProfile creates a new profile func (pm *ProfileManager) AddProfile(profileName string) error { // Use ServiceManager (creates profile in profiles/ directory) - if err := pm.serviceMgr.AddProfile(profileName, androidUsername); err != nil { + profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername) + if err != nil { return fmt.Errorf("failed to add profile: %w", err) } - log.Infof("created new profile: %s", profileName) + log.Infof("created new profile: %s", profile.ID) return nil } // LogoutProfile logs out from a profile (clears authentication) -func (pm *ProfileManager) LogoutProfile(profileName string) error { - profileName = sanitizeProfileName(profileName) - - configPath, err := pm.getProfileConfigPath(profileName) +func (pm *ProfileManager) LogoutProfile(id string) error { + configPath, err := pm.getProfileConfigPath(id) if err != nil { return err } + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return fmt.Errorf("id '%s' is not valid", id) + } + // Check if profile exists if _, err := os.Stat(configPath); os.IsNotExist(err) { - return fmt.Errorf("profile '%s' does not exist", profileName) + return fmt.Errorf("profile '%s' does not exist", id) } // Read current config using internal profilemanager @@ -174,53 +185,57 @@ func (pm *ProfileManager) LogoutProfile(profileName string) error { return fmt.Errorf("failed to save config: %w", err) } - log.Infof("logged out from profile: %s", profileName) + log.Infof("logged out from profile: %s", id) return nil } // RemoveProfile deletes a profile -func (pm *ProfileManager) RemoveProfile(profileName string) error { +func (pm *ProfileManager) RemoveProfile(id string) error { // Use ServiceManager (removes profile from profiles/ directory) - if err := pm.serviceMgr.RemoveProfile(profileName, androidUsername); err != nil { + if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil { return fmt.Errorf("failed to remove profile: %w", err) } - log.Infof("removed profile: %s", profileName) + log.Infof("removed profile: %s", id) return nil } // getProfileConfigPath returns the config file path for a profile // This is needed for Android-specific path handling (netbird.cfg for default profile) -func (pm *ProfileManager) getProfileConfigPath(profileName string) (string, error) { - if profileName == "" || profileName == profilemanager.DefaultProfileName { +func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) { + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return "", fmt.Errorf("id %q is not valid", id) + } + + if id == profilemanager.DefaultProfileName { // Android uses netbird.cfg for default profile instead of default.json // Default profile is stored in root configDir, not in profiles/ return filepath.Join(pm.configDir, defaultConfigFilename), nil } - // Non-default profiles are stored in profiles subdirectory - // This matches the Java Preferences.java expectation - profileName = sanitizeProfileName(profileName) profilesDir := filepath.Join(pm.configDir, profilesSubdir) - return filepath.Join(profilesDir, profileName+".json"), nil + return filepath.Join(profilesDir, id+".json"), nil } -// GetConfigPath returns the config file path for a given profile +// GetConfigPath returns the config file path for a given profile id // Java should call this instead of constructing paths with Preferences.configFile() -func (pm *ProfileManager) GetConfigPath(profileName string) (string, error) { - return pm.getProfileConfigPath(profileName) +func (pm *ProfileManager) GetConfigPath(id string) (string, error) { + return pm.getProfileConfigPath(id) } // GetStateFilePath returns the state file path for a given profile // Java should call this instead of constructing paths with Preferences.stateFile() -func (pm *ProfileManager) GetStateFilePath(profileName string) (string, error) { - if profileName == "" || profileName == profilemanager.DefaultProfileName { +func (pm *ProfileManager) GetStateFilePath(id string) (string, error) { + if id == "" || id == profilemanager.DefaultProfileName { return filepath.Join(pm.configDir, "state.json"), nil } - profileName = sanitizeProfileName(profileName) + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return "", fmt.Errorf("id %q is not valid", id) + } + profilesDir := filepath.Join(pm.configDir, profilesSubdir) - return filepath.Join(profilesDir, profileName+".state.json"), nil + return filepath.Join(profilesDir, id+".state.json"), nil } // GetActiveConfigPath returns the config file path for the currently active profile @@ -230,7 +245,7 @@ func (pm *ProfileManager) GetActiveConfigPath() (string, error) { if err != nil { return "", fmt.Errorf("failed to get active profile: %w", err) } - return pm.GetConfigPath(activeProfile) + return pm.GetConfigPath(activeProfile.ID) } // GetActiveStateFilePath returns the state file path for the currently active profile @@ -240,18 +255,5 @@ func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { if err != nil { return "", fmt.Errorf("failed to get active profile: %w", err) } - return pm.GetStateFilePath(activeProfile) -} - -// sanitizeProfileName removes invalid characters from profile name -func sanitizeProfileName(name string) string { - // Keep only alphanumeric, underscore, and hyphen - var result strings.Builder - for _, r := range name { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || r == '_' || r == '-' { - result.WriteRune(r) - } - } - return result.String() + return pm.GetStateFilePath(activeProfile.ID) } diff --git a/client/cmd/debug.go b/client/cmd/debug.go index bc7b0e98c..57e75f663 100644 --- a/client/cmd/debug.go +++ b/client/cmd/debug.go @@ -130,7 +130,7 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error { client := proto.NewDaemonServiceClient(conn) resp, err := client.GetConfig(cmd.Context(), &proto.GetConfigRequest{ - ProfileName: activeProf.Name, + ProfileName: string(activeProf.ID), Username: currUser.Username, }) if err != nil { diff --git a/client/cmd/login.go b/client/cmd/login.go index bd37e30f1..a7ee960b1 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -96,17 +96,19 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str dnsLabelsReq = dnsLabelsValidated.ToSafeStringList() } + handle := activeProf.ID.String() + loginRequest := proto.LoginRequest{ SetupKey: providedSetupKey, ManagementUrl: managementURL, IsUnixDesktopClient: isUnixRunningDesktop(), Hostname: hostName, DnsLabels: dnsLabelsReq, - ProfileName: &activeProf.Name, + ProfileName: &handle, Username: &username, } - profileState, err := pm.GetProfileState(activeProf.Name) + profileState, err := pm.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { @@ -170,14 +172,13 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr return activeProf, nil } -func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) error { - err := switchProfile(context.Background(), profileName, username) +func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) error { + resolvedID, err := switchProfile(ctx, handle, username) if err != nil { return fmt.Errorf("switch profile on daemon: %v", err) } - err = pm.SwitchProfile(profileName) - if err != nil { + if err := pm.SwitchProfile(resolvedID); err != nil { return fmt.Errorf("switch profile: %v", err) } @@ -205,11 +206,15 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage return nil } -func switchProfile(ctx context.Context, profileName string, username string) error { +// switchProfile asks the daemon to switch to the profile identified by +// handle (a name, ID, or unique ID prefix). Returns the resolved profile +// ID so the caller can update the local active-profile state without +// re-resolving the handle. +func switchProfile(ctx context.Context, handle string, username string) (profilemanager.ID, error) { conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { //nolint - return fmt.Errorf("failed to connect to daemon error: %v\n"+ + return "", fmt.Errorf("failed to connect to daemon error: %v\n"+ "If the daemon is not running please run: "+ "\nnetbird service install \nnetbird service start\n", err) } @@ -217,15 +222,15 @@ func switchProfile(ctx context.Context, profileName string, username string) err client := proto.NewDaemonServiceClient(conn) - _, err = client.SwitchProfile(ctx, &proto.SwitchProfileRequest{ - ProfileName: &profileName, + resp, err := client.SwitchProfile(ctx, &proto.SwitchProfileRequest{ + ProfileName: &handle, Username: &username, }) if err != nil { - return fmt.Errorf("switch profile failed: %v", err) + return "", fmt.Errorf("switch profile failed: %w", err) } - return nil + return profilemanager.ID(resp.Id), nil } func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, activeProf *profilemanager.Profile) error { @@ -249,7 +254,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, return fmt.Errorf("read config file %s: %v", configFilePath, err) } - err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.Name) + err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID) if err != nil { return fmt.Errorf("foreground login failed: %v", err) } @@ -277,7 +282,7 @@ func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.Lo return nil } -func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey, profileName string) error { +func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey string, profileID profilemanager.ID) error { authClient, err := auth.NewAuth(ctx, config.PrivateKey, config.ManagementURL, config) if err != nil { return fmt.Errorf("failed to create auth client: %v", err) @@ -291,7 +296,7 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman jwtToken := "" if setupKey == "" && needsLogin { - tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileName) + tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileID) if err != nil { return fmt.Errorf("interactive sso login failed: %v", err) } @@ -306,10 +311,10 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman return nil } -func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileName string) (*auth.TokenInfo, error) { +func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileID profilemanager.ID) (*auth.TokenInfo, error) { hint := "" pm := profilemanager.NewProfileManager() - profileState, err := pm.GetProfileState(profileName) + profileState, err := pm.GetProfileState(profileID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { diff --git a/client/cmd/login_test.go b/client/cmd/login_test.go index 47522e189..0aa1856b1 100644 --- a/client/cmd/login_test.go +++ b/client/cmd/login_test.go @@ -27,7 +27,7 @@ func TestLogin(t *testing.T) { profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json" sm := profilemanager.ServiceManager{} err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "default", + ID: "default", Username: currUser.Username, }) if err != nil { diff --git a/client/cmd/profile.go b/client/cmd/profile.go index d6e81760f..268034e70 100644 --- a/client/cmd/profile.go +++ b/client/cmd/profile.go @@ -2,11 +2,16 @@ package cmd import ( "context" + "errors" "fmt" "os/user" + "strings" + "text/tabwriter" "time" "github.com/spf13/cobra" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/profilemanager" @@ -14,6 +19,8 @@ import ( "github.com/netbirdio/netbird/util" ) +var profileListShowID bool + var profileCmd = &cobra.Command{ Use: "profile", Short: "Manage NetBird client profiles", @@ -31,27 +38,40 @@ var profileListCmd = &cobra.Command{ var profileAddCmd = &cobra.Command{ Use: "add ", Short: "Add a new profile", - Long: `Add a new profile to the NetBird client. The profile name must be unique.`, + Long: `Add a new profile. Profile name is free-form, a unique ID is generated for the on-disk config file.`, Args: cobra.ExactArgs(1), RunE: addProfileFunc, } +var profileRenameCmd = &cobra.Command{ + Use: "rename ", + Short: "Renames an existing profile", + Long: `Renames an existing profile (by a name, ID, or unique ID prefix). Profile name is free-form.`, + Args: cobra.ExactArgs(2), + RunE: renameProfileFunc, +} + var profileRemoveCmd = &cobra.Command{ - Use: "remove ", - Short: "Remove a profile", - Long: `Remove a profile from the NetBird client. The profile must not be inactive.`, - Args: cobra.ExactArgs(1), - RunE: removeProfileFunc, + Use: "remove ", + Short: "Remove a profile", + Long: `Remove a profile by name, ID, or unique ID prefix.`, + Aliases: []string{"rm"}, + Args: cobra.ExactArgs(1), + RunE: removeProfileFunc, } var profileSelectCmd = &cobra.Command{ - Use: "select ", + Use: "select ", Short: "Select a profile", - Long: `Make the specified profile active. This will switch the client to use the selected profile's configuration.`, + Long: `Make the specified profile active. Accepts a name, ID, or unique ID prefix.`, Args: cobra.ExactArgs(1), RunE: selectProfileFunc, } +func init() { + profileListCmd.Flags().BoolVar(&profileListShowID, "show-id", false, "show the profile ID column") +} + func setupCmd(cmd *cobra.Command) error { SetFlagsFromEnvVars(rootCmd) SetFlagsFromEnvVars(cmd) @@ -65,6 +85,7 @@ func setupCmd(cmd *cobra.Command) error { return nil } + func listProfilesFunc(cmd *cobra.Command, _ []string) error { if err := setupCmd(cmd); err != nil { return err @@ -83,25 +104,33 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error { daemonClient := proto.NewDaemonServiceClient(conn) - profiles, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{ + resp, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{ Username: currUser.Username, }) if err != nil { return err } - // list profiles, add a tick if the profile is active - cmd.Println("Found", len(profiles.Profiles), "profiles:") - for _, profile := range profiles.Profiles { - // use a cross to indicate the passive profiles - activeMarker := "✗" - if profile.IsActive { - activeMarker = "✓" - } - cmd.Println(activeMarker, profile.Name) + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + if profileListShowID { + fmt.Fprintln(tw, "ID\tNAME\tACTIVE") + } else { + fmt.Fprintln(tw, "NAME\tACTIVE") } - - return nil + for _, profile := range resp.Profiles { + marker := "" + if profile.IsActive { + marker = "✓" + } + name := profilemanager.StripCtrlChars(profile.Name) + id := profilemanager.ID(profile.Id) + if profileListShowID { + fmt.Fprintf(tw, "%s\t%s\t%s\n", id.ShortID(), name, marker) + } else { + fmt.Fprintf(tw, "%s\t%s\n", name, marker) + } + } + return tw.Flush() } func addProfileFunc(cmd *cobra.Command, args []string) error { @@ -109,6 +138,41 @@ func addProfileFunc(cmd *cobra.Command, args []string) error { return err } + currUser, err := user.Current() + if err != nil { + return fmt.Errorf("get current user: %w", err) + } + + conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr) + if err != nil { + return fmt.Errorf("connect to service CLI interface: %w", err) + } + defer conn.Close() + + daemonClient := proto.NewDaemonServiceClient(conn) + profileName := args[0] + + id, err := addProfileOnDaemon(cmd.Context(), daemonClient, profileName, currUser.Username) + if err != nil { + return err + } + + dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, profileName) + if dupCount > 1 { + cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, profileName) + cmd.Println("Use `netbird profile list --show-id` to disambiguate later.") + } + + cmd.Printf("Profile added: %s %s\n", id.ShortID(), profilemanager.StripCtrlChars(profileName)) + return nil + +} + +func renameProfileFunc(cmd *cobra.Command, args []string) error { + if err := setupCmd(cmd); err != nil { + return err + } + conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr) if err != nil { return fmt.Errorf("connect to service CLI interface: %w", err) @@ -121,21 +185,43 @@ func addProfileFunc(cmd *cobra.Command, args []string) error { } daemonClient := proto.NewDaemonServiceClient(conn) + handle := args[0] + newProfilename := args[1] - profileName := args[0] - - _, err = daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{ - ProfileName: profileName, - Username: currUser.Username, + resp, err := daemonClient.RenameProfile(cmd.Context(), &proto.RenameProfileRequest{ + Handle: handle, + Username: currUser.Username, + NewProfileName: newProfilename, }) if err != nil { - return err + return wrapAmbiguityError(err, handle) } - cmd.Println("Profile added successfully:", profileName) + dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, newProfilename) + if dupCount > 1 { + cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, newProfilename) + cmd.Println("Use `netbird profile list --show-id` to disambiguate later.") + } + + cmd.Printf("Profile renamed from %s to %s\n", profilemanager.StripCtrlChars(resp.OldProfileName), profilemanager.StripCtrlChars(newProfilename)) + return nil } +func countProfilesWithName(ctx context.Context, c proto.DaemonServiceClient, username, name string) (int, error) { + resp, err := c.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username}) + if err != nil { + return 0, err + } + n := 0 + for _, p := range resp.Profiles { + if p.Name == name { + n++ + } + } + return n, nil +} + func removeProfileFunc(cmd *cobra.Command, args []string) error { if err := setupCmd(cmd); err != nil { return err @@ -153,18 +239,17 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error { } daemonClient := proto.NewDaemonServiceClient(conn) + handle := args[0] - profileName := args[0] - - _, err = daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{ - ProfileName: profileName, + resp, err := daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{ + ProfileName: handle, Username: currUser.Username, }) if err != nil { - return err + return wrapAmbiguityError(err, handle) } - cmd.Println("Profile removed successfully:", profileName) + cmd.Printf("Profile removed: %s\n", resp.Id) return nil } @@ -174,7 +259,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { } profileManager := profilemanager.NewProfileManager() - profileName := args[0] + handle := args[0] currUser, err := user.Current() if err != nil { @@ -191,32 +276,15 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { daemonClient := proto.NewDaemonServiceClient(conn) - profiles, err := daemonClient.ListProfiles(ctx, &proto.ListProfilesRequest{ - Username: currUser.Username, + switchResp, err := daemonClient.SwitchProfile(ctx, &proto.SwitchProfileRequest{ + ProfileName: &handle, + Username: &currUser.Username, }) if err != nil { - return fmt.Errorf("list profiles: %w", err) + return wrapAmbiguityError(err, handle) } - var profileExists bool - - for _, profile := range profiles.Profiles { - if profile.Name == profileName { - profileExists = true - break - } - } - - if !profileExists { - return fmt.Errorf("profile %s does not exist", profileName) - } - - if err := switchProfile(cmd.Context(), profileName, currUser.Username); err != nil { - return err - } - - err = profileManager.SwitchProfile(profileName) - if err != nil { + if err := profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)); err != nil { return err } @@ -231,6 +299,46 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { } } - cmd.Println("Profile switched successfully to:", profileName) + id := profilemanager.ID(switchResp.Id) + cmd.Printf("Profile switched to: %s\n", id.ShortID()) return nil } + +// wrapAmbiguityError turns the daemon's gRPC InvalidArgument errors +// (which carry the resolver's message verbatim) into CLI-friendly text +// that points the user at --show-id. +func wrapAmbiguityError(err error, handle string) error { + if err == nil { + return nil + } + st, ok := gstatus.FromError(err) + if !ok { + return err + } + switch st.Code() { + case codes.InvalidArgument: + msg := st.Message() + if strings.Contains(msg, "ambiguous") { + return errors.New(msg + "\nRun `netbird profile list --show-id` to see IDs, then select by ID prefix:\n netbird profile select|remove ") + } + case codes.NotFound: + return fmt.Errorf("profile %q not found", handle) + } + return err +} + +// addProfileOnDaemon issues the AddProfile RPC on an existing daemon client +// and returns the new profile's ID. It is the single entry point for profile +// creation, shared by `netbird profile add` and the `netbird up --profile +// ` auto-create path. +func addProfileOnDaemon(ctx context.Context, client proto.DaemonServiceClient, profileName, username string) (profilemanager.ID, error) { + resp, err := client.AddProfile(ctx, &proto.AddProfileRequest{ + ProfileName: profileName, + Username: username, + }) + if err != nil { + return "", fmt.Errorf("add profile failed: %w", err) + } + + return profilemanager.ID(resp.Id), nil +} diff --git a/client/cmd/root.go b/client/cmd/root.go index b1d960bec..f3fde2f1c 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -190,6 +190,7 @@ func init() { // profile commands profileCmd.AddCommand(profileListCmd) profileCmd.AddCommand(profileAddCmd) + profileCmd.AddCommand(profileRenameCmd) profileCmd.AddCommand(profileRemoveCmd) profileCmd.AddCommand(profileSelectCmd) diff --git a/client/cmd/status.go b/client/cmd/status.go index 103b3044a..5a7559cf1 100644 --- a/client/cmd/status.go +++ b/client/cmd/status.go @@ -11,7 +11,6 @@ import ( "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" nbstatus "github.com/netbirdio/netbird/client/status" "github.com/netbirdio/netbird/util" @@ -111,11 +110,10 @@ func statusFunc(cmd *cobra.Command, args []string) error { return nil } - pm := profilemanager.NewProfileManager() - var profName string - if activeProf, err := pm.GetActiveProfile(); err == nil { - profName = activeProf.Name - } + // Resolve the active profile's display name via the daemon, which runs + // as root and can read the per-user profile files. The local profile + // manager only knows the active profile ID, not its display name. + profName := getActiveProfileName(ctx) var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{ Anonymize: anonymizeFlag, @@ -167,6 +165,25 @@ func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) ( return resp, nil } +// getActiveProfileName asks the daemon for the active profile's display +// name. The daemon runs as root and can read the per-user profile files to +// resolve the ID to its human-readable name. Returns an empty string on any +// error so status output degrades gracefully. +func getActiveProfileName(ctx context.Context) string { + conn, err := DialClientGRPCServer(ctx, daemonAddr) + if err != nil { + return "" + } + defer conn.Close() + + resp, err := proto.NewDaemonServiceClient(conn).GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}) + if err != nil { + return "" + } + + return resp.GetProfileName() +} + func parseFilters() error { switch strings.ToLower(statusFilter) { case "", "idle", "connecting", "connected": diff --git a/client/cmd/up.go b/client/cmd/up.go index cabd0aacf..0506bc65b 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -128,16 +128,9 @@ func upFunc(cmd *cobra.Command, args []string) error { var profileSwitched bool // switch profile if provided if profileName != "" { - err = switchProfile(cmd.Context(), profileName, username.Username) - if err != nil { + if err := switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username); err != nil { return fmt.Errorf("switch profile: %v", err) } - - err = pm.SwitchProfile(profileName) - if err != nil { - return fmt.Errorf("switch profile: %v", err) - } - profileSwitched = true } @@ -152,6 +145,52 @@ func upFunc(cmd *cobra.Command, args []string) error { return runInDaemonMode(ctx, cmd, pm, activeProf, profileSwitched) } +// switchOrCreateProfile switches the active profile to the one identified by +// handle, creating it first when it does not exist yet. This restores the +// pre-0.73 behaviour where `netbird up --profile ` auto-creates a +// missing profile instead of failing. +func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) error { + resolvedID, err := switchProfile(ctx, handle, username) + if err != nil { + st, ok := gstatus.FromError(err) + if !ok || st.Code() != codes.NotFound { + return err + } + // Don't fail immediately on a create error: a concurrent run may + // have created the profile between the NotFound above and this + // call, in which case the retried switch still succeeds. Only + // surface the create error if the switch also fails. + _, createErr := createProfile(ctx, handle, username) + if resolvedID, err = switchProfile(ctx, handle, username); err != nil { + if createErr != nil { + return fmt.Errorf("create profile: %w", createErr) + } + return err + } + } + + if err := pm.SwitchProfile(resolvedID); err != nil { + return err + } + return nil +} + +// createProfile dials the daemon and creates a new profile with the given +// display name, returning its generated ID. Use addProfileOnDaemon directly +// when a daemon client is already available to reuse the connection. +func createProfile(ctx context.Context, profileName, username string) (profilemanager.ID, error) { + conn, err := DialClientGRPCServer(ctx, daemonAddr) + if err != nil { + //nolint + return "", fmt.Errorf("failed to connect to daemon error: %v\n"+ + "If the daemon is not running please run: "+ + "\nnetbird service install \nnetbird service start\n", err) + } + defer conn.Close() + + return addProfileOnDaemon(ctx, proto.NewDaemonServiceClient(conn), profileName, username) +} + func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error { // override the default profile filepath if provided if configPath != "" { @@ -190,7 +229,7 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr _, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath) - err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.Name) + err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID) if err != nil { return fmt.Errorf("foreground login failed: %v", err) } @@ -261,10 +300,10 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager } // set the new config - req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.Name, username.Username) + req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username) if _, err := client.SetConfig(ctx, req); err != nil { if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable { - log.Warnf("setConfig method is not available in the daemon") + log.Warnf("setConfig method is not available in the daemon: %s", st.Message()) } else { return fmt.Errorf("call service setConfig method: %v", err) } @@ -289,10 +328,11 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ return fmt.Errorf("setup login request: %v", err) } - loginRequest.ProfileName = &activeProf.Name + profileID := activeProf.ID.String() + loginRequest.ProfileName = &profileID loginRequest.Username = &username - profileState, err := pm.GetProfileState(activeProf.Name) + profileState, err := pm.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { @@ -329,7 +369,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ } if _, err := client.Up(ctx, &proto.UpRequest{ - ProfileName: &activeProf.Name, + ProfileName: &profileID, Username: &username, }); err != nil { return fmt.Errorf("call service up method: %v", err) diff --git a/client/cmd/up_daemon_test.go b/client/cmd/up_daemon_test.go index 682a45365..ea4cdf162 100644 --- a/client/cmd/up_daemon_test.go +++ b/client/cmd/up_daemon_test.go @@ -29,14 +29,14 @@ func TestUpDaemon(t *testing.T) { } sm := profilemanager.ServiceManager{} - err = sm.AddProfile("test1", currUser.Username) + created, err := sm.AddProfile("test1", currUser.Username) if err != nil { t.Fatalf("failed to add profile: %v", err) return } err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "test1", + ID: created.ID, Username: currUser.Username, }) if err != nil { diff --git a/client/embed/embed.go b/client/embed/embed.go index 0e8991be2..d0d88b177 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -279,9 +279,11 @@ func (c *Client) Start(startCtx context.Context) error { select { case <-startCtx.Done(): - // Cancel the client context before stopping: Engine.Start blocks on the - // signal stream while holding the engine mutex and only unblocks on - // cancellation. Stopping first would deadlock on that mutex. + // ConnectClient.Stop now cancels its own run context and waits for the + // run loop to tear the engine down, so this cancel() is no longer + // required to break the deadlock and could be removed. It is kept as a + // defensive belt-and-suspenders: cancelling the parent context first + // guarantees the run loop is unblocked even if Stop's contract regresses. cancel() if stopErr := client.Stop(); stopErr != nil { return fmt.Errorf("stop error after context done. Stop error: %w. Context done: %w", stopErr, startCtx.Err()) diff --git a/client/internal/connect.go b/client/internal/connect.go index d93b62bb5..7cd2bab22 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -11,6 +11,7 @@ import ( "runtime/debug" "strings" "sync" + "sync/atomic" "time" "github.com/cenkalti/backoff/v4" @@ -54,6 +55,10 @@ var androidRunOverride func(c *ConnectClient, runningChan chan struct{}, logPath type ConnectClient struct { ctx context.Context + runCancel context.CancelFunc + runExited chan struct{} + runOnce sync.Once + runStarted atomic.Bool config *profilemanager.Config statusRecorder *peer.Status @@ -70,8 +75,14 @@ func NewConnectClient( config *profilemanager.Config, statusRecorder *peer.Status, ) *ConnectClient { + // Derive the run context here so Stop owns the cancel that unblocks the run + // loop. runCancel is set once at construction, so Stop can call it without + // racing the run loop's startup. Callers therefore need not cancel before Stop. + runCtx, runCancel := context.WithCancel(ctx) return &ConnectClient{ - ctx: ctx, + ctx: runCtx, + runCancel: runCancel, + runExited: make(chan struct{}), config: config, statusRecorder: statusRecorder, engineMutex: sync.Mutex{}, @@ -135,6 +146,11 @@ func (c *ConnectClient) RunOniOS( } func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan struct{}, logPath string) error { + // Mark the loop as started and signal exit on return so Stop can wait for + // the loop to finish (and skip the wait if the loop never ran). + c.runStarted.Store(true) + defer c.runOnce.Do(func() { close(c.runExited) }) + defer func() { if r := recover(); r != nil { rec := c.statusRecorder @@ -290,7 +306,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan log.Debug(err) if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) { state.Set(StatusNeedsLogin) - _ = c.Stop() + c.runCancel() return backoff.Permanent(wrapErr(err)) // unrecoverable error } return wrapErr(err) @@ -410,14 +426,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan c.engine = nil c.engineMutex.Unlock() - // todo: consider to remove this condition. Is not thread safe. - // We should always call Stop(), but we need to verify that it is idempotent - if engine.wgInterface != nil { - log.Infof("ensuring %s is removed, Netbird engine context cancelled", engine.wgInterface.Name()) + log.Infof("ensuring wg interface is removed, Netbird engine context cancelled") - if err := engine.Stop(); err != nil { - log.Errorf("Failed to stop engine: %v", err) - } + if err := engine.Stop(); err != nil { + log.Errorf("Failed to stop engine: %v", err) } c.statusRecorder.ClientTeardown() @@ -433,12 +445,12 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan } c.statusRecorder.ClientStart() - err = backoff.Retry(operation, backOff) + err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx)) if err != nil { log.Debugf("exiting client retry loop due to unrecoverable error: %s", err) if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.PermissionDenied) { state.Set(StatusNeedsLogin) - _ = c.Stop() + c.runCancel() } return err } @@ -516,11 +528,9 @@ func (c *ConnectClient) Status() StatusType { } func (c *ConnectClient) Stop() error { - engine := c.Engine() - if engine != nil { - if err := engine.Stop(); err != nil { - return fmt.Errorf("stop engine: %w", err) - } + c.runCancel() + if c.runStarted.Load() { + <-c.runExited } return nil } diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 76df588a5..ca7785d35 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -843,6 +843,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { "PreSharedKey": "sensitive: WireGuard pre-shared key", "SSHKey": "sensitive: SSH private key", "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", + "Name": "non-config: profile name is not needed for debug purposes", "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields", } diff --git a/client/internal/dns/mgmt/mgmt.go b/client/internal/dns/mgmt/mgmt.go index 988e427fb..ddc8cf585 100644 --- a/client/internal/dns/mgmt/mgmt.go +++ b/client/internal/dns/mgmt/mgmt.go @@ -51,13 +51,20 @@ type cachedRecord struct { } // Resolver caches critical NetBird infrastructure domains. -// records, refreshing, mgmtDomain and serverDomains are all guarded by mutex. +// records, refreshing, failedResolves, mgmtDomain and serverDomains are all +// guarded by mutex. type Resolver struct { records map[dns.Question]*cachedRecord mgmtDomain *domain.Domain serverDomains *dnsconfig.ServerDomains mutex sync.RWMutex + // failedResolves records the last failed initial resolve per domain so a + // domain that never resolves isn't retried on every server-domains update + // until refreshBackoff elapses. Entries are cleared on success and pruned + // to the current server-domains set. + failedResolves map[domain.Domain]time.Time + chain ChainResolver chainMaxPriority int refreshGroup singleflight.Group @@ -76,9 +83,10 @@ type Resolver struct { // NewResolver creates a new management domains cache resolver. func NewResolver() *Resolver { return &Resolver{ - records: make(map[dns.Question]*cachedRecord), - refreshing: make(map[dns.Question]*atomic.Bool), - cacheTTL: resolveCacheTTL(), + records: make(map[dns.Question]*cachedRecord), + refreshing: make(map[dns.Question]*atomic.Bool), + failedResolves: make(map[domain.Domain]time.Time), + cacheTTL: resolveCacheTTL(), } } @@ -173,7 +181,9 @@ func (m *Resolver) continueToNext(w dns.ResponseWriter, r *dns.Msg) { // AddDomain resolves a domain and stores its A/AAAA records in the cache. // A family that resolves NODATA (nil err, zero records) evicts any stale -// entry for that qtype. +// entry for that qtype. When one family hard-errors while the other succeeds, +// the resolved family is still cached but AddDomain returns an error so the +// caller retries the incomplete resolve rather than treating it as complete. func (m *Resolver) AddDomain(ctx context.Context, d domain.Domain) error { dnsName := strings.ToLower(dns.Fqdn(d.PunycodeString())) @@ -203,6 +213,10 @@ func (m *Resolver) AddDomain(ctx context.Context, d domain.Domain) error { log.Debugf("added/updated domain=%s with %d A records and %d AAAA records", d.SafeString(), len(aRecords), len(aaaaRecords)) + if errA != nil || errAAAA != nil { + return fmt.Errorf("resolve %s: incomplete, a family failed: %w", d.SafeString(), errors.Join(errA, errAAAA)) + } + return nil } @@ -462,6 +476,7 @@ func (m *Resolver) RemoveDomain(d domain.Domain) error { delete(m.records, qAAAA) delete(m.refreshing, qA) delete(m.refreshing, qAAAA) + delete(m.failedResolves, d) log.Debugf("removed domain=%s from cache", d.SafeString()) return nil @@ -505,6 +520,7 @@ func (m *Resolver) UpdateFromServerDomains(ctx context.Context, serverDomains dn allDomains := m.extractDomainsFromServerDomains(updatedServerDomains) currentDomains := m.GetCachedDomains() removedDomains = m.removeStaleDomains(currentDomains, allDomains) + m.pruneFailedResolves(allDomains) } m.addNewDomains(ctx, newDomains) @@ -577,13 +593,85 @@ func (m *Resolver) isManagementDomain(domain domain.Domain) bool { return m.mgmtDomain != nil && domain == *m.mgmtDomain } -// addNewDomains resolves and caches all domains from the update +// addNewDomains resolves and caches domains that are not yet in the cache, +// running the lookups concurrently. Domains already cached are skipped and left +// to the stale-while-revalidate refresh path, so a sync never re-resolves them +// synchronously: once NetBird owns the OS resolver the resolve runs through the +// handler chain and would otherwise dial the managed upstreams under the engine +// sync lock on every update. func (m *Resolver) addNewDomains(ctx context.Context, newDomains domain.List) { + var wg sync.WaitGroup + seen := make(map[domain.Domain]struct{}, len(newDomains)) for _, newDomain := range newDomains { - if err := m.AddDomain(ctx, newDomain); err != nil { - log.Warnf("failed to add/update domain=%s: %v", newDomain.SafeString(), err) - } else { - log.Debugf("added/updated management cache domain=%s", newDomain.SafeString()) + if _, dup := seen[newDomain]; dup { + continue + } + seen[newDomain] = struct{}{} + + if !m.needsResolve(newDomain) { + continue + } + + wg.Add(1) + go func(d domain.Domain) { + defer wg.Done() + if err := m.AddDomain(ctx, d); err != nil { + m.markResolveFailed(d) + log.Warnf("failed to add/update domain=%s: %v", d.SafeString(), err) + return + } + m.clearResolveFailed(d) + log.Debugf("added/updated management cache domain=%s", d.SafeString()) + }(newDomain) + } + wg.Wait() +} + +// needsResolve reports whether d should be resolved now. A recent failed or +// incomplete resolve gates retries on the backoff even when one family is +// already cached, so a transiently-failed family is retried instead of being +// treated as fully resolved. Otherwise a domain with any cached record is left +// to the stale-while-revalidate refresh path. +func (m *Resolver) needsResolve(d domain.Domain) bool { + dnsName := strings.ToLower(dns.Fqdn(d.PunycodeString())) + + m.mutex.RLock() + defer m.mutex.RUnlock() + + if failedAt, ok := m.failedResolves[d]; ok { + return time.Since(failedAt) >= refreshBackoff + } + + for _, qtype := range []uint16{dns.TypeA, dns.TypeAAAA} { + q := dns.Question{Name: dnsName, Qtype: qtype, Qclass: dns.ClassINET} + if _, ok := m.records[q]; ok { + return false + } + } + return true +} + +func (m *Resolver) markResolveFailed(d domain.Domain) { + m.mutex.Lock() + m.failedResolves[d] = time.Now() + m.mutex.Unlock() +} + +func (m *Resolver) clearResolveFailed(d domain.Domain) { + m.mutex.Lock() + delete(m.failedResolves, d) + m.mutex.Unlock() +} + +// pruneFailedResolves drops failure markers for domains no longer present in +// the server-domains set, keeping the map bounded to the current set (a +// failed-only domain has no cached record, so RemoveDomain never sees it). +func (m *Resolver) pruneFailedResolves(domains domain.List) { + m.mutex.Lock() + defer m.mutex.Unlock() + for d := range m.failedResolves { + if !slices.Contains(domains, d) { + delete(m.failedResolves, d) } } } diff --git a/client/internal/dns/mgmt/mgmt_refresh_test.go b/client/internal/dns/mgmt/mgmt_refresh_test.go index 9faa5a0b8..64a5342e2 100644 --- a/client/internal/dns/mgmt/mgmt_refresh_test.go +++ b/client/internal/dns/mgmt/mgmt_refresh_test.go @@ -21,6 +21,7 @@ type fakeChain struct { mu sync.Mutex calls map[string]int answers map[string][]dns.RR + qErr map[string]error err error hasRoot bool onLookup func() @@ -30,6 +31,7 @@ func newFakeChain() *fakeChain { return &fakeChain{ calls: map[string]int{}, answers: map[string][]dns.RR{}, + qErr: map[string]error{}, hasRoot: true, } } @@ -47,6 +49,9 @@ func (f *fakeChain) ResolveInternal(ctx context.Context, msg *dns.Msg, maxPriori f.calls[key]++ answers := f.answers[key] err := f.err + if err == nil { + err = f.qErr[key] + } onLookup := f.onLookup f.mu.Unlock() @@ -75,6 +80,12 @@ func (f *fakeChain) setAnswer(name string, qtype uint16, ip string) { } } +func (f *fakeChain) setErr(name string, qtype uint16, err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.qErr[name+"|"+dns.TypeToString[qtype]] = err +} + func (f *fakeChain) callCount(name string, qtype uint16) int { f.mu.Lock() defer f.mu.Unlock() diff --git a/client/internal/dns/mgmt/mgmt_resolve_test.go b/client/internal/dns/mgmt/mgmt_resolve_test.go new file mode 100644 index 000000000..5cfbac8f0 --- /dev/null +++ b/client/internal/dns/mgmt/mgmt_resolve_test.go @@ -0,0 +1,183 @@ +package mgmt + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" + "github.com/netbirdio/netbird/shared/management/domain" +) + +// A domain already in the cache must not be re-resolved on a subsequent server +// domains update; it is left to the stale-while-revalidate refresh path. +func TestResolver_UpdateFromServerDomains_SkipsCached(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("signal.example.com.", dns.TypeA, "10.0.0.2") + r.SetChainResolver(chain, 50) + + sd := dnsconfig.ServerDomains{Signal: domain.Domain("signal.example.com")} + + _, err := r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + require.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA), + "first update must resolve the domain") + + _, err = r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + assert.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA), + "cached domain must not be re-resolved on a subsequent update") +} + +// New domains in a single update must resolve concurrently rather than serially. +func TestResolver_AddNewDomains_ResolvesConcurrently(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + + var inflight, maxInflight atomic.Int32 + chain.onLookup = func() { + n := inflight.Add(1) + for { + old := maxInflight.Load() + if n <= old || maxInflight.CompareAndSwap(old, n) { + break + } + } + time.Sleep(50 * time.Millisecond) + inflight.Add(-1) + } + + relays := []domain.Domain{"a.example.com", "b.example.com", "c.example.com", "d.example.com"} + for _, d := range relays { + chain.setAnswer(dns.Fqdn(string(d)), dns.TypeA, "10.0.0.2") + } + r.SetChainResolver(chain, 50) + + start := time.Now() + _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: relays}) + require.NoError(t, err) + elapsed := time.Since(start) + + assert.GreaterOrEqual(t, int(maxInflight.Load()), 2, "domains must resolve concurrently") + // Serial resolution of 4 domains would take at least 4*50ms; concurrent is far less. + assert.Less(t, elapsed, 300*time.Millisecond, "resolution should not be serial") +} + +// A domain that fails to resolve must not be retried on every update; the +// failure backoff suppresses re-resolution until it expires. +func TestResolver_UpdateFromServerDomains_BacksOffFailures(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.err = errors.New("resolve boom") + r.SetChainResolver(chain, 50) + + sd := dnsconfig.ServerDomains{Signal: domain.Domain("signal.example.com")} + + _, err := r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + require.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA), + "first update must attempt the resolve") + + _, err = r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + assert.Equal(t, 1, chain.callCount("signal.example.com.", dns.TypeA), + "failed resolve must back off and not retry on the next update") +} + +// A domain listed under more than one server-domain type (e.g. STUN and TURN on +// the same host) must be resolved once per update, not once per occurrence. +func TestResolver_AddNewDomains_DedupesDuplicateDomains(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("dup.example.com.", dns.TypeA, "10.0.0.9") + r.SetChainResolver(chain, 50) + + sd := dnsconfig.ServerDomains{ + Stuns: []domain.Domain{"dup.example.com"}, + Turns: []domain.Domain{"dup.example.com"}, + } + + _, err := r.UpdateFromServerDomains(context.Background(), sd) + require.NoError(t, err) + assert.Equal(t, 1, chain.callCount("dup.example.com.", dns.TypeA), + "a domain appearing under multiple server-domain types must resolve once") +} + +// A failure marker must be dropped once its domain leaves the server-domains set +// so the map stays bounded to the current set. +func TestResolver_UpdateFromServerDomains_PrunesFailedResolves(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.err = errors.New("resolve boom") + r.SetChainResolver(chain, 50) + + _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Signal: domain.Domain("gone.example.com")}) + require.NoError(t, err) + r.mutex.RLock() + _, marked := r.failedResolves[domain.Domain("gone.example.com")] + r.mutex.RUnlock() + require.True(t, marked, "failed resolve must be recorded") + + _, err = r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Signal: domain.Domain("other.example.com")}) + require.NoError(t, err) + r.mutex.RLock() + _, stillMarked := r.failedResolves[domain.Domain("gone.example.com")] + r.mutex.RUnlock() + assert.False(t, stillMarked, "failure marker for a domain no longer in the set must be pruned") +} + +// When one family hard-errors while the other resolves, the domain is cached +// for the working family but recorded as incomplete so the failed family is +// retried under backoff instead of being treated as fully resolved forever. +func TestResolver_AddNewDomains_RetriesPartialFamilyFailure(t *testing.T) { + d := domain.Domain("relay.example.com") + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("relay.example.com.", dns.TypeA, "10.0.0.2") + chain.setErr("relay.example.com.", dns.TypeAAAA, errors.New("servfail")) + r.SetChainResolver(chain, 50) + + _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: []domain.Domain{d}}) + require.NoError(t, err) + + r.mutex.RLock() + _, aCached := r.records[dns.Question{Name: "relay.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}] + _, marked := r.failedResolves[d] + r.mutex.RUnlock() + require.True(t, aCached, "the working family must still be cached") + require.True(t, marked, "a partial failure must be recorded so the failed family is retried") + + assert.False(t, r.needsResolve(d), "within the backoff window the domain is not retried") + + r.mutex.Lock() + r.failedResolves[d] = time.Now().Add(-2 * refreshBackoff) + r.mutex.Unlock() + assert.True(t, r.needsResolve(d), "after the backoff elapses the domain is retried to pick up the missing family") +} + +// A family that returns NODATA (legitimately absent, e.g. an IPv4-only host) is +// not a failure: the domain must not be marked for retry, otherwise it would be +// re-resolved on every sync. +func TestResolver_AddNewDomains_NodataIsNotFailure(t *testing.T) { + d := domain.Domain("v4only.example.com") + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("v4only.example.com.", dns.TypeA, "10.0.0.2") + r.SetChainResolver(chain, 50) + + _, err := r.UpdateFromServerDomains(context.Background(), dnsconfig.ServerDomains{Relay: []domain.Domain{d}}) + require.NoError(t, err) + + r.mutex.RLock() + _, marked := r.failedResolves[d] + r.mutex.RUnlock() + assert.False(t, marked, "a NODATA family must not be recorded as a failure") + assert.False(t, r.needsResolve(d), "an IPv4-only host must not be re-resolved on later syncs") +} diff --git a/client/internal/dns/resutil/resolve.go b/client/internal/dns/resutil/resolve.go index 07a70d6d1..a2599aee7 100644 --- a/client/internal/dns/resutil/resolve.go +++ b/client/internal/dns/resutil/resolve.go @@ -207,3 +207,35 @@ func FormatAnswers(answers []dns.RR) string { } return "[" + strings.Join(parts, ", ") + "]" } + +// StripOPT removes any OPT pseudo-RRs from the message's Extra section. Per +// RFC 6891 a responder must not include an OPT RR toward a client that did not +// advertise EDNS0. +func StripOPT(msg *dns.Msg) { + if len(msg.Extra) == 0 { + return + } + out := msg.Extra[:0] + for _, rr := range msg.Extra { + if _, ok := rr.(*dns.OPT); ok { + continue + } + out = append(out, rr) + } + msg.Extra = out +} + +// ExtractEDE returns the first Extended DNS Error (RFC 8914) option carried in +// the message, if present. +func ExtractEDE(msg *dns.Msg) (*dns.EDNS0_EDE, bool) { + opt := msg.IsEdns0() + if opt == nil { + return nil, false + } + for _, o := range opt.Option { + if ede, ok := o.(*dns.EDNS0_EDE); ok { + return ede, true + } + } + return nil, false +} diff --git a/client/internal/dns/resutil/resolve_test.go b/client/internal/dns/resutil/resolve_test.go index 432367c22..e6a8cc6a5 100644 --- a/client/internal/dns/resutil/resolve_test.go +++ b/client/internal/dns/resutil/resolve_test.go @@ -120,3 +120,42 @@ func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) { assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "upstream failure should map to SERVFAIL") } + +func TestStripOPT(t *testing.T) { + rm := &dns.Msg{ + Extra: []dns.RR{ + &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}, + &dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)}, + }, + } + StripOPT(rm) + assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept") + _, isOPT := rm.Extra[0].(*dns.OPT) + assert.False(t, isOPT, "remaining record must not be OPT") +} + +func TestExtractEDE(t *testing.T) { + t.Run("no edns", func(t *testing.T) { + _, ok := ExtractEDE(&dns.Msg{}) + assert.False(t, ok, "message without OPT has no EDE") + }) + + t.Run("edns without ede", func(t *testing.T) { + rm := &dns.Msg{} + rm.SetEdns0(4096, false) + _, ok := ExtractEDE(rm) + assert.False(t, ok, "OPT without EDE option returns false") + }) + + t.Run("with ede", func(t *testing.T) { + rm := &dns.Msg{} + opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}} + opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: 49152, ExtraText: "upstream timeout"}) + rm.Extra = append(rm.Extra, opt) + + ede, ok := ExtractEDE(rm) + assert.True(t, ok, "EDE option should be found") + assert.Equal(t, uint16(49152), ede.InfoCode) + assert.Equal(t, "upstream timeout", ede.ExtraText) + }) +} diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index dcd4cb9d0..7556c66cc 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -6,6 +6,7 @@ import ( "fmt" "net/netip" "net/url" + "os" "slices" "strings" "sync" @@ -38,11 +39,15 @@ const ( // defaultWarningDelayBase is the starting grace window before a // "Nameserver group unreachable" event fires for a group that's // never been healthy and only has overlay upstreams with no - // Connected peer. Per-server and overridable; see warningDelayFor. - defaultWarningDelayBase = 30 * time.Second + // Connected peer. Per-server and overridable via envWarningDelay; + // see warningDelay. + defaultWarningDelayBase = 60 * time.Second // warningDelayBonusCap caps the route-count bonus added to the - // base grace window. See warningDelayFor. + // base grace window. See warningDelay. warningDelayBonusCap = 30 * time.Second + // envWarningDelay overrides defaultWarningDelayBase with a Go duration + // string (e.g. "90s", "2m"). Invalid or non-positive values are ignored. + envWarningDelay = "NB_DNS_HEALTH_WARNING_DELAY" ) // errNoUsableNameservers signals that a merged-domain group has no usable @@ -135,7 +140,7 @@ type DefaultServer struct { disableSys bool mux sync.Mutex service service - dnsMuxMap registeredHandlerMap + dnsMuxHandlers []handlerWrapper localResolver *local.Resolver wgInterface WGIface hostManager hostManager @@ -199,8 +204,6 @@ type handlerWrapper struct { priority int } -type registeredHandlerMap map[types.HandlerID]handlerWrapper - // DefaultServerConfig holds configuration parameters for NewDefaultServer type DefaultServerConfig struct { WgInterface WGIface @@ -289,7 +292,6 @@ func newDefaultServer( service: dnsService, handlerChain: handlerChain, extraDomains: make(map[domain.Domain]int), - dnsMuxMap: make(registeredHandlerMap), localResolver: local.NewResolver(), wgInterface: wgInterface, statusRecorder: statusRecorder, @@ -298,7 +300,7 @@ func newDefaultServer( hostManager: &noopHostConfigurator{}, mgmtCacheResolver: mgmtCacheResolver, currentConfigHash: ^uint64(0), // Initialize to max uint64 to ensure first config is always applied - warningDelayBase: defaultWarningDelayBase, + warningDelayBase: warningDelayBaseFromEnv(), healthRefresh: make(chan struct{}, 1), } // Wire the local resolver against the peer status recorder so it can @@ -328,7 +330,7 @@ func (s *DefaultServer) SetRouteSources(selected, active func() route.HAMap) { type routeSettable interface { setSelectedRoutes(func() route.HAMap) } - for _, entry := range s.dnsMuxMap { + for _, entry := range s.dnsMuxHandlers { if h, ok := entry.handler.(routeSettable); ok { h.setSelectedRoutes(selected) } @@ -978,19 +980,23 @@ func (s *DefaultServer) usableNameServers(nameServers []nbdns.NameServer) []neti func (s *DefaultServer) updateMux(muxUpdates []handlerWrapper) { // this will introduce a short period of time when the server is not able to handle DNS requests - for _, existing := range s.dnsMuxMap { + for _, existing := range s.dnsMuxHandlers { s.deregisterHandler([]string{existing.domain}, existing.priority) - existing.handler.Stop() + // The local resolver is a persistent singleton shared by every custom + // zone and reused across config updates. Its chain registrations are + // per-config and must be deregistered, but Stop() cancels its lookup + // context (breaking external CNAME-target resolution) and clears its + // records, so it must not be torn down here. + if existing.handler != s.localResolver { + existing.handler.Stop() + } } - muxUpdateMap := make(registeredHandlerMap) - for _, update := range muxUpdates { s.registerHandler([]string{update.domain}, update.handler, update.priority) - muxUpdateMap[update.handler.ID()] = update } - s.dnsMuxMap = muxUpdateMap + s.dnsMuxHandlers = muxUpdates } // updateNSGroupStates records the new group set and pokes the refresher. @@ -1154,6 +1160,26 @@ func (s *DefaultServer) projectUnhealthy(p *nsGroupProj, servers []netip.AddrPor return false } +// warningDelayBaseFromEnv returns the base grace window, honoring +// envWarningDelay when it holds a valid positive Go duration. Invalid or +// non-positive values fall back to defaultWarningDelayBase. +func warningDelayBaseFromEnv() time.Duration { + val := os.Getenv(envWarningDelay) + if val == "" { + return defaultWarningDelayBase + } + d, err := time.ParseDuration(val) + if err != nil { + log.Warnf("invalid %s value %q, using default %v: %v", envWarningDelay, val, defaultWarningDelayBase, err) + return defaultWarningDelayBase + } + if d <= 0 { + log.Warnf("%s must be positive, got %v, using default %v", envWarningDelay, d, defaultWarningDelayBase) + return defaultWarningDelayBase + } + return d +} + // warningDelay returns the grace window for the given selected-route // count. Scales gently: +1s per 100 routes, capped by // warningDelayBonusCap. Parallel handshakes mean handshake time grows @@ -1204,7 +1230,7 @@ func (s *DefaultServer) groupHasImmediateUpstream(servers []netip.AddrPort, snap // in more than one handler. func (s *DefaultServer) collectUpstreamHealth() map[netip.AddrPort]UpstreamHealth { merged := make(map[netip.AddrPort]UpstreamHealth) - for _, entry := range s.dnsMuxMap { + for _, entry := range s.dnsMuxHandlers { reporter, ok := entry.handler.(upstreamHealthReporter) if !ok { continue diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go index 722c2abd7..4ef790412 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -104,19 +104,6 @@ func init() { formatter.SetTextFormatter(log.StandardLogger()) } -func generateDummyHandler(d string, servers []nbdns.NameServer) *upstreamResolverBase { - var srvs []netip.AddrPort - for _, srv := range servers { - srvs = append(srvs, srv.AddrPort()) - } - u := &upstreamResolverBase{ - domain: domain.Domain(d), - cancel: func() {}, - } - u.addRace(srvs) - return u -} - func TestUpdateDNSServer(t *testing.T) { nameServers := []nbdns.NameServer{ @@ -132,22 +119,20 @@ func TestUpdateDNSServer(t *testing.T) { }, } - dummyHandler := local.NewResolver() - testCases := []struct { name string - initUpstreamMap registeredHandlerMap + initUpstreamMap []handlerWrapper initLocalZones []nbdns.CustomZone initSerial uint64 inputSerial uint64 inputUpdate nbdns.Config shouldFail bool - expectedUpstreamMap registeredHandlerMap + expectedUpstreamMap []handlerWrapper expectedLocalQs []dns.Question }{ { name: "Initial Config Should Succeed", - initUpstreamMap: make(registeredHandlerMap), + initUpstreamMap: nil, initSerial: 0, inputSerial: 1, inputUpdate: nbdns.Config{ @@ -169,20 +154,17 @@ func TestUpdateDNSServer(t *testing.T) { }, }, }, - expectedUpstreamMap: registeredHandlerMap{ - generateDummyHandler("netbird.io", nameServers).ID(): handlerWrapper{ + expectedUpstreamMap: []handlerWrapper{ + { domain: "netbird.io", - handler: dummyHandler, priority: PriorityUpstream, }, - dummyHandler.ID(): handlerWrapper{ + { domain: "netbird.cloud", - handler: dummyHandler, priority: PriorityLocal, }, - generateDummyHandler(".", nameServers).ID(): handlerWrapper{ + { domain: nbdns.RootZone, - handler: dummyHandler, priority: PriorityDefault, }, }, @@ -191,10 +173,10 @@ func TestUpdateDNSServer(t *testing.T) { { name: "New Config Should Succeed", initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: registeredHandlerMap{ - generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{ + initUpstreamMap: []handlerWrapper{ + { domain: "netbird.cloud", - handler: dummyHandler, + handler: &mockHandler{}, priority: PriorityUpstream, }, }, @@ -215,15 +197,13 @@ func TestUpdateDNSServer(t *testing.T) { }, }, }, - expectedUpstreamMap: registeredHandlerMap{ - generateDummyHandler("netbird.io", nameServers).ID(): handlerWrapper{ + expectedUpstreamMap: []handlerWrapper{ + { domain: "netbird.io", - handler: dummyHandler, priority: PriorityUpstream, }, - "local-resolver": handlerWrapper{ + { domain: "netbird.cloud", - handler: dummyHandler, priority: PriorityLocal, }, }, @@ -232,7 +212,7 @@ func TestUpdateDNSServer(t *testing.T) { { name: "Smaller Config Serial Should Be Skipped", initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: make(registeredHandlerMap), + initUpstreamMap: nil, initSerial: 2, inputSerial: 1, shouldFail: true, @@ -240,7 +220,7 @@ func TestUpdateDNSServer(t *testing.T) { { name: "Empty NS Group Domain Or Not Primary Element Should Fail", initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: make(registeredHandlerMap), + initUpstreamMap: nil, initSerial: 0, inputSerial: 1, inputUpdate: nbdns.Config{ @@ -262,7 +242,7 @@ func TestUpdateDNSServer(t *testing.T) { { name: "Invalid NS Group Nameservers list Should Fail", initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: make(registeredHandlerMap), + initUpstreamMap: nil, initSerial: 0, inputSerial: 1, inputUpdate: nbdns.Config{ @@ -284,7 +264,7 @@ func TestUpdateDNSServer(t *testing.T) { { name: "Invalid Custom Zone Records list Should Skip", initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: make(registeredHandlerMap), + initUpstreamMap: nil, initSerial: 0, inputSerial: 1, inputUpdate: nbdns.Config{ @@ -301,42 +281,41 @@ func TestUpdateDNSServer(t *testing.T) { }, }, }, - expectedUpstreamMap: registeredHandlerMap{generateDummyHandler(".", nameServers).ID(): handlerWrapper{ + expectedUpstreamMap: []handlerWrapper{{ domain: ".", - handler: dummyHandler, priority: PriorityDefault, }}, }, { name: "Empty Config Should Succeed and Clean Maps", initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: registeredHandlerMap{ - generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{ + initUpstreamMap: []handlerWrapper{ + { domain: zoneRecords[0].Name, - handler: dummyHandler, + handler: &mockHandler{}, priority: PriorityUpstream, }, }, initSerial: 0, inputSerial: 1, inputUpdate: nbdns.Config{ServiceEnable: true}, - expectedUpstreamMap: make(registeredHandlerMap), + expectedUpstreamMap: nil, expectedLocalQs: []dns.Question{}, }, { name: "Disabled Service Should clean map", initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: registeredHandlerMap{ - generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{ + initUpstreamMap: []handlerWrapper{ + { domain: zoneRecords[0].Name, - handler: dummyHandler, + handler: &mockHandler{}, priority: PriorityUpstream, }, }, initSerial: 0, inputSerial: 1, inputUpdate: nbdns.Config{ServiceEnable: false}, - expectedUpstreamMap: make(registeredHandlerMap), + expectedUpstreamMap: nil, expectedLocalQs: []dns.Question{}, }, } @@ -393,7 +372,7 @@ func TestUpdateDNSServer(t *testing.T) { } }() - dnsServer.dnsMuxMap = testCase.initUpstreamMap + dnsServer.dnsMuxHandlers = testCase.initUpstreamMap dnsServer.localResolver.Update(testCase.initLocalZones) dnsServer.updateSerial = testCase.initSerial @@ -405,14 +384,20 @@ func TestUpdateDNSServer(t *testing.T) { t.Fatalf("update dns server should not fail, got error: %v", err) } - if len(dnsServer.dnsMuxMap) != len(testCase.expectedUpstreamMap) { - t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxMap)) + if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) { + t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers)) } - for key := range testCase.expectedUpstreamMap { - _, found := dnsServer.dnsMuxMap[key] + for _, expected := range testCase.expectedUpstreamMap { + found := false + for _, got := range dnsServer.dnsMuxHandlers { + if got.domain == expected.domain && got.priority == expected.priority { + found = true + break + } + } if !found { - t.Fatalf("update upstream failed, key %s was not found in the dnsMuxMap: %#v", key, dnsServer.dnsMuxMap) + t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers) } } @@ -512,8 +497,8 @@ func TestDNSFakeResolverHandleUpdates(t *testing.T) { } }() - dnsServer.dnsMuxMap = registeredHandlerMap{ - "id1": handlerWrapper{ + dnsServer.dnsMuxHandlers = []handlerWrapper{ + { domain: zoneRecords[0].Name, handler: &local.Resolver{}, priority: PriorityUpstream, @@ -1029,15 +1014,15 @@ func (m *mockService) RegisterMux(string, dns.Handler) {} func (m *mockService) DeregisterMux(string) {} func TestDefaultServer_UpdateMux(t *testing.T) { - baseMatchHandlers := registeredHandlerMap{ - "upstream-group1": { + baseMatchHandlers := []handlerWrapper{ + { domain: "example.com", handler: &mockHandler{ Id: "upstream-group1", }, priority: PriorityUpstream, }, - "upstream-group2": { + { domain: "example.com", handler: &mockHandler{ Id: "upstream-group2", @@ -1046,15 +1031,15 @@ func TestDefaultServer_UpdateMux(t *testing.T) { }, } - baseRootHandlers := registeredHandlerMap{ - "upstream-root1": { + baseRootHandlers := []handlerWrapper{ + { domain: ".", handler: &mockHandler{ Id: "upstream-root1", }, priority: PriorityDefault, }, - "upstream-root2": { + { domain: ".", handler: &mockHandler{ Id: "upstream-root2", @@ -1063,22 +1048,22 @@ func TestDefaultServer_UpdateMux(t *testing.T) { }, } - baseMixedHandlers := registeredHandlerMap{ - "upstream-group1": { + baseMixedHandlers := []handlerWrapper{ + { domain: "example.com", handler: &mockHandler{ Id: "upstream-group1", }, priority: PriorityUpstream, }, - "upstream-group2": { + { domain: "example.com", handler: &mockHandler{ Id: "upstream-group2", }, priority: PriorityUpstream - 1, }, - "upstream-other": { + { domain: "other.com", handler: &mockHandler{ Id: "upstream-other", @@ -1089,7 +1074,7 @@ func TestDefaultServer_UpdateMux(t *testing.T) { tests := []struct { name string - initialHandlers registeredHandlerMap + initialHandlers []handlerWrapper updates []handlerWrapper expectedHandlers map[string]string // map[HandlerID]domain description string @@ -1373,32 +1358,38 @@ func TestDefaultServer_UpdateMux(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { server := &DefaultServer{ - dnsMuxMap: tt.initialHandlers, - handlerChain: NewHandlerChain(), - service: &mockService{}, + dnsMuxHandlers: tt.initialHandlers, + handlerChain: NewHandlerChain(), + service: &mockService{}, } // Perform the update server.updateMux(tt.updates) // Verify the results - assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxMap), + assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxHandlers), "Number of handlers after update doesn't match expected") // Check each expected handler for id, expectedDomain := range tt.expectedHandlers { - handler, exists := server.dnsMuxMap[types.HandlerID(id)] - assert.True(t, exists, "Expected handler %s not found", id) - if exists { - assert.Equal(t, expectedDomain, handler.domain, + var found *handlerWrapper + for i := range server.dnsMuxHandlers { + if server.dnsMuxHandlers[i].handler.ID() == types.HandlerID(id) { + found = &server.dnsMuxHandlers[i] + break + } + } + assert.NotNil(t, found, "Expected handler %s not found", id) + if found != nil { + assert.Equal(t, expectedDomain, found.domain, "Domain mismatch for handler %s", id) } } // Verify no unexpected handlers exist - for HandlerID := range server.dnsMuxMap { - _, expected := tt.expectedHandlers[string(HandlerID)] - assert.True(t, expected, "Unexpected handler found: %s", HandlerID) + for _, entry := range server.dnsMuxHandlers { + _, expected := tt.expectedHandlers[string(entry.handler.ID())] + assert.True(t, expected, "Unexpected handler found: %s", entry.handler.ID()) } // Verify the handlerChain state and order @@ -1413,7 +1404,7 @@ func TestDefaultServer_UpdateMux(t *testing.T) { // Verify handler exists in mux foundInMux := false - for _, muxEntry := range server.dnsMuxMap { + for _, muxEntry := range server.dnsMuxHandlers { if chainEntry.Handler == muxEntry.handler && chainEntry.Priority == muxEntry.priority && chainEntry.Pattern == dns.Fqdn(muxEntry.domain) { @@ -1422,12 +1413,108 @@ func TestDefaultServer_UpdateMux(t *testing.T) { } } assert.True(t, foundInMux, - "Handler in chain not found in dnsMuxMap") + "Handler in chain not found in dnsMuxHandlers") } }) } } +// chainHasPattern reports whether the handler chain holds an entry registered +// for the given fqdn pattern at the given priority. +func chainHasPattern(s *DefaultServer, pattern string, priority int) bool { + for _, h := range s.handlerChain.handlers { + if h.OrigPattern == pattern && h.Priority == priority { + return true + } + } + return false +} + +// TestDefaultServer_UpdateMux_SharedHandlerZoneRemoval verifies that updateMux +// tracks each (handler, domain) registration independently when one handler +// serves multiple zones. Every custom zone is served by the same handler +// instance (the local resolver, whose ID is the constant "local-resolver"), so +// removing one zone must deregister exactly that zone's chain entry and leave +// the others in place. Tracking registrations by handler ID alone collapses all +// zones onto one entry, leaving removed zones in the chain to answer +// authoritatively with no records. +func TestDefaultServer_UpdateMux_SharedHandlerZoneRemoval(t *testing.T) { + // One handler serves every custom zone, mirroring s.localResolver. + shared := &mockHandler{Id: "local-resolver"} + + server := &DefaultServer{ + handlerChain: NewHandlerChain(), + service: &mockService{}, + } + + // Two custom zones under the same handler. The surviving zone is registered + // last, mirroring the management emission order. + server.updateMux([]handlerWrapper{ + {domain: "userzone.test", handler: shared, priority: PriorityLocal}, + {domain: "peerzone.test", handler: shared, priority: PriorityLocal}, + }) + + require.True(t, chainHasPattern(server, "userzone.test.", PriorityLocal), + "userzone.test should be registered after the first update") + require.True(t, chainHasPattern(server, "peerzone.test.", PriorityLocal), + "peerzone.test should be registered after the first update") + + // Remove one zone, keep the other. + server.updateMux([]handlerWrapper{ + {domain: "peerzone.test", handler: shared, priority: PriorityLocal}, + }) + + assert.True(t, chainHasPattern(server, "peerzone.test.", PriorityLocal), + "peerzone.test should remain after removing userzone.test") + assert.False(t, chainHasPattern(server, "userzone.test.", PriorityLocal), + "userzone.test handler must be deregistered, not leaked in the chain") +} + +// TestDefaultServer_UpdateMux_PreservesLocalResolver verifies that updateMux +// does not tear down the shared local resolver during reconfiguration. The +// resolver is a process-lifetime singleton reused across config updates; +// Stop() cancels its lookup context (breaking external CNAME-target +// resolution) and clears its records. updateMux must deregister its chain +// entries without stopping it. Records surviving a teardown update is the +// observable proxy: Stop() would have cleared them. +func TestDefaultServer_UpdateMux_PreservesLocalResolver(t *testing.T) { + resolver := local.NewResolver() + require.NoError(t, resolver.RegisterRecord(nbdns.SimpleRecord{ + Name: "peer.netbird.cloud.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "10.0.0.1", + })) + + server := &DefaultServer{ + handlerChain: NewHandlerChain(), + service: &mockService{}, + localResolver: resolver, + } + + server.updateMux([]handlerWrapper{ + {domain: "netbird.cloud", handler: resolver, priority: PriorityLocal}, + }) + + // Remove the zone. The resolver must survive so its records and lookup + // context stay intact for the next registration. + server.updateMux(nil) + + var response *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + response = m + return nil + }, + }, &dns.Msg{Question: []dns.Question{{Name: "peer.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}}) + + require.NotNil(t, response, "local resolver should answer after teardown") + assert.Equal(t, dns.RcodeSuccess, response.Rcode, + "local resolver records must survive teardown; updateMux must not Stop() the shared resolver") + assert.NotEmpty(t, response.Answer, "answer should contain the surviving record") +} + func TestExtraDomains(t *testing.T) { tests := []struct { name string @@ -2049,7 +2136,6 @@ func TestBuildUpstreamHandler_MergesGroupsPerDomain(t *testing.T) { localResolver: local.NewResolver(), handlerChain: NewHandlerChain(), hostManager: &noopHostConfigurator{}, - dnsMuxMap: make(registeredHandlerMap), } groups := []*nbdns.NameServerGroup{ @@ -2207,7 +2293,7 @@ func TestEvaluateNSGroupHealth(t *testing.T) { } } -// healthStubHandler is a minimal dnsMuxMap entry that exposes a fixed +// healthStubHandler is a minimal dnsMuxHandlers entry that exposes a fixed // UpstreamHealth snapshot, letting tests drive recomputeNSGroupStates // without spinning up real handlers. type healthStubHandler struct { @@ -2283,12 +2369,11 @@ func newProjTestFixture(t *testing.T) *projTestFixture { ctx: context.Background(), wgInterface: &mocWGIface{}, statusRecorder: recorder, - dnsMuxMap: make(registeredHandlerMap), selectedRoutes: func() route.HAMap { return fx.selected }, activeRoutes: func() route.HAMap { return fx.active }, warningDelayBase: defaultWarningDelayBase, } - fx.server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: fx.stub, priority: PriorityUpstream} + fx.server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: fx.stub, priority: PriorityUpstream}} fx.server.mux.Lock() fx.server.updateNSGroupStates([]*nbdns.NameServerGroup{fx.group}) @@ -2395,7 +2480,6 @@ func TestProjection_OverlayAddrNoRouteDelaysWarning(t *testing.T) { ctx: context.Background(), wgInterface: &mocWGIface{}, statusRecorder: recorder, - dnsMuxMap: make(registeredHandlerMap), selectedRoutes: func() route.HAMap { return nil }, activeRoutes: func() route.HAMap { return nil }, warningDelayBase: 50 * time.Millisecond, @@ -2407,7 +2491,7 @@ func TestProjection_OverlayAddrNoRouteDelaysWarning(t *testing.T) { stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{ overlayPeer: {LastFail: time.Now(), LastErr: "timeout"}, }} - server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream} + server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}} server.mux.Lock() server.updateNSGroupStates([]*nbdns.NameServerGroup{group}) @@ -2444,7 +2528,6 @@ func TestProjection_StopClearsHealthState(t *testing.T) { service: NewServiceViaMemory(wgIface), hostManager: &noopHostConfigurator{}, extraDomains: map[domain.Domain]int{}, - dnsMuxMap: make(registeredHandlerMap), statusRecorder: peer.NewRecorder("mgm"), selectedRoutes: func() route.HAMap { return nil }, activeRoutes: func() route.HAMap { return nil }, @@ -2459,7 +2542,7 @@ func TestProjection_StopClearsHealthState(t *testing.T) { NameServers: []nbdns.NameServer{{IP: srv.Addr(), NSType: nbdns.UDPNameServerType, Port: int(srv.Port())}}, } stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{srv: {LastOk: time.Now()}}} - server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream} + server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}} server.mux.Lock() server.updateNSGroupStates([]*nbdns.NameServerGroup{group}) @@ -2484,6 +2567,32 @@ func TestProjection_StopClearsHealthState(t *testing.T) { // rule 3: startup failures while the peer is handshaking, then the peer // comes up and a query succeeds before the grace window elapses. No // warning should ever have fired, and no recovery either. +func TestWarningDelayBaseFromEnv(t *testing.T) { + tests := []struct { + name string + set bool + val string + want time.Duration + }{ + {name: "unset uses default", set: false, want: defaultWarningDelayBase}, + {name: "valid override", set: true, val: "90s", want: 90 * time.Second}, + {name: "valid minutes", set: true, val: "2m", want: 2 * time.Minute}, + {name: "invalid falls back", set: true, val: "notaduration", want: defaultWarningDelayBase}, + {name: "zero falls back", set: true, val: "0s", want: defaultWarningDelayBase}, + {name: "negative falls back", set: true, val: "-30s", want: defaultWarningDelayBase}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(envWarningDelay, tc.val) + if !tc.set { + os.Unsetenv(envWarningDelay) + } + assert.Equal(t, tc.want, warningDelayBaseFromEnv(), "grace window base") + }) + } +} + func TestProjection_OverlayRecoversDuringGrace(t *testing.T) { fx := newProjTestFixture(t) fx.server.warningDelayBase = 200 * time.Millisecond @@ -2595,7 +2704,6 @@ func TestProjection_MixedGroupEmitsImmediately(t *testing.T) { server := &DefaultServer{ ctx: context.Background(), statusRecorder: recorder, - dnsMuxMap: make(registeredHandlerMap), selectedRoutes: func() route.HAMap { return overlayMap }, activeRoutes: func() route.HAMap { return nil }, warningDelayBase: time.Hour, @@ -2613,7 +2721,7 @@ func TestProjection_MixedGroupEmitsImmediately(t *testing.T) { overlay: {LastFail: time.Now(), LastErr: "timeout"}, }, } - server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream} + server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}} server.mux.Lock() server.updateNSGroupStates([]*nbdns.NameServerGroup{group}) @@ -2640,7 +2748,6 @@ func TestDNSLoopPrevention(t *testing.T) { localResolver: local.NewResolver(), handlerChain: NewHandlerChain(), hostManager: &noopHostConfigurator{}, - dnsMuxMap: make(registeredHandlerMap), } tests := []struct { diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go index a4f713d68..72fc0450c 100644 --- a/client/internal/dns/upstream.go +++ b/client/internal/dns/upstream.go @@ -443,29 +443,32 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.M return raceResult{}, &upstreamFailure{upstream: upstream, reason: "no response"} } + // A valid response means the upstream is reachable, whatever the Rcode. + u.markUpstreamOk(upstream) + proto := "" if upstreamProto != nil { proto = upstreamProto.protocol } if rm.Rcode == dns.RcodeServerFailure || rm.Rcode == dns.RcodeRefused { + // SERVFAIL and REFUSED are per-question outcomes (DNSSEC-bogus names, + // refused zones, transient recursion errors), not reachability + // problems: fail over for a better answer but keep the upstream healthy. if code, ok := nonRetryableEDE(rm); ok { if !hadEdns { - stripOPT(rm) + resutil.StripOPT(rm) } - u.markUpstreamOk(upstream) return raceResult{msg: rm, upstream: upstream, protocol: proto, ede: edeName(code)}, nil } reason := dns.RcodeToString[rm.Rcode] - u.markUpstreamFail(upstream, reason) return raceResult{}, &upstreamFailure{upstream: upstream, reason: reason} } if !hadEdns { - stripOPT(rm) + resutil.StripOPT(rm) } - u.markUpstreamOk(upstream) return raceResult{msg: rm, upstream: upstream, protocol: proto}, nil } @@ -520,22 +523,6 @@ func upstreamUDPSize() uint16 { return dns.MinMsgSize } -// stripOPT removes any OPT pseudo-RRs from the response's Extra section so -// the response complies with RFC 6891 when the client did not advertise EDNS0. -func stripOPT(rm *dns.Msg) { - if len(rm.Extra) == 0 { - return - } - out := rm.Extra[:0] - for _, rr := range rm.Extra { - if _, ok := rr.(*dns.OPT); ok { - continue - } - out = append(out, rr) - } - rm.Extra = out -} - func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.AddrPort, startTime time.Time) *upstreamFailure { if !errors.Is(err, context.DeadlineExceeded) && !isTimeout(err) { return &upstreamFailure{upstream: upstream, reason: err.Error()} diff --git a/client/internal/dns/upstream_test.go b/client/internal/dns/upstream_test.go index 8b3c589f1..4c2784545 100644 --- a/client/internal/dns/upstream_test.go +++ b/client/internal/dns/upstream_test.go @@ -517,6 +517,78 @@ func TestUpstreamResolver_HealthTracking(t *testing.T) { assert.NotContains(t, health, bad, "sibling upstream should not be queried when primary answers") } +// TestUpstreamResolver_HealthTracking_ResponseMeansReachable verifies that an +// upstream which answers with SERVFAIL or REFUSED is recorded as healthy: +// those are per-question outcomes from a reachable server and must not mark +// the upstream unhealthy. Only transport failures (timeouts) do. +func TestUpstreamResolver_HealthTracking_ResponseMeansReachable(t *testing.T) { + a := netip.MustParseAddrPort("192.0.2.10:53") + b := netip.MustParseAddrPort("192.0.2.11:53") + timeoutErr := &net.OpError{Op: "read", Err: fmt.Errorf("i/o timeout")} + + tests := []struct { + name string + respA mockUpstreamResponse + respB mockUpstreamResponse + wantHealthy bool + }{ + { + name: "both SERVFAIL are reachable", + respA: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")}, + respB: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")}, + wantHealthy: true, + }, + { + name: "both REFUSED are reachable", + respA: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")}, + respB: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")}, + wantHealthy: true, + }, + { + name: "timeout marks unhealthy", + respA: mockUpstreamResponse{err: timeoutErr}, + respB: mockUpstreamResponse{err: timeoutErr}, + wantHealthy: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mockClient := &mockUpstreamResolverPerServer{ + responses: map[string]mockUpstreamResponse{ + a.String(): tc.respA, + b.String(): tc.respB, + }, + rtt: time.Millisecond, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + resolver := &upstreamResolverBase{ + ctx: ctx, + upstreamClient: mockClient, + upstreamTimeout: UpstreamTimeout, + } + resolver.addRace([]netip.AddrPort{a, b}) + + responseWriter := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { return nil }} + resolver.ServeDNS(responseWriter, new(dns.Msg).SetQuestion("example.com.", dns.TypeA)) + + health := resolver.UpstreamHealth() + require.Contains(t, health, a, "primary upstream should have a health record") + if tc.wantHealthy { + assert.False(t, health[a].LastOk.IsZero(), "responding upstream should have LastOk set") + assert.True(t, health[a].LastFail.IsZero(), "responding upstream should not be marked failed") + assert.Empty(t, health[a].LastErr, "responding upstream should have no error") + } else { + assert.False(t, health[a].LastFail.IsZero(), "timed-out upstream should be marked failed") + assert.NotEmpty(t, health[a].LastErr, "timed-out upstream should record an error") + } + }) + } +} + func TestFormatFailures(t *testing.T) { testCases := []struct { name string @@ -913,19 +985,6 @@ func TestEDEName(t *testing.T) { assert.Equal(t, "EDE 9999", edeName(9999), "unknown code falls back to numeric") } -func TestStripOPT(t *testing.T) { - rm := &dns.Msg{ - Extra: []dns.RR{ - &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}, - &dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)}, - }, - } - stripOPT(rm) - assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept") - _, isOPT := rm.Extra[0].(*dns.OPT) - assert.False(t, isOPT, "remaining record must not be OPT") -} - func TestUpstreamResolver_NonRetryableEDEShortCircuits(t *testing.T) { upstream1 := netip.MustParseAddrPort("192.0.2.1:53") upstream2 := netip.MustParseAddrPort("192.0.2.2:53") diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go index 2e8ef84ab..c15a8520f 100644 --- a/client/internal/dnsfwd/forwarder.go +++ b/client/internal/dnsfwd/forwarder.go @@ -26,6 +26,15 @@ import ( const errResolveFailed = "failed to resolve query for domain=%s: %v" const upstreamTimeout = 15 * time.Second +// EDE info codes the forwarder emits on upstream failures so the querying +// client can see the reason without inspecting this peer's logs. They live in +// the RFC 8914 Private Use range (49152-65535); the Go resolver never exposes a +// real upstream EDE here, so these cannot collide with a genuine code. +const ( + edeNetbirdUpstreamTimeout uint16 = 49152 + edeNetbirdUpstreamFailure uint16 = 49153 +) + type resolver interface { LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) } @@ -220,7 +229,7 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q result := resutil.LookupIP(ctx, f.resolver, network, qname, question.Qtype) if result.Err != nil { - f.handleDNSError(ctx, logger, w, question, resp, qname, result, startTime) + f.handleDNSError(ctx, logger, w, question, resp, qname, result, query.IsEdns0() != nil, startTime) return } @@ -333,6 +342,7 @@ func (f *DNSForwarder) handleDNSError( resp *dns.Msg, domain string, result resutil.LookupResult, + reqHasEdns bool, startTime time.Time, ) { qType := question.Qtype @@ -374,6 +384,10 @@ func (f *DNSForwarder) handleDNSError( logger.Warnf(errResolveFailed, domain, result.Err) } + if reqHasEdns { + attachEDE(resp, edeCodeFor(dnsErr), edeText(dnsErr)) + } + f.writeResponse(logger, w, resp, domain, startTime) } @@ -414,3 +428,33 @@ func (f *DNSForwarder) getMatchingEntries(domain string) (route.ResID, []*Forwar return selectedResId, matches } + +// edeCodeFor maps an upstream lookup error to the NetBird EDE info code. +func edeCodeFor(dnsErr *net.DNSError) uint16 { + if dnsErr != nil && dnsErr.IsTimeout { + return edeNetbirdUpstreamTimeout + } + return edeNetbirdUpstreamFailure +} + +// edeText builds the EDE extra-text describing the class of upstream failure. +// It deliberately omits the upstream server address, which may be an internal +// resolver and is exposed to any client permitted to use the route; the full +// detail stays in the forwarder's local log. +func edeText(dnsErr *net.DNSError) string { + if dnsErr != nil && dnsErr.IsTimeout { + return "netbird forwarder: upstream timeout" + } + return "netbird forwarder: upstream failure" +} + +// attachEDE adds an Extended DNS Error (RFC 8914) option to the response, +// creating the OPT pseudo-record if the response does not already carry one. +func attachEDE(resp *dns.Msg, code uint16, text string) { + opt := resp.IsEdns0() + if opt == nil { + resp.SetEdns0(dns.DefaultMsgSize, false) + opt = resp.IsEdns0() + } + opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text}) +} diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go index 7325ef8a7..046595473 100644 --- a/client/internal/dnsfwd/forwarder_test.go +++ b/client/internal/dnsfwd/forwarder_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" firewall "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/dns/resutil" "github.com/netbirdio/netbird/client/internal/dns/test" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/route" @@ -617,6 +618,85 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { } } +func TestDNSForwarder_UpstreamFailureEDE(t *testing.T) { + tests := []struct { + name string + lookupErr error + reqEdns bool + wantEDE bool + wantCode uint16 + wantTextHas string + }{ + { + name: "timeout with edns0", + lookupErr: &net.DNSError{Err: "i/o timeout", Server: "10.0.0.53:53", IsTimeout: true}, + reqEdns: true, + wantEDE: true, + wantCode: edeNetbirdUpstreamTimeout, + wantTextHas: "netbird forwarder: upstream timeout", + }, + { + name: "server failure with edns0", + lookupErr: &net.DNSError{Err: "server misbehaving", Server: "10.0.0.53:53"}, + reqEdns: true, + wantEDE: true, + wantCode: edeNetbirdUpstreamFailure, + wantTextHas: "netbird forwarder: upstream failure", + }, + { + name: "no edns0 in request omits ede", + lookupErr: &net.DNSError{Err: "server misbehaving", Server: "10.0.0.53:53"}, + reqEdns: false, + wantEDE: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 300, nil, &peer.Status{}, nil) + forwarder.resolver = mockResolver + + d, err := domain.FromString("example.com") + require.NoError(t, err) + forwarder.UpdateDomains([]*ForwarderEntry{{Domain: d, ResID: "test-res"}}) + + mockResolver.On("LookupNetIP", mock.Anything, "ip4", "example.com."). + Return([]netip.Addr(nil), tt.lookupErr).Once() + + query := &dns.Msg{} + query.SetQuestion("example.com.", dns.TypeA) + if tt.reqEdns { + query.SetEdns0(dns.DefaultMsgSize, false) + } + + var writtenResp *dns.Msg + mockWriter := &test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + writtenResp = m + return nil + }, + } + + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) + mockResolver.AssertExpectations(t) + + require.NotNil(t, writtenResp, "expected a response") + assert.Equal(t, dns.RcodeServerFailure, writtenResp.Rcode, "upstream failure must be SERVFAIL") + + ede, ok := resutil.ExtractEDE(writtenResp) + if !tt.wantEDE { + assert.False(t, ok, "response must not carry EDE") + return + } + require.True(t, ok, "response must carry EDE") + assert.Equal(t, tt.wantCode, ede.InfoCode, "EDE info code") + assert.Contains(t, ede.ExtraText, tt.wantTextHas, "EDE extra-text") + assert.NotContains(t, ede.ExtraText, "10.0.0.53", "must not leak upstream server address") + }) + } +} + func TestDNSForwarder_TCPTruncation(t *testing.T) { // Test that large UDP responses are truncated with TC bit set mockResolver := &MockResolver{} diff --git a/client/internal/engine.go b/client/internal/engine.go index 80a7fa343..07fa4a98a 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -88,6 +88,8 @@ const ( var ErrResetConnection = fmt.Errorf("reset connection") +var ErrEngineAlreadyStarted = errors.New("engine already started") + type EngineConfig struct { WgPort int WgIfaceName string @@ -201,6 +203,8 @@ type Engine struct { ctx context.Context cancel context.CancelFunc + started bool + wgInterface WGIface udpMux *udpmux.UniversalUDPMuxDefault @@ -288,9 +292,15 @@ func NewEngine( services EngineServices, mobileDep MobileDependency, ) *Engine { + // The engine is single-use: a fresh instance is built per connection + // cycle (see Client.run), so the run context is created once here rather + // than in Start. + ctx, cancel := context.WithCancel(clientCtx) engine := &Engine{ clientCtx: clientCtx, clientCancel: clientCancel, + ctx: ctx, + cancel: cancel, signal: services.SignalClient, signaler: peer.NewSignaler(services.SignalClient, config.WgPrivateKey), mgmClient: services.MgmClient, @@ -323,8 +333,34 @@ func (e *Engine) Stop() error { log.Debugf("tried stopping engine that is nil") return nil } + e.cancel() e.syncMsgMux.Lock() + e.stopLocked() + + e.syncMsgMux.Unlock() + + timeout := e.calculateShutdownTimeout() + log.Debugf("waiting for goroutines to finish with timeout: %v", timeout) + shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if err := waitWithContext(shutdownCtx, &e.shutdownWg); err != nil { + log.Warnf("shutdown timeout exceeded after %v, some goroutines may still be running", timeout) + } + + log.Infof("stopped Netbird Engine") + + return nil +} + +// stopLocked tears down everything Start may have brought up, in the order +// teardown requires (DNS before the interface goes down, flow manager after). +// The caller must hold syncMsgMux. It is shared by Stop and by Start's failure +// path, so a partially-initialized engine is cleaned up the same way; every +// step is nil-guarded. It does not wait on shutdownWg — the caller does that +// after releasing the lock, since the goroutines also take syncMsgMux. +func (e *Engine) stopLocked() { if e.connMgr != nil { e.connMgr.Close() } @@ -375,10 +411,6 @@ func (e *Engine) Stop() error { // so dbus and friends don't complain because of a missing interface e.stopDNSServer() - if e.cancel != nil { - e.cancel() - } - e.jobExecutorWG.Wait() // block until job goroutines finish e.close() @@ -397,21 +429,6 @@ func (e *Engine) Stop() error { if err := e.stateManager.PersistState(context.Background()); err != nil { log.Errorf("failed to persist state: %v", err) } - - e.syncMsgMux.Unlock() - - timeout := e.calculateShutdownTimeout() - log.Debugf("waiting for goroutines to finish with timeout: %v", timeout) - shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - if err := waitWithContext(shutdownCtx, &e.shutdownWg); err != nil { - log.Warnf("shutdown timeout exceeded after %v, some goroutines may still be running", timeout) - } - - log.Infof("stopped Netbird Engine") - - return nil } // calculateShutdownTimeout returns shutdown timeout: 10s base + 100ms per peer, capped at 30s. @@ -449,18 +466,38 @@ func waitWithContext(ctx context.Context, wg *sync.WaitGroup) error { // Start creates a new WireGuard tunnel interface and listens to events from Signal and Management services // Connections to remote peers are not established here. // However, they will be established once an event with a list of peers to connect to will be received from Management Service -func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) error { +func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) (err error) { e.syncMsgMux.Lock() defer e.syncMsgMux.Unlock() - if err := iface.ValidateMTU(e.config.MTU); err != nil { + // The engine is single-use. Reject a duplicate start and a start on an + // already-stopped engine (run context cancelled). + if e.started { + return ErrEngineAlreadyStarted + } + + if ctxErr := e.ctx.Err(); ctxErr != nil { + return fmt.Errorf("engine already stopped: %w", ctxErr) + } + + e.started = true + + // Tear down any partially-initialized state on a failed start. Cancel the + // run context first so goroutines started before the failure (connMgr, + // srWatcher, monitors) unwind, then stopLocked mirrors Stop's teardown (we + // already hold syncMsgMux), cleaning up route/DNS/flow/state managers too, + // not just what close() covers. + defer func() { + if err != nil { + e.cancel() + e.stopLocked() + } + }() + + if err = iface.ValidateMTU(e.config.MTU); err != nil { return fmt.Errorf("invalid MTU configuration: %w", err) } - if e.cancel != nil { - e.cancel() - } - e.ctx, e.cancel = context.WithCancel(e.clientCtx) e.exposeManager = expose.NewManager(e.ctx, e.mgmClient) wgIface, err := e.newWgIface() @@ -494,13 +531,11 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings() if err != nil { - e.close() return fmt.Errorf("read initial settings: %w", err) } dnsServer, err := e.newDnsServer(dnsConfig) if err != nil { - e.close() return fmt.Errorf("create dns server: %w", err) } e.dnsServer = dnsServer @@ -535,7 +570,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) if err = e.wgInterfaceCreate(); err != nil { log.Errorf("failed creating tunnel interface %s: [%s]", e.config.WgIfaceName, err.Error()) - e.close() return fmt.Errorf("create wg interface: %w", err) } @@ -544,7 +578,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) } if err := e.createFirewall(); err != nil { - e.close() return err } @@ -556,7 +589,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) e.udpMux, err = e.wgInterface.Up() if err != nil { log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error()) - e.close() return fmt.Errorf("up wg interface: %w", err) } @@ -581,9 +613,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) e.acl = acl.NewDefaultManager(e.firewall) } - err = e.dnsServer.Initialize() - if err != nil { - e.close() + if err := e.dnsServer.Initialize(); err != nil { return fmt.Errorf("initialize dns server: %w", err) } @@ -595,7 +625,9 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg) e.srWatcher.Start(peer.IsForceRelayed()) - e.receiveSignalEvents() + if err = e.receiveSignalEvents(); err != nil { + return err + } e.receiveManagementEvents() e.receiveJobEvents() @@ -647,7 +679,6 @@ func (e *Engine) createFirewall() error { func (e *Engine) initFirewall() error { if err := e.routeManager.SetFirewall(e.firewall); err != nil { - e.close() return fmt.Errorf("set firewall: %w", err) } @@ -1762,7 +1793,7 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV } // receiveSignalEvents connects to the Signal Service event stream to negotiate connection with remote peers -func (e *Engine) receiveSignalEvents() { +func (e *Engine) receiveSignalEvents() error { e.shutdownWg.Add(1) go func() { defer e.shutdownWg.Done() @@ -1778,6 +1809,13 @@ func (e *Engine) receiveSignalEvents() { return e.ctx.Err() } + // Self-addressed heartbeat: the signal client's receive watchdog + // round-trips this through the server to confirm the receive stream + // is delivering. Liveness is already recorded before this handler. + if msg.GetBody().GetType() == sProto.Body_HEARTBEAT { + return nil + } + conn, ok := e.peerStore.PeerConn(msg.Key) if !ok { return fmt.Errorf("wrongly addressed message %s", msg.Key) @@ -1826,7 +1864,12 @@ func (e *Engine) receiveSignalEvents() { } }() - e.signal.WaitStreamConnected() + // todo: consider to remove this blocker. I do not see benefit to block the Start operations + e.signal.WaitStreamConnected(e.ctx) + if err := e.ctx.Err(); err != nil { + return fmt.Errorf("wait for signal stream: %w", err) + } + return nil } func (e *Engine) parseNATExternalIPMappings() []string { diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 289f1906f..8f29bf072 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -247,7 +247,7 @@ func TestEngine_SSH(t *testing.T) { return } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) defer cancel() relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) @@ -426,7 +426,7 @@ func TestEngine_UpdateNetworkMap(t *testing.T) { return } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) defer cancel() relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) @@ -638,7 +638,7 @@ func TestEngine_Sync(t *testing.T) { return } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) defer cancel() // feed updates to Engine via mocked Management client @@ -817,7 +817,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) { return } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) defer cancel() wgIfaceName := fmt.Sprintf("utun%d", 104+n) @@ -1024,7 +1024,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) { return } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) defer cancel() wgIfaceName := fmt.Sprintf("utun%d", 104+n) diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index b0c7fd470..a77f0ff32 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -108,6 +108,10 @@ type ConfigInput struct { // Config Configuration type type Config struct { + // Name is the human-readable profile name shown in CLI/UI listings. + // It is independent of the profile's on-disk filename (which is the ID). + Name string + // Wireguard private key of local peer PrivateKey string PreSharedKey string @@ -270,6 +274,16 @@ func createNewConfig(input ConfigInput) (*Config, error) { } func (config *Config) apply(input ConfigInput) (updated bool, err error) { + if config.Name != "" { + sanitized, err := sanitizeDisplayName(config.Name) + if err != nil { + return false, fmt.Errorf("invalid profile name: %w", err) + } + if sanitized != config.Name { + config.Name = sanitized + updated = true + } + } if config.ManagementURL == nil { log.Infof("using default Management URL %s", DefaultManagementURL) config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL) diff --git a/client/internal/profilemanager/id.go b/client/internal/profilemanager/id.go new file mode 100644 index 000000000..3b82c8779 --- /dev/null +++ b/client/internal/profilemanager/id.go @@ -0,0 +1,118 @@ +package profilemanager + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "path/filepath" + "strings" + "unicode" + "unicode/utf8" +) + +const ( + // profileIDByteLen is the number of random bytes generated for a new + // profile ID. The resulting hex string is twice this length. + profileIDByteLen = 16 + + // shortIDLen is the number of leading characters of an ID we render in + // list output. Profiles per device are few, so 8 chars is collision-safe + // in practice and easy to type as a prefix. + shortIDLen = 8 + + // maxProfileNameLen caps the human-readable profile name to keep table + // output legible and prevent denial-of-service via huge JSON fields. + maxProfileNameLen = 128 + + // maxProfileIDLen bounds the on-disk filename we'll accept. New + // IDs are 32 hex chars, legacy stems are sanitized profile names. The + // cap is generous enough to cover both without permitting absurdly + // long filenames. + maxProfileIDLen = 64 +) + +type ID string + +// generateProfileID returns a new random hex ID for a profile file. +func generateProfileID() (ID, error) { + buf := make([]byte, profileIDByteLen) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("read random bytes: %w", err) + } + return ID(hex.EncodeToString(buf)), nil +} + +// IsValidProfileFilenameStem reports whether id is safe to use as the stem +// of a profile JSON filename. +func IsValidProfileFilenameStem(id ID) bool { + s := id.String() + if s == "" || len(s) > maxProfileIDLen { + return false + } + if s == defaultProfileName { + return true + } + if strings.ContainsAny(s, `/\`) || strings.Contains(s, "..") { + return false + } + // filepath.Base catches any leftover separators on platforms with + // exotic path conventions. + if filepath.Base(s) != s { + return false + } + for _, r := range s { + if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-') { + return false + } + } + return true +} + +// sanitizeDisplayName normalizes a user-supplied profile display name for +// storage. It strips ASCII control characters, rejects invalid UTF-8, and +// caps the length. Emojis, spaces, punctuation, and non-ASCII letters are +// preserved. Returns an error if nothing usable remains. +func sanitizeDisplayName(name string) (string, error) { + if !utf8.ValidString(name) { + return "", fmt.Errorf("name is not valid UTF-8") + } + name = StripCtrlChars(name) + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("name is empty after sanitization") + } + if utf8.RuneCountInString(name) > maxProfileNameLen { + return "", fmt.Errorf("name exceeds %d characters", maxProfileNameLen) + } + return name, nil +} + +// StripCtrlChars control characters from a name before printing it. +func StripCtrlChars(name string) string { + var b strings.Builder + b.Grow(len(name)) + for _, r := range name { + // Skip C0 controls and DEL, plus C1 controls (0x80–0x9F). + if r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F) { + continue + } + b.WriteRune(r) + } + return b.String() +} + +// ShortID truncates an ID for display. +func (id ID) ShortID() string { + if id == DefaultProfileName { + return DefaultProfileName + } + runes := []rune(id) + if len(runes) <= shortIDLen { + return id.String() + } + return string(runes[:shortIDLen]) +} + +func (id ID) String() string { + return string(id) +} diff --git a/client/internal/profilemanager/profilemanager.go b/client/internal/profilemanager/profilemanager.go index c87f521cb..e25d493d5 100644 --- a/client/internal/profilemanager/profilemanager.go +++ b/client/internal/profilemanager/profilemanager.go @@ -19,19 +19,41 @@ const ( ) type Profile struct { - Name string + // ID is the on-disk filename stem (without .json). For new profiles + // it is a 32-char hex string; legacy profiles created before the + // ID-keyed layout keep their original name as their ID. The reserved + // value "default" identifies the special default profile. + ID ID + // Name is the human-readable display name. Falls back to ID when the + // underlying JSON has no "name" field set. + Name string + // Path is the absolute path to the profile JSON. Populated by the + // loader so callers do not have to reconstruct it from ID + dir. + Path string IsActive bool } func (p *Profile) FilePath() (string, error) { - if p.Name == "" { - return "", fmt.Errorf("active profile name is empty") + if p.Path != "" { + return p.Path, nil } - if p.Name == defaultProfileName { + id := p.ID + if id == "" { + id = ID(p.Name) + } + if id == "" { + return "", fmt.Errorf("profile ID is empty") + } + + if id == defaultProfileName { return DefaultConfigPath, nil } + if !IsValidProfileFilenameStem(id) { + return "", fmt.Errorf("invalid profile ID: %q", id) + } + username, err := user.Current() if err != nil { return "", fmt.Errorf("failed to get current user: %w", err) @@ -42,10 +64,13 @@ func (p *Profile) FilePath() (string, error) { return "", fmt.Errorf("failed to get config directory for user %s: %w", username.Username, err) } - return filepath.Join(configDir, p.Name+".json"), nil + return filepath.Join(configDir, id.String()+".json"), nil } func (p *Profile) IsDefault() bool { + if p.ID != "" { + return p.ID == defaultProfileName + } return p.Name == defaultProfileName } @@ -57,18 +82,24 @@ func NewProfileManager() *ProfileManager { return &ProfileManager{} } +// GetActiveProfile returns the active profile as recorded in the local +// user state file. Only ID is populated. func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { pm.mu.Lock() defer pm.mu.Unlock() - prof := pm.getActiveProfileState() - return &Profile{Name: prof}, nil + id := pm.getActiveProfileState() + return &Profile{ID: id}, nil } -func (pm *ProfileManager) SwitchProfile(profileName string) error { - profileName = sanitizeProfileName(profileName) +// SwitchProfile records the given profile ID as active in the local user +// state file. +func (pm *ProfileManager) SwitchProfile(id ID) error { + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) + } - if err := pm.setActiveProfileState(profileName); err != nil { + if err := pm.setActiveProfileState(id); err != nil { return fmt.Errorf("failed to switch profile: %w", err) } return nil @@ -85,7 +116,7 @@ func sanitizeProfileName(name string) string { }, name) } -func (pm *ProfileManager) getActiveProfileState() string { +func (pm *ProfileManager) getActiveProfileState() ID { configDir, err := getConfigDir() if err != nil { @@ -113,10 +144,10 @@ func (pm *ProfileManager) getActiveProfileState() string { return defaultProfileName } - return profileName + return ID(profileName) } -func (pm *ProfileManager) setActiveProfileState(profileName string) error { +func (pm *ProfileManager) setActiveProfileState(id ID) error { configDir, err := getConfigDir() if err != nil { @@ -125,7 +156,7 @@ func (pm *ProfileManager) setActiveProfileState(profileName string) error { statePath := filepath.Join(configDir, activeProfileStateFilename) - err = os.WriteFile(statePath, []byte(profileName), 0600) + err = os.WriteFile(statePath, []byte(id), 0600) if err != nil { return fmt.Errorf("failed to write active profile state: %w", err) } @@ -142,7 +173,7 @@ func GetLoginHint() string { return "" } - profileState, err := pm.GetProfileState(activeProf.Name) + profileState, err := pm.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) return "" diff --git a/client/internal/profilemanager/profilemanager_test.go b/client/internal/profilemanager/profilemanager_test.go index 79a7ae650..882a71d0a 100644 --- a/client/internal/profilemanager/profilemanager_test.go +++ b/client/internal/profilemanager/profilemanager_test.go @@ -50,14 +50,14 @@ func TestServiceManager_CreateAndGetDefaultProfile(t *testing.T) { state, err := sm.GetActiveProfileState() assert.NoError(t, err) - assert.Equal(t, state.Name, defaultProfileName) // No active profile state yet + assert.Equal(t, defaultProfileName, state.ID.String()) // No active profile state yet err = sm.SetActiveProfileStateToDefault() assert.NoError(t, err) active, err := sm.GetActiveProfileState() assert.NoError(t, err) - assert.Equal(t, "default", active.Name) + assert.Equal(t, "default", active.ID.String()) }) }) } @@ -92,14 +92,14 @@ func TestServiceManager_SetActiveProfileState(t *testing.T) { currUser, err := user.Current() assert.NoError(t, err) sm := &ServiceManager{} - state := &ActiveProfileState{Name: "foo", Username: currUser.Username} + state := &ActiveProfileState{ID: "foo", Username: currUser.Username} err = sm.SetActiveProfileState(state) assert.NoError(t, err) // Should error on nil or incomplete state err = sm.SetActiveProfileState(nil) assert.Error(t, err) - err = sm.SetActiveProfileState(&ActiveProfileState{Name: "", Username: ""}) + err = sm.SetActiveProfileState(&ActiveProfileState{ID: "", Username: ""}) assert.Error(t, err) }) }) diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go index ef3eb1114..5ddd11b04 100644 --- a/client/internal/profilemanager/service.go +++ b/client/internal/profilemanager/service.go @@ -2,6 +2,7 @@ package profilemanager import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -23,12 +24,43 @@ var ( DefaultConfigPathDir = "" DefaultConfigPath = "" ActiveProfileStatePath = "" -) -var ( ErrorOldDefaultConfigNotFound = errors.New("old default config not found") ) +// ErrAmbiguousHandle is returned when a profile handle (ID prefix or name) +// matches more than one profile. Callers can render Candidates to help the +// user disambiguate. +type ErrAmbiguousHandle struct { + Handle string + Candidates []Profile + Kind AmbiguityKind +} + +// AmbiguityKind describes which matcher produced the ambiguity, so callers +// can tailor the error message. +type AmbiguityKind int + +const ( + AmbiguityKindIDPrefix AmbiguityKind = iota + AmbiguityKindName +) + +// profileMeta is the minimal slice of a profile JSON we need, so we avoid +// reading all fields +type profileMeta struct { + Name string +} + +func (e *ErrAmbiguousHandle) Error() string { + switch e.Kind { + case AmbiguityKindIDPrefix: + return fmt.Sprintf("ID prefix %q is ambiguous (matches %d profiles)", e.Handle, len(e.Candidates)) + default: + return fmt.Sprintf("name %q is ambiguous (%d profiles share this name)", e.Handle, len(e.Candidates)) + } +} + func init() { DefaultConfigPathDir = "/var/lib/netbird/" @@ -54,25 +86,34 @@ func init() { } type ActiveProfileState struct { - Name string `json:"name"` + // ID is the on-disk filename stem of the active profile. The JSON tag stays + // as "name" for backwards compatibility with active state files written + // before the ID-based config files. Legacy values were profile names, which + // were also the legacy filename stems, so they still resolve to the correct + // file on disk. + ID ID `json:"name"` Username string `json:"username"` } func (a *ActiveProfileState) FilePath() (string, error) { - if a.Name == "" { - return "", fmt.Errorf("active profile name is empty") + if a.ID == "" { + return "", fmt.Errorf("active profile ID is empty") } - if a.Name == defaultProfileName { + if a.ID == defaultProfileName { return DefaultConfigPath, nil } + if !IsValidProfileFilenameStem(a.ID) { + return "", fmt.Errorf("invalid profile ID: %q", a.ID) + } + configDir, err := getConfigDirForUser(a.Username) if err != nil { return "", fmt.Errorf("failed to get config directory for user %s: %w", a.Username, err) } - return filepath.Join(configDir, a.Name+".json"), nil + return filepath.Join(configDir, a.ID.String()+".json"), nil } type ServiceManager struct { @@ -178,7 +219,7 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) { return nil, fmt.Errorf("failed to set active profile to default: %w", err) } return &ActiveProfileState{ - Name: "default", + ID: defaultProfileName, Username: "", }, nil } else { @@ -186,12 +227,12 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) { } } - if activeProfile.Name == "" { + if activeProfile.ID == "" { if err := s.SetActiveProfileStateToDefault(); err != nil { return nil, fmt.Errorf("failed to set active profile to default: %w", err) } return &ActiveProfileState{ - Name: "default", + ID: defaultProfileName, Username: "", }, nil } @@ -216,25 +257,29 @@ func (s *ServiceManager) setDefaultActiveState() error { } func (s *ServiceManager) SetActiveProfileState(a *ActiveProfileState) error { - if a == nil || a.Name == "" { + if a == nil || a.ID == "" { return errors.New("invalid active profile state") } - if a.Name != defaultProfileName && a.Username == "" { - return fmt.Errorf("username must be set for non-default profiles, got: %s", a.Name) + if a.ID != defaultProfileName && a.Username == "" { + return fmt.Errorf("username must be set for non-default profiles, got: %s", a.ID) + } + + if a.ID != defaultProfileName && !IsValidProfileFilenameStem(a.ID) { + return fmt.Errorf("invalid profile ID: %q", a.ID) } if err := util.WriteJsonWithRestrictedPermission(context.Background(), ActiveProfileStatePath, a); err != nil { return fmt.Errorf("failed to write active profile state: %w", err) } - log.Infof("active profile set to %s for %s", a.Name, a.Username) + log.Infof("active profile set to %s for %s", a.ID, a.Username) return nil } func (s *ServiceManager) SetActiveProfileStateToDefault() error { return s.SetActiveProfileState(&ActiveProfileState{ - Name: "default", + ID: defaultProfileName, Username: "", }) } @@ -243,57 +288,117 @@ func (s *ServiceManager) DefaultProfilePath() string { return DefaultConfigPath } -func (s *ServiceManager) AddProfile(profileName, username string) error { +// AddProfile creates a new profile with a generated ID. The user-supplied +// displayName is stored inside the JSON's name field, the on-disk filename +// uses the generated ID. +// +// The returned Profile carries the freshly-generated ID so callers can +// show it to the user (and so the gRPC AddProfileResponse can include +// it). +func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) { configDir, err := s.getConfigDir(username) if err != nil { - return fmt.Errorf("failed to get config directory: %w", err) + return nil, fmt.Errorf("failed to get config directory: %w", err) } - profileName = sanitizeProfileName(profileName) - - if profileName == defaultProfileName { - return fmt.Errorf("cannot create profile with reserved name: %s", defaultProfileName) - } - - profPath := filepath.Join(configDir, profileName+".json") - profileExists, err := fileExists(profPath) + displayName, err = sanitizeDisplayName(displayName) if err != nil { - return fmt.Errorf("failed to check if profile exists: %w", err) - } - if profileExists { - return ErrProfileAlreadyExists + return nil, fmt.Errorf("invalid profile name: %w", err) } + id, err := generateProfileID() + if err != nil { + return nil, fmt.Errorf("generate profile id: %w", err) + } + + profPath := filepath.Join(configDir, id.String()+".json") cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath}) if err != nil { - return fmt.Errorf("failed to create new config: %w", err) + return nil, fmt.Errorf("failed to create new config: %w", err) + } + cfg.Name = displayName + + if err := util.WriteJson(context.Background(), profPath, cfg); err != nil { + return nil, fmt.Errorf("failed to write profile config: %w", err) } - err = util.WriteJson(context.Background(), profPath, cfg) + return &Profile{ + ID: id, + Name: displayName, + Path: profPath, + }, nil +} + +func (s *ServiceManager) RenameProfile(id ID, username string, newName string) error { + displayName, err := sanitizeDisplayName(newName) if err != nil { - return fmt.Errorf("failed to write profile config: %w", err) + return fmt.Errorf("invalid profile name: %w", err) } + if !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) + } + + profiles, err := s.loadAllProfiles(username) + if err != nil { + return fmt.Errorf("load profiles: %w", err) + } + + var target *Profile + for i := range profiles { + if profiles[i].ID == id { + target = &profiles[i] + break + } + } + if target == nil { + return ErrProfileNotFound + } + + data, err := os.ReadFile(target.Path) + if err != nil { + return err + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return err + } + cfg.Name = displayName + + if err := util.WriteJson(context.Background(), target.Path, cfg); err != nil { + return fmt.Errorf("failed to write profile name: %w", err) + } return nil } -func (s *ServiceManager) RemoveProfile(profileName, username string) error { - configDir, err := s.getConfigDir(username) - if err != nil { - return fmt.Errorf("failed to get config directory: %w", err) +// RemoveProfile deletes the profile identified by id. Callers must have +// already resolved any user-supplied handle to a concrete ID via +// ResolveProfile. +func (s *ServiceManager) RemoveProfile(id ID, username string) error { + if id == defaultProfileName { + defaultName := readProfileName(DefaultConfigPath) + if defaultName == "" { + defaultName = defaultProfileName + } + return fmt.Errorf("cannot remove default profile with name: %s", defaultName) + } + if !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) } - profileName = sanitizeProfileName(profileName) - - if profileName == defaultProfileName { - return fmt.Errorf("cannot remove profile with reserved name: %s", defaultProfileName) - } - profPath := filepath.Join(configDir, profileName+".json") - profileExists, err := fileExists(profPath) + profiles, err := s.loadAllProfiles(username) if err != nil { - return fmt.Errorf("failed to check if profile exists: %w", err) + return fmt.Errorf("load profiles: %w", err) } - if !profileExists { + + var target *Profile + for i := range profiles { + if profiles[i].ID == id { + target = &profiles[i] + break + } + } + if target == nil { return ErrProfileNotFound } @@ -301,57 +406,26 @@ func (s *ServiceManager) RemoveProfile(profileName, username string) error { if err != nil && !errors.Is(err, ErrNoActiveProfile) { return fmt.Errorf("failed to get active profile: %w", err) } - - if activeProf != nil && activeProf.Name == profileName { - return fmt.Errorf("cannot remove active profile: %s", profileName) + if activeProf != nil && activeProf.ID == id { + return fmt.Errorf("cannot remove active profile: %s", id) } - err = util.RemoveJson(profPath) - if err != nil { + if err := util.RemoveJson(target.Path); err != nil { return fmt.Errorf("failed to remove profile config: %w", err) } + + stateFile := filepath.Join(filepath.Dir(target.Path), id.String()+".state.json") + if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) { + log.Warnf("failed to remove profile state file %s: %v", stateFile, err) + } + return nil } +// ListProfiles returns every profile for the given user, including the +// default profile, with IsActive flags set. func (s *ServiceManager) ListProfiles(username string) ([]Profile, error) { - configDir, err := s.getConfigDir(username) - if err != nil { - return nil, fmt.Errorf("failed to get config directory: %w", err) - } - - files, err := util.ListFiles(configDir, "*.json") - if err != nil { - return nil, fmt.Errorf("failed to list profile files: %w", err) - } - - var filtered []string - for _, file := range files { - if strings.HasSuffix(file, "state.json") { - continue // skip state files - } - filtered = append(filtered, file) - } - sort.Strings(filtered) - - var activeProfName string - activeProf, err := s.GetActiveProfileState() - if err == nil { - activeProfName = activeProf.Name - } - - var profiles []Profile - // add default profile always - profiles = append(profiles, Profile{Name: defaultProfileName, IsActive: activeProfName == "" || activeProfName == defaultProfileName}) - for _, file := range filtered { - profileName := strings.TrimSuffix(filepath.Base(file), ".json") - var isActive bool - if activeProfName != "" && activeProfName == profileName { - isActive = true - } - profiles = append(profiles, Profile{Name: profileName, IsActive: isActive}) - } - - return profiles, nil + return s.loadAllProfiles(username) } // GetStatePath returns the path to the state file based on the operating system @@ -369,7 +443,12 @@ func (s *ServiceManager) GetStatePath() string { return defaultStatePath } - if activeProf.Name == defaultProfileName { + if activeProf.ID == defaultProfileName { + return defaultStatePath + } + + if !IsValidProfileFilenameStem(activeProf.ID) { + log.Warnf("invalid active profile ID %q, using default state path", activeProf.ID) return defaultStatePath } @@ -379,7 +458,7 @@ func (s *ServiceManager) GetStatePath() string { return defaultStatePath } - return filepath.Join(configDir, activeProf.Name+".state.json") + return filepath.Join(configDir, activeProf.ID.String()+".state.json") } // getConfigDir returns the profiles directory, using profilesDir if set, otherwise getConfigDirForUser @@ -390,3 +469,169 @@ func (s *ServiceManager) getConfigDir(username string) (string, error) { return getConfigDirForUser(username) } + +// loadAllProfiles returns every profile visible to the daemon for the +// given user, including the default profile. The returned slice is sorted +// by ID for a stable display order. +// +// Each Profile is fully populated: ID is the filename stem, Name comes +// from the JSON's "name" field (falling back to the filename stem when absent) +// and Path is built from a basename read off disk. +func (s *ServiceManager) loadAllProfiles(username string) ([]Profile, error) { + activeID, activeIsDefault := s.activeProfileID() + defaultName := readProfileName(DefaultConfigPath) + if defaultName == "" { + defaultName = defaultProfileName + } + + profiles := []Profile{{ + ID: defaultProfileName, + Name: defaultName, + Path: DefaultConfigPath, + IsActive: activeIsDefault, + }} + + configDir, err := s.getConfigDir(username) + if err != nil { + return nil, fmt.Errorf("get config directory: %w", err) + } + + entries, err := os.ReadDir(configDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return profiles, nil + } + return nil, fmt.Errorf("read profile directory: %w", err) + } + + var fileProfiles []Profile + for _, entry := range entries { + if entry.IsDir() { + continue + } + base := entry.Name() + if !strings.HasSuffix(base, ".json") { + continue + } + if strings.HasSuffix(base, ".state.json") { + continue + } + stem := ID(strings.TrimSuffix(base, ".json")) + if stem == defaultProfileName { + // default lives at the top-level config dir, not under / + continue + } + if !IsValidProfileFilenameStem(ID(stem)) { + continue + } + path := filepath.Join(configDir, base) + name := readProfileName(path) + if name == "" { + name = stem.String() + } + fileProfiles = append(fileProfiles, Profile{ + ID: stem, + Name: name, + Path: path, + IsActive: stem == ID(activeID), + }) + } + + sort.Slice(fileProfiles, func(i, j int) bool { + if fileProfiles[i].Name != fileProfiles[j].Name { + return fileProfiles[i].Name < fileProfiles[j].Name + } + // Sort tie-break on ID so duplicate names always render in the same order. + return fileProfiles[i].ID < fileProfiles[j].ID + }) + profiles = append(profiles, fileProfiles...) + return profiles, nil +} + +// readProfileName parses just the "name" field from the profile Json. +func readProfileName(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var meta profileMeta + if err := json.Unmarshal(data, &meta); err != nil { + return "" + } + return meta.Name +} + +// activeProfileID returns the currently-active profile's ID. The second +// return value is true when the active profile is the default one. +func (s *ServiceManager) activeProfileID() (ID, bool) { + state, err := s.GetActiveProfileState() + if err != nil || state == nil { + return defaultProfileName, true + } + if state.ID == "" || state.ID == defaultProfileName { + return defaultProfileName, true + } + return state.ID, false +} + +// ResolveProfile turns a user-supplied handle into a Profile. Resolution +// precedence is: exact ID match, then unique exact name, then unique ID +// prefix. Ambiguous matches return *ErrAmbiguousHandle so callers can +// surface the candidates. +func (s *ServiceManager) ResolveProfile(handle, username string) (*Profile, error) { + if handle == "" { + return nil, fmt.Errorf("profile handle is empty") + } + + profiles, err := s.loadAllProfiles(username) + if err != nil { + return nil, err + } + + for i := range profiles { + if profiles[i].ID == ID(handle) { + return &profiles[i], nil + } + } + + var nameMatches []Profile + for i := range profiles { + if profiles[i].Name == handle { + nameMatches = append(nameMatches, profiles[i]) + } + } + if len(nameMatches) == 1 { + return &nameMatches[0], nil + } + if len(nameMatches) > 1 { + return nil, &ErrAmbiguousHandle{ + Handle: handle, + Candidates: nameMatches, + Kind: AmbiguityKindName, + } + } + + // ID prefix match. Skip the default profile so `select d` does not + // accidentally pick it via prefix. + var prefixMatches []Profile + for i := range profiles { + if profiles[i].ID == defaultProfileName { + continue + } + if strings.HasPrefix(profiles[i].ID.String(), handle) { + prefixMatches = append(prefixMatches, profiles[i]) + } + } + if len(prefixMatches) == 1 { + return &prefixMatches[0], nil + } + if len(prefixMatches) > 1 { + return nil, &ErrAmbiguousHandle{ + Handle: handle, + Candidates: prefixMatches, + Kind: AmbiguityKindIDPrefix, + } + } + + return nil, ErrProfileNotFound +} diff --git a/client/internal/profilemanager/service_test.go b/client/internal/profilemanager/service_test.go new file mode 100644 index 000000000..5e051b15d --- /dev/null +++ b/client/internal/profilemanager/service_test.go @@ -0,0 +1,230 @@ +package profilemanager + +import ( + "context" + "errors" + "os" + "os/user" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/util" +) + +// withTestSM wires up patched globals + a clean config dir and returns a +// fully initialized ServiceManager plus the username we are scoped to. +func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) { + t.Helper() + withTempConfigDir(t, func(configDir string) { + withPatchedGlobals(t, configDir, func() { + u, err := user.Current() + require.NoError(t, err) + sm := &ServiceManager{} + require.NoError(t, sm.CreateDefaultProfile()) + fn(sm, u.Username) + }) + }) +} + +func TestServiceProfile_ExactID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + got, err := sm.ResolveProfile(created.ID.String(), username) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + assert.Equal(t, "work", got.Name) + }) +} + +func TestServiceProfile_IDPrefix(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + prefix := created.ID[:4] + got, err := sm.ResolveProfile(prefix.String(), username) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + }) +} + +func TestServiceProfile_AmbiguousPrefix(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + // Plant two profiles whose IDs share a known prefix by writing + // the files directly, since generated IDs are random. + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + for _, id := range []string{"abcd1111aaaa", "abcd2222bbbb"} { + path := filepath.Join(configDir, id+".json") + require.NoError(t, util.WriteJson(context.Background(), path, &Config{Name: id})) + } + + _, err = sm.ResolveProfile("abcd", username) + var amb *ErrAmbiguousHandle + require.ErrorAs(t, err, &amb) + assert.Equal(t, AmbiguityKindIDPrefix, amb.Kind) + assert.Len(t, amb.Candidates, 2) + }) +} + +func TestServiceProfile_ExactNameUnique(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.AddProfile("work", username) + require.NoError(t, err) + + got, err := sm.ResolveProfile("work", username) + require.NoError(t, err) + assert.Equal(t, "work", got.Name) + }) +} + +func TestServiceProfile_AmbiguousName(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.AddProfile("work", username) + require.NoError(t, err) + _, err = sm.AddProfile("work", username) + require.NoError(t, err) + + _, err = sm.ResolveProfile("work", username) + var amb *ErrAmbiguousHandle + require.ErrorAs(t, err, &amb) + assert.Equal(t, AmbiguityKindName, amb.Kind) + assert.Len(t, amb.Candidates, 2) + }) +} + +func TestServiceProfile_NotFound(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + _, err := sm.ResolveProfile("nope", username) + assert.ErrorIs(t, err, ErrProfileNotFound) + }) +} + +func TestServiceProfile_DefaultByExactID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + got, err := sm.ResolveProfile(defaultProfileName, username) + require.NoError(t, err) + assert.Equal(t, defaultProfileName, got.ID.String()) + }) +} + +func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) { + // Legacy profiles stored as .json with no "name" JSON field + // should still be discoverable by name and removable by name. + withTestSM(t, func(sm *ServiceManager, username string) { + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + path := filepath.Join(configDir, "legacy.json") + require.NoError(t, util.WriteJson(context.Background(), path, &Config{})) + + got, err := sm.ResolveProfile("legacy", username) + require.NoError(t, err) + assert.Equal(t, "legacy", got.ID.String()) + // Name falls back to the filename stem when JSON omits it. + assert.Equal(t, "legacy", got.Name) + }) +} + +func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + first, err := sm.AddProfile("work", username) + require.NoError(t, err) + + second, err := sm.AddProfile("work", username) + require.NoError(t, err) + assert.NotEqual(t, first.ID, second.ID) + assert.Equal(t, "work", second.Name) + }) +} + +func TestAddProfile_RejectsInvalidNames(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + cases := []string{ + "", // empty + "\x00\x01", // only control chars (becomes empty) + strings.Repeat("a", maxProfileNameLen+1), // too long + } + for _, name := range cases { + _, err := sm.AddProfile(name, username) + assert.Error(t, err, "expected error for %q", name) + } + }) +} + +func TestRemoveProfile_RejectsInvalidID(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + err := sm.RemoveProfile("../escape", username) + assert.Error(t, err) + }) +} + +func TestSanitizeDisplayName(t *testing.T) { + cases := []struct { + in string + want string + wantErr bool + }{ + {"work", "work", false}, + {"My Work Account", "My Work Account", false}, + {"emoji 🚀 ok", "emoji 🚀 ok", false}, + {"漢字テスト", "漢字テスト", false}, + {"with\x00null", "withnull", false}, + {"\x01\x02\x03", "", true}, + {"", "", true}, + } + for _, tc := range cases { + got, err := sanitizeDisplayName(tc.in) + if tc.wantErr { + assert.Error(t, err, "case %q", tc.in) + continue + } + assert.NoError(t, err, "case %q", tc.in) + assert.Equal(t, tc.want, got, "case %q", tc.in) + } +} + +func TestIsValidProfileFilenameStem(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"default", true}, + {"abc123def456", true}, + {"legacy-name", true}, + {"legacy_name", true}, + {"", false}, + {"..", false}, + {"../etc", false}, + {"foo/bar", false}, + {`foo\bar`, false}, + {"with space", false}, + {"with.dot", false}, + {strings.Repeat("a", maxProfileIDLen+1), false}, + } + for _, tc := range cases { + got := IsValidProfileFilenameStem(ID(tc.in)) + assert.Equal(t, tc.want, got, "case %q", tc.in) + } +} + +func TestRemoveProfile_DeletesStateFile(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + configDir, err := sm.getConfigDir(username) + require.NoError(t, err) + statePath := filepath.Join(configDir, created.ID.String()+".state.json") + require.NoError(t, os.WriteFile(statePath, []byte(`{"email":"a@b"}`), 0600)) + + require.NoError(t, sm.RemoveProfile(created.ID, username)) + _, err = os.Stat(statePath) + assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed") + }) +} diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go index f09391ede..1bf3318af 100644 --- a/client/internal/profilemanager/state.go +++ b/client/internal/profilemanager/state.go @@ -13,13 +13,20 @@ type ProfileState struct { Email string `json:"email"` } -func (pm *ProfileManager) GetProfileState(profileName string) (*ProfileState, error) { +// GetProfileState reads the per-profile state file keyed by profile ID. +// The state file lives in the user's config directory. Legacy state files +// keyed by the old profile name remain readable. +func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) { configDir, err := getConfigDir() if err != nil { return nil, fmt.Errorf("get config directory: %w", err) } - stateFile := filepath.Join(configDir, profileName+".state.json") + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return nil, fmt.Errorf("invalid profile ID: %q", id) + } + + stateFile := filepath.Join(configDir, id.String()+".state.json") stateFileExists, err := fileExists(stateFile) if err != nil { return nil, fmt.Errorf("failed to check if profile state file exists: %w", err) @@ -51,7 +58,12 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { return fmt.Errorf("get active profile: %w", err) } - stateFile := filepath.Join(configDir, activeProf.Name+".state.json") + id := activeProf.ID + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid active profile ID: %q", id) + } + + stateFile := filepath.Join(configDir, id.String()+".state.json") err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state) if err != nil { return fmt.Errorf("write profile state: %w", err) diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go index e25cc2a5c..22f3355c8 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -251,6 +251,14 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { r.MsgHdr.AuthenticatedData = true } + // Advertise EDNS0 to the forwarder so it may return an Extended DNS Error + // describing why a lookup failed. The OPT is stripped from the reply when + // the original client did not request EDNS0. + hadEdns := r.IsEdns0() != nil + if !hadEdns { + r.SetEdns0(dns.DefaultMsgSize, false) + } + upstream := net.JoinHostPort(upstreamIP.String(), strconv.FormatUint(uint64(d.forwarderPort.Load()), 10)) ctx, cancel := context.WithTimeout(context.Background(), dnsTimeout) defer cancel() @@ -260,6 +268,13 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { return } + if ede, ok := resutil.ExtractEDE(reply); ok { + resutil.SetMeta(w, "ede", fmt.Sprintf("%d %s", ede.InfoCode, ede.ExtraText)) + } + if !hadEdns { + resutil.StripOPT(reply) + } + resutil.SetMeta(w, "peer", peerKey) reply.Id = r.Id diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 0edf4607f..22458d575 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -333,6 +333,8 @@ func (m *DefaultManager) Stop(stateManager *statemanager.Manager) { } } + m.notifier.Close() + m.mux.Lock() defer m.mux.Unlock() m.clientRoutes = nil diff --git a/client/internal/routemanager/notifier/notifier_android.go b/client/internal/routemanager/notifier/notifier_android.go index 140a583f7..49300dbb2 100644 --- a/client/internal/routemanager/notifier/notifier_android.go +++ b/client/internal/routemanager/notifier/notifier_android.go @@ -16,7 +16,7 @@ import ( type Notifier struct { initialRoutes []*route.Route currentRoutes []*route.Route - fakeIPRoutes []*route.Route + fakeIPRoutes []*route.Route listener listener.NetworkChangeListener listenerMux sync.Mutex @@ -119,3 +119,7 @@ func (n *Notifier) GetInitialRouteRanges() []string { sort.Strings(initialStrings) return initialStrings } + +func (n *Notifier) Close() { + // unused +} diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go index 27a2a722d..d0888f3a1 100644 --- a/client/internal/routemanager/notifier/notifier_ios.go +++ b/client/internal/routemanager/notifier/notifier_ios.go @@ -3,6 +3,7 @@ package notifier import ( + "container/list" "net/netip" "slices" "sort" @@ -14,19 +15,26 @@ import ( ) type Notifier struct { + mu sync.Mutex + cond *sync.Cond currentPrefixes []string - - listener listener.NetworkChangeListener - listenerMux sync.Mutex + listener listener.NetworkChangeListener + queue *list.List + closed bool } func NewNotifier() *Notifier { - return &Notifier{} + n := &Notifier{ + queue: list.New(), + } + n.cond = sync.NewCond(&n.mu) + go n.deliverLoop() + return n } func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() + n.mu.Lock() + defer n.mu.Unlock() n.listener = listener } @@ -43,32 +51,52 @@ func (n *Notifier) OnNewRoutes(route.HAMap) { } func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { - newNets := make([]string, 0) + newNets := make([]string, 0, len(prefixes)) for _, prefix := range prefixes { newNets = append(newNets, prefix.String()) } sort.Strings(newNets) + n.mu.Lock() if slices.Equal(n.currentPrefixes, newNets) { + n.mu.Unlock() return } - n.currentPrefixes = newNets - n.notify() + routes := strings.Join(n.currentPrefixes, ",") + n.queue.PushBack(routes) + n.cond.Signal() + n.mu.Unlock() } -func (n *Notifier) notify() { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() - if n.listener == nil { - return - } - go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged(strings.Join(n.currentPrefixes, ",")) - }(n.listener) +func (n *Notifier) Close() { + n.mu.Lock() + n.closed = true + n.cond.Signal() + n.mu.Unlock() } func (n *Notifier) GetInitialRouteRanges() []string { return nil } + +func (n *Notifier) deliverLoop() { + for { + n.mu.Lock() + for n.queue.Len() == 0 && !n.closed { + n.cond.Wait() + } + if n.closed && n.queue.Len() == 0 { + n.mu.Unlock() + return + } + routes := n.queue.Remove(n.queue.Front()).(string) + l := n.listener + n.mu.Unlock() + + if l != nil { + l.OnNetworkChanged(routes) + } + } +} diff --git a/client/internal/routemanager/notifier/notifier_other.go b/client/internal/routemanager/notifier/notifier_other.go index f57cadb0b..71b1096c2 100644 --- a/client/internal/routemanager/notifier/notifier_other.go +++ b/client/internal/routemanager/notifier/notifier_other.go @@ -38,3 +38,7 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { func (n *Notifier) GetInitialRouteRanges() []string { return []string{} } + +func (n *Notifier) Close() { + // unused +} diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 9d447ef3f..432133999 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -36,6 +36,7 @@ type URLOpener interface { // Auth can register or login new client type Auth struct { ctx context.Context + cancel context.CancelFunc config *profilemanager.Config cfgPath string } @@ -51,8 +52,19 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { return nil, err } + // Use a cancellable context so Stop() can abort an in-progress interactive + // login. The PKCE flow's WaitToken blocks (and keeps its loopback HTTP server + // bound to a port) until the OAuth callback arrives or the flow expires; + // cancelling the context unblocks WaitToken, which then shuts that server down + // and frees the port for the next login attempt. iOS runs login in the main-app + // process (decoupled from the network extension), so without this the server + // lingers after the user dismisses the browser and the next connect stalls + // trying to bind the same port. + ctx, cancel := context.WithCancel(context.Background()) + return &Auth{ - ctx: context.Background(), + ctx: ctx, + cancel: cancel, config: cfg, cfgPath: cfgPath, }, nil @@ -60,12 +72,24 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { // NewAuthWithConfig instantiate Auth based on existing config func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth { + ctx, cancel := context.WithCancel(ctx) return &Auth{ ctx: ctx, + cancel: cancel, config: config, } } +// Stop aborts an in-progress interactive login started via Login/LoginWithDeviceName. +// It cancels the auth context, which unblocks the PKCE WaitToken and shuts down its +// loopback HTTP server, freeing the redirect port. Safe to call multiple times and +// safe to call when no login is running. +func (a *Auth) Stop() { + if a.cancel != nil { + a.cancel() + } +} + // SaveConfigIfSSOSupported test the connectivity with the management server by retrieving the server device flow info. // If it returns a flow info than save the configuration and return true. If it gets a codes.NotFound, it means that SSO // is not supported and returns false without saving the configuration. For other errors return false. diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 6b5a37658..488b0186c 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -3954,9 +3954,11 @@ func (x *GetEventsResponse) GetEvents() []*SystemEvent { } type SwitchProfileRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` - Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + // profileName is treated as a handle: exact ID, unique ID prefix, or + // unique display name. The daemon resolves it server-side. + ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` + Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4006,7 +4008,11 @@ func (x *SwitchProfileRequest) GetUsername() string { } type SwitchProfileResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // id is the resolved on-disk ID of the profile that became active. + // Lets CLI clients update their local active-profile state without + // duplicating the resolution logic. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4041,6 +4047,13 @@ func (*SwitchProfileResponse) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{55} } +func (x *SwitchProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + type SetConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` @@ -4397,9 +4410,11 @@ func (*SetConfigResponse) Descriptor() ([]byte, []int) { } type AddProfileRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` - ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + // profileName carries the human-readable display name for the new + // profile. The on-disk filename is a separately-generated ID. + ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4449,7 +4464,10 @@ func (x *AddProfileRequest) GetProfileName() string { } type AddProfileResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // id is the generated on-disk ID of the new profile. CLI clients + // display a truncated form, UI clients can ignore it. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4484,17 +4502,133 @@ func (*AddProfileResponse) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{59} } +func (x *AddProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type RenameProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + // handle: an exact ID, a unique ID prefix, or a unique display name. + Handle string `protobuf:"bytes,2,opt,name=handle,proto3" json:"handle,omitempty"` + // newProfileName is the new human-readable display name for the profile. + NewProfileName string `protobuf:"bytes,3,opt,name=newProfileName,proto3" json:"newProfileName,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenameProfileRequest) Reset() { + *x = RenameProfileRequest{} + mi := &file_daemon_proto_msgTypes[60] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenameProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenameProfileRequest) ProtoMessage() {} + +func (x *RenameProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[60] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RenameProfileRequest.ProtoReflect.Descriptor instead. +func (*RenameProfileRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{60} +} + +func (x *RenameProfileRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *RenameProfileRequest) GetHandle() string { + if x != nil { + return x.Handle + } + return "" +} + +func (x *RenameProfileRequest) GetNewProfileName() string { + if x != nil { + return x.NewProfileName + } + return "" +} + +type RenameProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // confirm the old profile name after resolving handle. + OldProfileName string `protobuf:"bytes,1,opt,name=oldProfileName,proto3" json:"oldProfileName,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenameProfileResponse) Reset() { + *x = RenameProfileResponse{} + mi := &file_daemon_proto_msgTypes[61] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenameProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenameProfileResponse) ProtoMessage() {} + +func (x *RenameProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[61] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RenameProfileResponse.ProtoReflect.Descriptor instead. +func (*RenameProfileResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{61} +} + +func (x *RenameProfileResponse) GetOldProfileName() string { + if x != nil { + return x.OldProfileName + } + return "" +} + type RemoveProfileRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` - ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + // profileName is treated as a handle: an exact ID, a unique ID + // prefix, or a unique display name. Resolution happens server-side. + ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RemoveProfileRequest) Reset() { *x = RemoveProfileRequest{} - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4506,7 +4640,7 @@ func (x *RemoveProfileRequest) String() string { func (*RemoveProfileRequest) ProtoMessage() {} func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4519,7 +4653,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead. func (*RemoveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{60} + return file_daemon_proto_rawDescGZIP(), []int{62} } func (x *RemoveProfileRequest) GetUsername() string { @@ -4537,14 +4671,17 @@ func (x *RemoveProfileRequest) GetProfileName() string { } type RemoveProfileResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // id is the full resolved ID of the removed profile, so callers can + // confirm exactly which profile a name/prefix handle resolved to. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RemoveProfileResponse) Reset() { *x = RemoveProfileResponse{} - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4556,7 +4693,7 @@ func (x *RemoveProfileResponse) String() string { func (*RemoveProfileResponse) ProtoMessage() {} func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4569,7 +4706,14 @@ func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileResponse.ProtoReflect.Descriptor instead. func (*RemoveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{61} + return file_daemon_proto_rawDescGZIP(), []int{63} +} + +func (x *RemoveProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" } type ListProfilesRequest struct { @@ -4581,7 +4725,7 @@ type ListProfilesRequest struct { func (x *ListProfilesRequest) Reset() { *x = ListProfilesRequest{} - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4593,7 +4737,7 @@ func (x *ListProfilesRequest) String() string { func (*ListProfilesRequest) ProtoMessage() {} func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4606,7 +4750,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead. func (*ListProfilesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{62} + return file_daemon_proto_rawDescGZIP(), []int{64} } func (x *ListProfilesRequest) GetUsername() string { @@ -4625,7 +4769,7 @@ type ListProfilesResponse struct { func (x *ListProfilesResponse) Reset() { *x = ListProfilesResponse{} - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4637,7 +4781,7 @@ func (x *ListProfilesResponse) String() string { func (*ListProfilesResponse) ProtoMessage() {} func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4650,7 +4794,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProfilesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{63} + return file_daemon_proto_rawDescGZIP(), []int{65} } func (x *ListProfilesResponse) GetProfiles() []*Profile { @@ -4664,13 +4808,14 @@ type Profile struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` IsActive bool `protobuf:"varint,2,opt,name=is_active,json=isActive,proto3" json:"is_active,omitempty"` + Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Profile) Reset() { *x = Profile{} - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4682,7 +4827,7 @@ func (x *Profile) String() string { func (*Profile) ProtoMessage() {} func (x *Profile) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4695,7 +4840,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message { // Deprecated: Use Profile.ProtoReflect.Descriptor instead. func (*Profile) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{64} + return file_daemon_proto_rawDescGZIP(), []int{66} } func (x *Profile) GetName() string { @@ -4712,6 +4857,13 @@ func (x *Profile) GetIsActive() bool { return false } +func (x *Profile) GetId() string { + if x != nil { + return x.Id + } + return "" +} + type GetActiveProfileRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -4720,7 +4872,7 @@ type GetActiveProfileRequest struct { func (x *GetActiveProfileRequest) Reset() { *x = GetActiveProfileRequest{} - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4732,7 +4884,7 @@ func (x *GetActiveProfileRequest) String() string { func (*GetActiveProfileRequest) ProtoMessage() {} func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4745,20 +4897,21 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead. func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{65} + return file_daemon_proto_rawDescGZIP(), []int{67} } type GetActiveProfileResponse struct { state protoimpl.MessageState `protogen:"open.v1"` ProfileName string `protobuf:"bytes,1,opt,name=profileName,proto3" json:"profileName,omitempty"` Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetActiveProfileResponse) Reset() { *x = GetActiveProfileResponse{} - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4770,7 +4923,7 @@ func (x *GetActiveProfileResponse) String() string { func (*GetActiveProfileResponse) ProtoMessage() {} func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4783,7 +4936,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead. func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{66} + return file_daemon_proto_rawDescGZIP(), []int{68} } func (x *GetActiveProfileResponse) GetProfileName() string { @@ -4800,6 +4953,13 @@ func (x *GetActiveProfileResponse) GetUsername() string { return "" } +func (x *GetActiveProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + type LogoutRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"` @@ -4810,7 +4970,7 @@ type LogoutRequest struct { func (x *LogoutRequest) Reset() { *x = LogoutRequest{} - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4822,7 +4982,7 @@ func (x *LogoutRequest) String() string { func (*LogoutRequest) ProtoMessage() {} func (x *LogoutRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4835,7 +4995,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. func (*LogoutRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{67} + return file_daemon_proto_rawDescGZIP(), []int{69} } func (x *LogoutRequest) GetProfileName() string { @@ -4860,7 +5020,7 @@ type LogoutResponse struct { func (x *LogoutResponse) Reset() { *x = LogoutResponse{} - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4872,7 +5032,7 @@ func (x *LogoutResponse) String() string { func (*LogoutResponse) ProtoMessage() {} func (x *LogoutResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4885,7 +5045,7 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. func (*LogoutResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{68} + return file_daemon_proto_rawDescGZIP(), []int{70} } type GetFeaturesRequest struct { @@ -4896,7 +5056,7 @@ type GetFeaturesRequest struct { func (x *GetFeaturesRequest) Reset() { *x = GetFeaturesRequest{} - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4908,7 +5068,7 @@ func (x *GetFeaturesRequest) String() string { func (*GetFeaturesRequest) ProtoMessage() {} func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4921,7 +5081,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead. func (*GetFeaturesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{69} + return file_daemon_proto_rawDescGZIP(), []int{71} } type GetFeaturesResponse struct { @@ -4935,7 +5095,7 @@ type GetFeaturesResponse struct { func (x *GetFeaturesResponse) Reset() { *x = GetFeaturesResponse{} - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4947,7 +5107,7 @@ func (x *GetFeaturesResponse) String() string { func (*GetFeaturesResponse) ProtoMessage() {} func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4960,7 +5120,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead. func (*GetFeaturesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{70} + return file_daemon_proto_rawDescGZIP(), []int{72} } func (x *GetFeaturesResponse) GetDisableProfiles() bool { @@ -4998,7 +5158,7 @@ type MDMManagedFieldsViolation struct { func (x *MDMManagedFieldsViolation) Reset() { *x = MDMManagedFieldsViolation{} - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5010,7 +5170,7 @@ func (x *MDMManagedFieldsViolation) String() string { func (*MDMManagedFieldsViolation) ProtoMessage() {} func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5023,7 +5183,7 @@ func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message { // Deprecated: Use MDMManagedFieldsViolation.ProtoReflect.Descriptor instead. func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{71} + return file_daemon_proto_rawDescGZIP(), []int{73} } func (x *MDMManagedFieldsViolation) GetFields() []string { @@ -5041,7 +5201,7 @@ type TriggerUpdateRequest struct { func (x *TriggerUpdateRequest) Reset() { *x = TriggerUpdateRequest{} - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5053,7 +5213,7 @@ func (x *TriggerUpdateRequest) String() string { func (*TriggerUpdateRequest) ProtoMessage() {} func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5066,7 +5226,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead. func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{72} + return file_daemon_proto_rawDescGZIP(), []int{74} } type TriggerUpdateResponse struct { @@ -5079,7 +5239,7 @@ type TriggerUpdateResponse struct { func (x *TriggerUpdateResponse) Reset() { *x = TriggerUpdateResponse{} - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5091,7 +5251,7 @@ func (x *TriggerUpdateResponse) String() string { func (*TriggerUpdateResponse) ProtoMessage() {} func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5104,7 +5264,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead. func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{73} + return file_daemon_proto_rawDescGZIP(), []int{75} } func (x *TriggerUpdateResponse) GetSuccess() bool { @@ -5132,7 +5292,7 @@ type GetPeerSSHHostKeyRequest struct { func (x *GetPeerSSHHostKeyRequest) Reset() { *x = GetPeerSSHHostKeyRequest{} - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5144,7 +5304,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string { func (*GetPeerSSHHostKeyRequest) ProtoMessage() {} func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5157,7 +5317,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{74} + return file_daemon_proto_rawDescGZIP(), []int{76} } func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string { @@ -5184,7 +5344,7 @@ type GetPeerSSHHostKeyResponse struct { func (x *GetPeerSSHHostKeyResponse) Reset() { *x = GetPeerSSHHostKeyResponse{} - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5196,7 +5356,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string { func (*GetPeerSSHHostKeyResponse) ProtoMessage() {} func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5209,7 +5369,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{75} + return file_daemon_proto_rawDescGZIP(), []int{77} } func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte { @@ -5251,7 +5411,7 @@ type RequestJWTAuthRequest struct { func (x *RequestJWTAuthRequest) Reset() { *x = RequestJWTAuthRequest{} - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5263,7 +5423,7 @@ func (x *RequestJWTAuthRequest) String() string { func (*RequestJWTAuthRequest) ProtoMessage() {} func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5276,7 +5436,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead. func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{76} + return file_daemon_proto_rawDescGZIP(), []int{78} } func (x *RequestJWTAuthRequest) GetHint() string { @@ -5309,7 +5469,7 @@ type RequestJWTAuthResponse struct { func (x *RequestJWTAuthResponse) Reset() { *x = RequestJWTAuthResponse{} - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5321,7 +5481,7 @@ func (x *RequestJWTAuthResponse) String() string { func (*RequestJWTAuthResponse) ProtoMessage() {} func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5334,7 +5494,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead. func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{77} + return file_daemon_proto_rawDescGZIP(), []int{79} } func (x *RequestJWTAuthResponse) GetVerificationURI() string { @@ -5399,7 +5559,7 @@ type WaitJWTTokenRequest struct { func (x *WaitJWTTokenRequest) Reset() { *x = WaitJWTTokenRequest{} - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5411,7 +5571,7 @@ func (x *WaitJWTTokenRequest) String() string { func (*WaitJWTTokenRequest) ProtoMessage() {} func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5424,7 +5584,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead. func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{78} + return file_daemon_proto_rawDescGZIP(), []int{80} } func (x *WaitJWTTokenRequest) GetDeviceCode() string { @@ -5456,7 +5616,7 @@ type WaitJWTTokenResponse struct { func (x *WaitJWTTokenResponse) Reset() { *x = WaitJWTTokenResponse{} - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5468,7 +5628,7 @@ func (x *WaitJWTTokenResponse) String() string { func (*WaitJWTTokenResponse) ProtoMessage() {} func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5481,7 +5641,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead. func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{79} + return file_daemon_proto_rawDescGZIP(), []int{81} } func (x *WaitJWTTokenResponse) GetToken() string { @@ -5514,7 +5674,7 @@ type StartCPUProfileRequest struct { func (x *StartCPUProfileRequest) Reset() { *x = StartCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5526,7 +5686,7 @@ func (x *StartCPUProfileRequest) String() string { func (*StartCPUProfileRequest) ProtoMessage() {} func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5539,7 +5699,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{80} + return file_daemon_proto_rawDescGZIP(), []int{82} } // StartCPUProfileResponse confirms CPU profiling has started @@ -5551,7 +5711,7 @@ type StartCPUProfileResponse struct { func (x *StartCPUProfileResponse) Reset() { *x = StartCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5563,7 +5723,7 @@ func (x *StartCPUProfileResponse) String() string { func (*StartCPUProfileResponse) ProtoMessage() {} func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5576,7 +5736,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{81} + return file_daemon_proto_rawDescGZIP(), []int{83} } // StopCPUProfileRequest for stopping CPU profiling @@ -5588,7 +5748,7 @@ type StopCPUProfileRequest struct { func (x *StopCPUProfileRequest) Reset() { *x = StopCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5600,7 +5760,7 @@ func (x *StopCPUProfileRequest) String() string { func (*StopCPUProfileRequest) ProtoMessage() {} func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5613,7 +5773,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{82} + return file_daemon_proto_rawDescGZIP(), []int{84} } // StopCPUProfileResponse confirms CPU profiling has stopped @@ -5625,7 +5785,7 @@ type StopCPUProfileResponse struct { func (x *StopCPUProfileResponse) Reset() { *x = StopCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5637,7 +5797,7 @@ func (x *StopCPUProfileResponse) String() string { func (*StopCPUProfileResponse) ProtoMessage() {} func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5650,7 +5810,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{83} + return file_daemon_proto_rawDescGZIP(), []int{85} } type InstallerResultRequest struct { @@ -5661,7 +5821,7 @@ type InstallerResultRequest struct { func (x *InstallerResultRequest) Reset() { *x = InstallerResultRequest{} - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5673,7 +5833,7 @@ func (x *InstallerResultRequest) String() string { func (*InstallerResultRequest) ProtoMessage() {} func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5686,7 +5846,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead. func (*InstallerResultRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{84} + return file_daemon_proto_rawDescGZIP(), []int{86} } type InstallerResultResponse struct { @@ -5699,7 +5859,7 @@ type InstallerResultResponse struct { func (x *InstallerResultResponse) Reset() { *x = InstallerResultResponse{} - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5711,7 +5871,7 @@ func (x *InstallerResultResponse) String() string { func (*InstallerResultResponse) ProtoMessage() {} func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5724,7 +5884,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead. func (*InstallerResultResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{85} + return file_daemon_proto_rawDescGZIP(), []int{87} } func (x *InstallerResultResponse) GetSuccess() bool { @@ -5757,7 +5917,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5769,7 +5929,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5782,7 +5942,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{86} + return file_daemon_proto_rawDescGZIP(), []int{88} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -5853,7 +6013,7 @@ type ExposeServiceEvent struct { func (x *ExposeServiceEvent) Reset() { *x = ExposeServiceEvent{} - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5865,7 +6025,7 @@ func (x *ExposeServiceEvent) String() string { func (*ExposeServiceEvent) ProtoMessage() {} func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5878,7 +6038,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead. func (*ExposeServiceEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{87} + return file_daemon_proto_rawDescGZIP(), []int{89} } func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event { @@ -5919,7 +6079,7 @@ type ExposeServiceReady struct { func (x *ExposeServiceReady) Reset() { *x = ExposeServiceReady{} - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5931,7 +6091,7 @@ func (x *ExposeServiceReady) String() string { func (*ExposeServiceReady) ProtoMessage() {} func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5944,7 +6104,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead. func (*ExposeServiceReady) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{88} + return file_daemon_proto_rawDescGZIP(), []int{90} } func (x *ExposeServiceReady) GetServiceName() string { @@ -5989,7 +6149,7 @@ type StartCaptureRequest struct { func (x *StartCaptureRequest) Reset() { *x = StartCaptureRequest{} - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6001,7 +6161,7 @@ func (x *StartCaptureRequest) String() string { func (*StartCaptureRequest) ProtoMessage() {} func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6014,7 +6174,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead. func (*StartCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{89} + return file_daemon_proto_rawDescGZIP(), []int{91} } func (x *StartCaptureRequest) GetTextOutput() bool { @@ -6068,7 +6228,7 @@ type CapturePacket struct { func (x *CapturePacket) Reset() { *x = CapturePacket{} - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6080,7 +6240,7 @@ func (x *CapturePacket) String() string { func (*CapturePacket) ProtoMessage() {} func (x *CapturePacket) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6093,7 +6253,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message { // Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead. func (*CapturePacket) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{90} + return file_daemon_proto_rawDescGZIP(), []int{92} } func (x *CapturePacket) GetData() []byte { @@ -6114,7 +6274,7 @@ type StartBundleCaptureRequest struct { func (x *StartBundleCaptureRequest) Reset() { *x = StartBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6126,7 +6286,7 @@ func (x *StartBundleCaptureRequest) String() string { func (*StartBundleCaptureRequest) ProtoMessage() {} func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6139,7 +6299,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{91} + return file_daemon_proto_rawDescGZIP(), []int{93} } func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration { @@ -6157,7 +6317,7 @@ type StartBundleCaptureResponse struct { func (x *StartBundleCaptureResponse) Reset() { *x = StartBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6169,7 +6329,7 @@ func (x *StartBundleCaptureResponse) String() string { func (*StartBundleCaptureResponse) ProtoMessage() {} func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6182,7 +6342,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{92} + return file_daemon_proto_rawDescGZIP(), []int{94} } type StopBundleCaptureRequest struct { @@ -6193,7 +6353,7 @@ type StopBundleCaptureRequest struct { func (x *StopBundleCaptureRequest) Reset() { *x = StopBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6205,7 +6365,7 @@ func (x *StopBundleCaptureRequest) String() string { func (*StopBundleCaptureRequest) ProtoMessage() {} func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6218,7 +6378,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{93} + return file_daemon_proto_rawDescGZIP(), []int{95} } type StopBundleCaptureResponse struct { @@ -6229,7 +6389,7 @@ type StopBundleCaptureResponse struct { func (x *StopBundleCaptureResponse) Reset() { *x = StopBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[94] + mi := &file_daemon_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6241,7 +6401,7 @@ func (x *StopBundleCaptureResponse) String() string { func (*StopBundleCaptureResponse) ProtoMessage() {} func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[94] + mi := &file_daemon_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6254,7 +6414,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{94} + return file_daemon_proto_rawDescGZIP(), []int{96} } type PortInfo_Range struct { @@ -6267,7 +6427,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[96] + mi := &file_daemon_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6279,7 +6439,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[96] + mi := &file_daemon_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6672,8 +6832,9 @@ const file_daemon_proto_rawDesc = "" + "\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + "\f_profileNameB\v\n" + - "\t_username\"\x17\n" + - "\x15SwitchProfileResponse\"\x98\x11\n" + + "\t_username\"'\n" + + "\x15SwitchProfileResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -6742,23 +6903,33 @@ const file_daemon_proto_rawDesc = "" + "\x11SetConfigResponse\"Q\n" + "\x11AddProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + - "\vprofileName\x18\x02 \x01(\tR\vprofileName\"\x14\n" + - "\x12AddProfileResponse\"T\n" + + "\vprofileName\x18\x02 \x01(\tR\vprofileName\"$\n" + + "\x12AddProfileResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"r\n" + + "\x14RenameProfileRequest\x12\x1a\n" + + "\busername\x18\x01 \x01(\tR\busername\x12\x16\n" + + "\x06handle\x18\x02 \x01(\tR\x06handle\x12&\n" + + "\x0enewProfileName\x18\x03 \x01(\tR\x0enewProfileName\"?\n" + + "\x15RenameProfileResponse\x12&\n" + + "\x0eoldProfileName\x18\x01 \x01(\tR\x0eoldProfileName\"T\n" + "\x14RemoveProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + - "\vprofileName\x18\x02 \x01(\tR\vprofileName\"\x17\n" + - "\x15RemoveProfileResponse\"1\n" + + "\vprofileName\x18\x02 \x01(\tR\vprofileName\"'\n" + + "\x15RemoveProfileResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"1\n" + "\x13ListProfilesRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\"C\n" + "\x14ListProfilesResponse\x12+\n" + - "\bprofiles\x18\x01 \x03(\v2\x0f.daemon.ProfileR\bprofiles\":\n" + + "\bprofiles\x18\x01 \x03(\v2\x0f.daemon.ProfileR\bprofiles\"J\n" + "\aProfile\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1b\n" + - "\tis_active\x18\x02 \x01(\bR\bisActive\"\x19\n" + - "\x17GetActiveProfileRequest\"X\n" + + "\tis_active\x18\x02 \x01(\bR\bisActive\x12\x0e\n" + + "\x02id\x18\x03 \x01(\tR\x02id\"\x19\n" + + "\x17GetActiveProfileRequest\"h\n" + "\x18GetActiveProfileResponse\x12 \n" + "\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" + - "\busername\x18\x02 \x01(\tR\busername\"t\n" + + "\busername\x18\x02 \x01(\tR\busername\x12\x0e\n" + + "\x02id\x18\x03 \x01(\tR\x02id\"t\n" + "\rLogoutRequest\x12%\n" + "\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + @@ -6869,7 +7040,7 @@ const file_daemon_proto_rawDesc = "" + "\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" + "\n" + - "EXPOSE_TLS\x10\x042\xaf\x17\n" + + "EXPOSE_TLS\x10\x042\xff\x17\n" + "\rDaemonService\x126\n" + "\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" + "\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" + @@ -6900,6 +7071,7 @@ const file_daemon_proto_rawDesc = "" + "\tSetConfig\x12\x18.daemon.SetConfigRequest\x1a\x19.daemon.SetConfigResponse\"\x00\x12E\n" + "\n" + "AddProfile\x12\x19.daemon.AddProfileRequest\x1a\x1a.daemon.AddProfileResponse\"\x00\x12N\n" + + "\rRenameProfile\x12\x1c.daemon.RenameProfileRequest\x1a\x1d.daemon.RenameProfileResponse\"\x00\x12N\n" + "\rRemoveProfile\x12\x1c.daemon.RemoveProfileRequest\x1a\x1d.daemon.RemoveProfileResponse\"\x00\x12K\n" + "\fListProfiles\x12\x1b.daemon.ListProfilesRequest\x1a\x1c.daemon.ListProfilesResponse\"\x00\x12W\n" + "\x10GetActiveProfile\x12\x1f.daemon.GetActiveProfileRequest\x1a .daemon.GetActiveProfileResponse\"\x00\x129\n" + @@ -6927,7 +7099,7 @@ func file_daemon_proto_rawDescGZIP() []byte { } var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 98) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 100) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ExposeProtocol)(0), // 1: daemon.ExposeProtocol @@ -6993,53 +7165,55 @@ var file_daemon_proto_goTypes = []any{ (*SetConfigResponse)(nil), // 61: daemon.SetConfigResponse (*AddProfileRequest)(nil), // 62: daemon.AddProfileRequest (*AddProfileResponse)(nil), // 63: daemon.AddProfileResponse - (*RemoveProfileRequest)(nil), // 64: daemon.RemoveProfileRequest - (*RemoveProfileResponse)(nil), // 65: daemon.RemoveProfileResponse - (*ListProfilesRequest)(nil), // 66: daemon.ListProfilesRequest - (*ListProfilesResponse)(nil), // 67: daemon.ListProfilesResponse - (*Profile)(nil), // 68: daemon.Profile - (*GetActiveProfileRequest)(nil), // 69: daemon.GetActiveProfileRequest - (*GetActiveProfileResponse)(nil), // 70: daemon.GetActiveProfileResponse - (*LogoutRequest)(nil), // 71: daemon.LogoutRequest - (*LogoutResponse)(nil), // 72: daemon.LogoutResponse - (*GetFeaturesRequest)(nil), // 73: daemon.GetFeaturesRequest - (*GetFeaturesResponse)(nil), // 74: daemon.GetFeaturesResponse - (*MDMManagedFieldsViolation)(nil), // 75: daemon.MDMManagedFieldsViolation - (*TriggerUpdateRequest)(nil), // 76: daemon.TriggerUpdateRequest - (*TriggerUpdateResponse)(nil), // 77: daemon.TriggerUpdateResponse - (*GetPeerSSHHostKeyRequest)(nil), // 78: daemon.GetPeerSSHHostKeyRequest - (*GetPeerSSHHostKeyResponse)(nil), // 79: daemon.GetPeerSSHHostKeyResponse - (*RequestJWTAuthRequest)(nil), // 80: daemon.RequestJWTAuthRequest - (*RequestJWTAuthResponse)(nil), // 81: daemon.RequestJWTAuthResponse - (*WaitJWTTokenRequest)(nil), // 82: daemon.WaitJWTTokenRequest - (*WaitJWTTokenResponse)(nil), // 83: daemon.WaitJWTTokenResponse - (*StartCPUProfileRequest)(nil), // 84: daemon.StartCPUProfileRequest - (*StartCPUProfileResponse)(nil), // 85: daemon.StartCPUProfileResponse - (*StopCPUProfileRequest)(nil), // 86: daemon.StopCPUProfileRequest - (*StopCPUProfileResponse)(nil), // 87: daemon.StopCPUProfileResponse - (*InstallerResultRequest)(nil), // 88: daemon.InstallerResultRequest - (*InstallerResultResponse)(nil), // 89: daemon.InstallerResultResponse - (*ExposeServiceRequest)(nil), // 90: daemon.ExposeServiceRequest - (*ExposeServiceEvent)(nil), // 91: daemon.ExposeServiceEvent - (*ExposeServiceReady)(nil), // 92: daemon.ExposeServiceReady - (*StartCaptureRequest)(nil), // 93: daemon.StartCaptureRequest - (*CapturePacket)(nil), // 94: daemon.CapturePacket - (*StartBundleCaptureRequest)(nil), // 95: daemon.StartBundleCaptureRequest - (*StartBundleCaptureResponse)(nil), // 96: daemon.StartBundleCaptureResponse - (*StopBundleCaptureRequest)(nil), // 97: daemon.StopBundleCaptureRequest - (*StopBundleCaptureResponse)(nil), // 98: daemon.StopBundleCaptureResponse - nil, // 99: daemon.Network.ResolvedIPsEntry - (*PortInfo_Range)(nil), // 100: daemon.PortInfo.Range - nil, // 101: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 102: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 103: google.protobuf.Timestamp + (*RenameProfileRequest)(nil), // 64: daemon.RenameProfileRequest + (*RenameProfileResponse)(nil), // 65: daemon.RenameProfileResponse + (*RemoveProfileRequest)(nil), // 66: daemon.RemoveProfileRequest + (*RemoveProfileResponse)(nil), // 67: daemon.RemoveProfileResponse + (*ListProfilesRequest)(nil), // 68: daemon.ListProfilesRequest + (*ListProfilesResponse)(nil), // 69: daemon.ListProfilesResponse + (*Profile)(nil), // 70: daemon.Profile + (*GetActiveProfileRequest)(nil), // 71: daemon.GetActiveProfileRequest + (*GetActiveProfileResponse)(nil), // 72: daemon.GetActiveProfileResponse + (*LogoutRequest)(nil), // 73: daemon.LogoutRequest + (*LogoutResponse)(nil), // 74: daemon.LogoutResponse + (*GetFeaturesRequest)(nil), // 75: daemon.GetFeaturesRequest + (*GetFeaturesResponse)(nil), // 76: daemon.GetFeaturesResponse + (*MDMManagedFieldsViolation)(nil), // 77: daemon.MDMManagedFieldsViolation + (*TriggerUpdateRequest)(nil), // 78: daemon.TriggerUpdateRequest + (*TriggerUpdateResponse)(nil), // 79: daemon.TriggerUpdateResponse + (*GetPeerSSHHostKeyRequest)(nil), // 80: daemon.GetPeerSSHHostKeyRequest + (*GetPeerSSHHostKeyResponse)(nil), // 81: daemon.GetPeerSSHHostKeyResponse + (*RequestJWTAuthRequest)(nil), // 82: daemon.RequestJWTAuthRequest + (*RequestJWTAuthResponse)(nil), // 83: daemon.RequestJWTAuthResponse + (*WaitJWTTokenRequest)(nil), // 84: daemon.WaitJWTTokenRequest + (*WaitJWTTokenResponse)(nil), // 85: daemon.WaitJWTTokenResponse + (*StartCPUProfileRequest)(nil), // 86: daemon.StartCPUProfileRequest + (*StartCPUProfileResponse)(nil), // 87: daemon.StartCPUProfileResponse + (*StopCPUProfileRequest)(nil), // 88: daemon.StopCPUProfileRequest + (*StopCPUProfileResponse)(nil), // 89: daemon.StopCPUProfileResponse + (*InstallerResultRequest)(nil), // 90: daemon.InstallerResultRequest + (*InstallerResultResponse)(nil), // 91: daemon.InstallerResultResponse + (*ExposeServiceRequest)(nil), // 92: daemon.ExposeServiceRequest + (*ExposeServiceEvent)(nil), // 93: daemon.ExposeServiceEvent + (*ExposeServiceReady)(nil), // 94: daemon.ExposeServiceReady + (*StartCaptureRequest)(nil), // 95: daemon.StartCaptureRequest + (*CapturePacket)(nil), // 96: daemon.CapturePacket + (*StartBundleCaptureRequest)(nil), // 97: daemon.StartBundleCaptureRequest + (*StartBundleCaptureResponse)(nil), // 98: daemon.StartBundleCaptureResponse + (*StopBundleCaptureRequest)(nil), // 99: daemon.StopBundleCaptureRequest + (*StopBundleCaptureResponse)(nil), // 100: daemon.StopBundleCaptureResponse + nil, // 101: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 102: daemon.PortInfo.Range + nil, // 103: daemon.SystemEvent.MetadataEntry + (*durationpb.Duration)(nil), // 104: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 105: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 102, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 104, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 103, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 103, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 102, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 105, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 105, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 104, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration 23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo 20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState 19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState @@ -7050,8 +7224,8 @@ var file_daemon_proto_depIdxs = []int32{ 55, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent 24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState 31, // 14: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 99, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry - 100, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 101, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 102, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range 32, // 17: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo 32, // 18: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo 33, // 19: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule @@ -7062,15 +7236,15 @@ var file_daemon_proto_depIdxs = []int32{ 52, // 24: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage 2, // 25: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity 3, // 26: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 103, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp - 101, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 105, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 103, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry 55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 102, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 68, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile + 104, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 70, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile 1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol - 92, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady - 102, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration - 102, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration + 94, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 104, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration + 104, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration 30, // 36: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList 5, // 37: daemon.DaemonService.Login:input_type -> daemon.LoginRequest 7, // 38: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest @@ -7090,68 +7264,70 @@ var file_daemon_proto_depIdxs = []int32{ 46, // 52: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest 48, // 53: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest 51, // 54: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest - 93, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest - 95, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest - 97, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest + 95, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest + 97, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest + 99, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest 54, // 58: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest 56, // 59: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest 58, // 60: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest 60, // 61: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest 62, // 62: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest - 64, // 63: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest - 66, // 64: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest - 69, // 65: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest - 71, // 66: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest - 73, // 67: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest - 76, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest - 78, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest - 80, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest - 82, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest - 84, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest - 86, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest - 88, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest - 90, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest - 6, // 76: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 8, // 77: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 10, // 78: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 12, // 79: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 14, // 80: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 16, // 81: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 27, // 82: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 29, // 83: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 29, // 84: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 34, // 85: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse - 36, // 86: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 38, // 87: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 40, // 88: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 43, // 89: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 45, // 90: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 47, // 91: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 49, // 92: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse - 53, // 93: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 94, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket - 96, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse - 98, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse - 55, // 97: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent - 57, // 98: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse - 59, // 99: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse - 61, // 100: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse - 63, // 101: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse - 65, // 102: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse - 67, // 103: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse - 70, // 104: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse - 72, // 105: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse - 74, // 106: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 77, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse - 79, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse - 81, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse - 83, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse - 85, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse - 87, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse - 89, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse - 91, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent - 76, // [76:115] is the sub-list for method output_type - 37, // [37:76] is the sub-list for method input_type + 64, // 63: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest + 66, // 64: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest + 68, // 65: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest + 71, // 66: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest + 73, // 67: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest + 75, // 68: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest + 78, // 69: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 80, // 70: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 82, // 71: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 84, // 72: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 86, // 73: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 88, // 74: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 90, // 75: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 92, // 76: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 6, // 77: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 8, // 78: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 10, // 79: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 12, // 80: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 14, // 81: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 16, // 82: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 27, // 83: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 29, // 84: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 29, // 85: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 34, // 86: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 36, // 87: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 38, // 88: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 40, // 89: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 43, // 90: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 45, // 91: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 47, // 92: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 49, // 93: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 53, // 94: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 96, // 95: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket + 98, // 96: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse + 100, // 97: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse + 55, // 98: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 57, // 99: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 59, // 100: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 61, // 101: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 63, // 102: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 65, // 103: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse + 67, // 104: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 69, // 105: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 72, // 106: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 74, // 107: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 76, // 108: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 79, // 109: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 81, // 110: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 83, // 111: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 85, // 112: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 87, // 113: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 89, // 114: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 91, // 115: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 93, // 116: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 77, // [77:117] is the sub-list for method output_type + 37, // [37:77] is the sub-list for method input_type 37, // [37:37] is the sub-list for extension type_name 37, // [37:37] is the sub-list for extension extendee 0, // [0:37] is the sub-list for field type_name @@ -7173,9 +7349,9 @@ func file_daemon_proto_init() { file_daemon_proto_msgTypes[48].OneofWrappers = []any{} file_daemon_proto_msgTypes[54].OneofWrappers = []any{} file_daemon_proto_msgTypes[56].OneofWrappers = []any{} - file_daemon_proto_msgTypes[67].OneofWrappers = []any{} - file_daemon_proto_msgTypes[76].OneofWrappers = []any{} - file_daemon_proto_msgTypes[87].OneofWrappers = []any{ + file_daemon_proto_msgTypes[69].OneofWrappers = []any{} + file_daemon_proto_msgTypes[78].OneofWrappers = []any{} + file_daemon_proto_msgTypes[89].OneofWrappers = []any{ (*ExposeServiceEvent_Ready)(nil), } type x struct{} @@ -7184,7 +7360,7 @@ func file_daemon_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), NumEnums: 4, - NumMessages: 98, + NumMessages: 100, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index ea668f629..c1e3fe513 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -85,6 +85,8 @@ service DaemonService { rpc AddProfile(AddProfileRequest) returns (AddProfileResponse) {} + rpc RenameProfile(RenameProfileRequest) returns (RenameProfileResponse) {} + rpc RemoveProfile(RemoveProfileRequest) returns (RemoveProfileResponse) {} rpc ListProfiles(ListProfilesRequest) returns (ListProfilesResponse) {} @@ -625,11 +627,18 @@ message GetEventsResponse { } message SwitchProfileRequest { + // profileName is treated as a handle: exact ID, unique ID prefix, or + // unique display name. The daemon resolves it server-side. optional string profileName = 1; optional string username = 2; } -message SwitchProfileResponse {} +message SwitchProfileResponse { + // id is the resolved on-disk ID of the profile that became active. + // Lets CLI clients update their local active-profile state without + // duplicating the resolution logic. + string id = 1; +} message SetConfigRequest { string username = 1; @@ -696,17 +705,42 @@ message SetConfigResponse{} message AddProfileRequest { string username = 1; + // profileName carries the human-readable display name for the new + // profile. The on-disk filename is a separately-generated ID. string profileName = 2; } -message AddProfileResponse {} +message AddProfileResponse { + // id is the generated on-disk ID of the new profile. CLI clients + // display a truncated form, UI clients can ignore it. + string id = 1; +} + +message RenameProfileRequest { + string username = 1; + // handle: an exact ID, a unique ID prefix, or a unique display name. + string handle = 2; + // newProfileName is the new human-readable display name for the profile. + string newProfileName = 3; +} + +message RenameProfileResponse { + // confirm the old profile name after resolving handle. + string oldProfileName = 1; +} message RemoveProfileRequest { string username = 1; + // profileName is treated as a handle: an exact ID, a unique ID + // prefix, or a unique display name. Resolution happens server-side. string profileName = 2; } -message RemoveProfileResponse {} +message RemoveProfileResponse { + // id is the full resolved ID of the removed profile, so callers can + // confirm exactly which profile a name/prefix handle resolved to. + string id = 1; +} message ListProfilesRequest { string username = 1; @@ -719,6 +753,7 @@ message ListProfilesResponse { message Profile { string name = 1; bool is_active = 2; + string id = 3; } message GetActiveProfileRequest {} @@ -726,6 +761,7 @@ message GetActiveProfileRequest {} message GetActiveProfileResponse { string profileName = 1; string username = 2; + string id = 3; } message LogoutRequest { diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 66a8efcc3..5f585aafc 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -45,6 +45,7 @@ const ( DaemonService_SwitchProfile_FullMethodName = "/daemon.DaemonService/SwitchProfile" DaemonService_SetConfig_FullMethodName = "/daemon.DaemonService/SetConfig" DaemonService_AddProfile_FullMethodName = "/daemon.DaemonService/AddProfile" + DaemonService_RenameProfile_FullMethodName = "/daemon.DaemonService/RenameProfile" DaemonService_RemoveProfile_FullMethodName = "/daemon.DaemonService/RemoveProfile" DaemonService_ListProfiles_FullMethodName = "/daemon.DaemonService/ListProfiles" DaemonService_GetActiveProfile_FullMethodName = "/daemon.DaemonService/GetActiveProfile" @@ -112,6 +113,7 @@ type DaemonServiceClient interface { SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) SetConfig(ctx context.Context, in *SetConfigRequest, opts ...grpc.CallOption) (*SetConfigResponse, error) AddProfile(ctx context.Context, in *AddProfileRequest, opts ...grpc.CallOption) (*AddProfileResponse, error) + RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error) RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error) ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error) GetActiveProfile(ctx context.Context, in *GetActiveProfileRequest, opts ...grpc.CallOption) (*GetActiveProfileResponse, error) @@ -422,6 +424,16 @@ func (c *daemonServiceClient) AddProfile(ctx context.Context, in *AddProfileRequ return out, nil } +func (c *daemonServiceClient) RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RenameProfileResponse) + err := c.cc.Invoke(ctx, DaemonService_RenameProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *daemonServiceClient) RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(RemoveProfileResponse) @@ -613,6 +625,7 @@ type DaemonServiceServer interface { SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error) AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) + RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error) RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error) ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error) GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error) @@ -723,6 +736,9 @@ func (UnimplementedDaemonServiceServer) SetConfig(context.Context, *SetConfigReq func (UnimplementedDaemonServiceServer) AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) { return nil, status.Error(codes.Unimplemented, "method AddProfile not implemented") } +func (UnimplementedDaemonServiceServer) RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RenameProfile not implemented") +} func (UnimplementedDaemonServiceServer) RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error) { return nil, status.Error(codes.Unimplemented, "method RemoveProfile not implemented") } @@ -1237,6 +1253,24 @@ func _DaemonService_AddProfile_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _DaemonService_RenameProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RenameProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).RenameProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_RenameProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).RenameProfile(ctx, req.(*RenameProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_RemoveProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(RemoveProfileRequest) if err := dec(in); err != nil { @@ -1567,6 +1601,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "AddProfile", Handler: _DaemonService_AddProfile_Handler, }, + { + MethodName: "RenameProfile", + Handler: _DaemonService_RenameProfile_Handler, + }, { MethodName: "RemoveProfile", Handler: _DaemonService_RemoveProfile_Handler, diff --git a/client/server/login_overrides_test.go b/client/server/login_overrides_test.go index c45557c59..5a2298764 100644 --- a/client/server/login_overrides_test.go +++ b/client/server/login_overrides_test.go @@ -79,7 +79,7 @@ func TestPersistLoginOverrides(t *testing.T) { _, err := profilemanager.UpdateOrCreateConfig(seed) require.NoError(t, err, "seed config") - activeProf := &profilemanager.ActiveProfileState{Name: "default"} + activeProf := &profilemanager.ActiveProfileState{ID: "default"} err = persistLoginOverrides(activeProf, tt.newMgmtURL, tt.newPSK) require.NoError(t, err, "persistLoginOverrides") diff --git a/client/server/server.go b/client/server/server.go index 107d34fcc..3f6dabc56 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -375,7 +375,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques return nil, err } - config, err := setConfigInputFromRequest(msg) + config, err := s.setConfigInputFromRequest(msg) if err != nil { return nil, err } @@ -398,17 +398,17 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques // field is its own optional case. Returns the resolved ConfigInput // and a non-nil error only when the active profile file path cannot // be determined. -func setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) { +func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) { var config profilemanager.ConfigInput - profState := profilemanager.ActiveProfileState{ - Name: msg.ProfileName, - Username: msg.Username, - } - profPath, err := profState.FilePath() + resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username) if err != nil { - log.Errorf("failed to get active profile file path: %v", err) - return config, fmt.Errorf("failed to get active profile file path: %w", err) + log.Errorf("failed to resolve profile %q: %v", msg.ProfileName, err) + return config, err + } + profPath := resolved.Path + if profPath == "" { + profPath = profilemanager.DefaultConfigPath } config.ConfigPath = profPath @@ -535,30 +535,9 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro } if msg.ProfileName != nil { - if *msg.ProfileName != "default" && (msg.Username == nil || *msg.Username == "") { - log.Errorf("profile name is set to %s, but username is not provided", *msg.ProfileName) - return nil, fmt.Errorf("profile name is set to %s, but username is not provided", *msg.ProfileName) - } - - var username string - if *msg.ProfileName != "default" { - username = *msg.Username - } - - if *msg.ProfileName != activeProf.Name && username != activeProf.Username { - if s.checkProfilesDisabled() { - log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled") - return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) - } - - log.Infof("switching to profile %s for user '%s'", *msg.ProfileName, username) - if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: *msg.ProfileName, - Username: username, - }); err != nil { - log.Errorf("failed to set active profile state: %v", err) - return nil, fmt.Errorf("failed to set active profile state: %w", err) - } + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + log.Errorf("failed to switch profile: %v", err) + return nil, err } } @@ -568,7 +547,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro return nil, fmt.Errorf("failed to get active profile state: %w", err) } - log.Infof("active profile: %s for %s", activeProf.Name, activeProf.Username) + log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username) s.mutex.Lock() @@ -806,10 +785,10 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR } if msg != nil && msg.ProfileName != nil { - if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { s.mutex.Unlock() log.Errorf("failed to switch profile: %v", err) - return nil, fmt.Errorf("failed to switch profile: %w", err) + return nil, err } } @@ -820,7 +799,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR return nil, fmt.Errorf("failed to get active profile state: %w", err) } - log.Infof("active profile: %s for %s", activeProf.Name, activeProf.Username) + log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username) config, _, err := s.getConfig(activeProf) if err != nil { @@ -864,34 +843,60 @@ func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error) } } -func (s *Server) switchProfileIfNeeded(profileName string, userName *string, activeProf *profilemanager.ActiveProfileState) error { - if profileName != "default" && (userName == nil || *userName == "") { - log.Errorf("profile name is set to %s, but username is not provided", profileName) - return fmt.Errorf("profile name is set to %s, but username is not provided", profileName) +// resolveProfileHandle resolves a wire-level profile handle (display +// name, ID, or unique ID prefix) to a concrete profile. Returns gRPC +// status errors so handlers can return them directly. +func (s *Server) resolveProfileHandle(handle, username string) (*profilemanager.Profile, error) { + p, err := s.profileManager.ResolveProfile(handle, username) + if err == nil { + return p, nil + } + var amb *profilemanager.ErrAmbiguousHandle + if errors.As(err, &amb) { + return nil, gstatus.Errorf(codes.InvalidArgument, "%v", amb) + } + if errors.Is(err, profilemanager.ErrProfileNotFound) { + return nil, gstatus.Errorf(codes.NotFound, "profile %q not found", handle) + } + return nil, fmt.Errorf("resolve profile: %w", err) +} + +// switchProfileIfNeeded resolves the user-supplied handle, updates the +// active profile state if it differs from the current one, and returns +// the resolved profile so callers can include its ID in RPC responses. +func (s *Server) switchProfileIfNeeded(handle string, userName *string, activeProf *profilemanager.ActiveProfileState) (*profilemanager.Profile, error) { + if handle != profilemanager.DefaultProfileName && (userName == nil || *userName == "") { + log.Errorf("profile name is set to %s, but username is not provided", handle) + return nil, fmt.Errorf("profile name is set to %s, but username is not provided", handle) } var username string - if profileName != "default" { + if handle != profilemanager.DefaultProfileName { username = *userName } - if profileName != activeProf.Name || username != activeProf.Username { + resolved, err := s.resolveProfileHandle(handle, username) + if err != nil { + return nil, err + } + + if resolved.ID != activeProf.ID || username != activeProf.Username { if s.checkProfilesDisabled() { log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled") - return gstatus.Errorf(codes.Unavailable, errProfilesDisabled) + return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) } - log.Infof("switching to profile %s for user %s", profileName, username) + log.Infof("switching to profile %s (%s) for user %s", resolved.Name, resolved.ID, username) if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profileName, + ID: resolved.ID, Username: username, }); err != nil { log.Errorf("failed to set active profile state: %v", err) - return fmt.Errorf("failed to set active profile state: %w", err) + return nil, fmt.Errorf("failed to set active profile state: %w", err) } } - return nil + return resolved, nil } // SwitchProfile switches the active profile in the daemon. @@ -906,9 +911,9 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi } if msg != nil && msg.ProfileName != nil { - if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { log.Errorf("failed to switch profile: %v", err) - return nil, fmt.Errorf("failed to switch profile: %w", err) + return nil, err } } activeProf, err = s.profileManager.GetActiveProfileState() @@ -924,7 +929,7 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi s.config = config - return &proto.SwitchProfileResponse{}, nil + return &proto.SwitchProfileResponse{Id: activeProf.ID.String()}, nil } // Down engine work in the daemon. @@ -988,6 +993,10 @@ func (s *Server) cleanupConnection() error { return nil } + // TODO: consider calling s.connectClient.Stop() instead of engine.Stop(). + // actCancel() lets the run loop stop the engine too, so both stop it + // concurrently; ConnectClient.Stop cancels and waits for the run loop, + // making the run loop the sole owner of engine shutdown. if engine != nil { if err := engine.Stop(); err != nil { return err @@ -1014,22 +1023,27 @@ func (s *Server) Logout(ctx context.Context, msg *proto.LogoutRequest) (*proto.L } func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutRequest) (*proto.LogoutResponse, error) { - if err := s.validateProfileOperation(*msg.ProfileName, true); err != nil { - return nil, err - } - if msg.Username == nil || *msg.Username == "" { return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided when profile name is specified") } username := *msg.Username - if err := s.logoutFromProfile(ctx, *msg.ProfileName, username); err != nil { - log.Errorf("failed to logout from profile %s: %v", *msg.ProfileName, err) + resolved, err := s.resolveProfileHandle(*msg.ProfileName, username) + if err != nil { + return nil, err + } + + if err := s.validateProfileOperation(resolved.ID, true); err != nil { + return nil, err + } + + if err := s.logoutFromProfile(ctx, resolved); err != nil { + log.Errorf("failed to logout from profile %s: %v", resolved.ID, err) return nil, gstatus.Errorf(codes.Internal, "logout: %v", err) } activeProf, _ := s.profileManager.GetActiveProfileState() - if activeProf != nil && activeProf.Name == *msg.ProfileName { + if activeProf != nil && activeProf.ID == resolved.ID { if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) { log.Errorf("failed to cleanup connection: %v", err) } @@ -1091,30 +1105,30 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof return config, configExisted, nil } -func (s *Server) canRemoveProfile(profileName string) error { - if profileName == profilemanager.DefaultProfileName { +func (s *Server) canRemoveProfile(id profilemanager.ID) error { + if id == profilemanager.DefaultProfileName { return fmt.Errorf("remove profile with reserved name: %s", profilemanager.DefaultProfileName) } activeProf, err := s.profileManager.GetActiveProfileState() - if err == nil && activeProf.Name == profileName { - return fmt.Errorf("remove active profile: %s", profileName) + if err == nil && activeProf.ID == id { + return fmt.Errorf("remove active profile: %s", id) } return nil } -func (s *Server) validateProfileOperation(profileName string, allowActiveProfile bool) error { +func (s *Server) validateProfileOperation(id profilemanager.ID, allowActiveProfile bool) error { if s.checkProfilesDisabled() { return gstatus.Errorf(codes.Unavailable, errProfilesDisabled) } - if profileName == "" { + if id == "" { return gstatus.Errorf(codes.InvalidArgument, "profile name must be provided") } if !allowActiveProfile { - if err := s.canRemoveProfile(profileName); err != nil { + if err := s.canRemoveProfile(id); err != nil { return gstatus.Errorf(codes.InvalidArgument, "%v", err) } } @@ -1122,25 +1136,20 @@ func (s *Server) validateProfileOperation(profileName string, allowActiveProfile return nil } -// logoutFromProfile logs out from a specific profile by loading its config and sending logout request -func (s *Server) logoutFromProfile(ctx context.Context, profileName, username string) error { +func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile) error { activeProf, err := s.profileManager.GetActiveProfileState() - if err == nil && activeProf.Name == profileName && s.connectClient != nil { + if err == nil && activeProf.ID == profile.ID && s.connectClient != nil { return s.sendLogoutRequest(ctx) } - profileState := &profilemanager.ActiveProfileState{ - Name: profileName, - Username: username, - } - profilePath, err := profileState.FilePath() - if err != nil { - return fmt.Errorf("get profile path: %w", err) + cfgPath := profile.Path + if cfgPath == "" { + cfgPath = profilemanager.DefaultConfigPath } - config, err := profilemanager.GetConfig(profilePath) + config, err := profilemanager.GetConfig(cfgPath) if err != nil { - return fmt.Errorf("profile '%s' not found", profileName) + return fmt.Errorf("profile '%s' not found", profile.ID) } return s.sendLogoutRequestWithConfig(ctx, config) @@ -1558,15 +1567,14 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p return nil, ctx.Err() } - prof := profilemanager.ActiveProfileState{ - Name: req.ProfileName, - Username: req.Username, - } - - cfgPath, err := prof.FilePath() + resolved, err := s.resolveProfileHandle(req.ProfileName, req.Username) if err != nil { - log.Errorf("failed to get active profile file path: %v", err) - return nil, fmt.Errorf("failed to get active profile file path: %w", err) + log.Errorf("failed to resolve profile %q: %v", req.ProfileName, err) + return nil, err + } + cfgPath := resolved.Path + if cfgPath == "" { + cfgPath = profilemanager.DefaultConfigPath } cfg, err := profilemanager.GetConfig(cfgPath) @@ -1671,12 +1679,39 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) ( return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and username must be provided") } - if err := s.profileManager.AddProfile(msg.ProfileName, msg.Username); err != nil { + created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username) + if err != nil { log.Errorf("failed to create profile: %v", err) return nil, fmt.Errorf("failed to create profile: %w", err) } - return &proto.AddProfileResponse{}, nil + return &proto.AddProfileResponse{Id: created.ID.String()}, nil +} + +func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequest) (*proto.RenameProfileResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.checkProfilesDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) + } + + if msg.Handle == "" || msg.Username == "" || msg.NewProfileName == "" { + return nil, gstatus.Errorf(codes.InvalidArgument, "profile name, username and new profile name must be provided") + } + + resolved, err := s.resolveProfileHandle(msg.Handle, msg.Username) + if err != nil { + return nil, err + } + + err = s.profileManager.RenameProfile(resolved.ID, msg.Username, msg.NewProfileName) + if err != nil { + log.Errorf("failed to rename profile: %v", err) + return nil, fmt.Errorf("failed to rename profile: %w", err) + } + + return &proto.RenameProfileResponse{OldProfileName: resolved.Name}, nil } // RemoveProfile removes a profile from the daemon. @@ -1684,20 +1719,29 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ s.mutex.Lock() defer s.mutex.Unlock() - if err := s.validateProfileOperation(msg.ProfileName, false); err != nil { + if s.checkProfilesDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) + } + + if msg.ProfileName == "" { + return nil, gstatus.Errorf(codes.InvalidArgument, "profile name must be provided") + } + + resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username) + if err != nil { return nil, err } - if err := s.logoutFromProfile(ctx, msg.ProfileName, msg.Username); err != nil { - log.Warnf("failed to logout from profile %s before removal: %v", msg.ProfileName, err) + if err := s.logoutFromProfile(ctx, resolved); err != nil { + log.Warnf("failed to logout from profile %s before removal: %v", resolved.ID, err) } - if err := s.profileManager.RemoveProfile(msg.ProfileName, msg.Username); err != nil { + if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil { log.Errorf("failed to remove profile: %v", err) return nil, fmt.Errorf("failed to remove profile: %w", err) } - return &proto.RemoveProfileResponse{}, nil + return &proto.RemoveProfileResponse{Id: resolved.ID.String()}, nil } // ListProfiles lists all profiles in the daemon. @@ -1720,6 +1764,7 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques } for i, profile := range profiles { response.Profiles[i] = &proto.Profile{ + Id: profile.ID.String(), Name: profile.Name, IsActive: profile.IsActive, } @@ -1728,7 +1773,9 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques return response, nil } -// GetActiveProfile returns the active profile in the daemon. +// GetActiveProfile returns the active profile in the daemon. The ProfileName +// field carries the display name for backwards compatibility with UI clients, +// new callers should prefer Id. func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfileRequest) (*proto.GetActiveProfileResponse, error) { s.mutex.Lock() defer s.mutex.Unlock() @@ -1739,9 +1786,23 @@ func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfi return nil, fmt.Errorf("failed to get active profile state: %w", err) } + // Fallback to legacy name == ID + displayName := activeProfile.ID.String() + if activeProfile.ID != profilemanager.DefaultProfileName { + if profiles, lerr := s.profileManager.ListProfiles(activeProfile.Username); lerr == nil { + for _, p := range profiles { + if p.ID == activeProfile.ID { + displayName = p.Name + break + } + } + } + } + return &proto.GetActiveProfileResponse{ - ProfileName: activeProfile.Name, + ProfileName: displayName, Username: activeProfile.Username, + Id: activeProfile.ID.String(), }, nil } diff --git a/client/server/server_test.go b/client/server/server_test.go index 66e0fcc4c..fa9599818 100644 --- a/client/server/server_test.go +++ b/client/server/server_test.go @@ -97,7 +97,7 @@ func TestConnectWithRetryRuns(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "test-profile", + ID: "test-profile", Username: currUser.Username, }) if err != nil { @@ -158,7 +158,7 @@ func TestServer_Up(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, }) if err != nil { @@ -228,7 +228,7 @@ func TestServer_SubcribeEvents(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "default", + ID: "default", Username: currUser.Username, }) if err != nil { diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index a2c07cb02..9818f9fdf 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -62,7 +62,7 @@ func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profN pm := profilemanager.ServiceManager{} require.NoError(t, pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, })) diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index 553d4ad71..7c85d16ce 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -47,7 +47,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { pm := profilemanager.ServiceManager{} err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, }) require.NoError(t, err) @@ -96,7 +96,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { DisableNotifications: &disableNotifications, LazyConnectionEnabled: &lazyConnectionEnabled, BlockInbound: &blockInbound, - DisableIpv6: &disableIPv6, + DisableIpv6: &disableIPv6, NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"}, CleanNATExternalIPs: false, CustomDNSAddress: []byte("1.1.1.1:53"), @@ -112,7 +112,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.NoError(t, err) profState := profilemanager.ActiveProfileState{ - Name: profName, + ID: profilemanager.ID(profName), Username: currUser.Username, } cfgPath, err := profState.FilePath() diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index 0d3ac416b..b8ab0b2e4 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -418,7 +418,14 @@ func newServiceClient(args *newServiceClientArgs) *serviceClient { case args.showProfiles: s.showProfilesUI() case args.showQuickActions: - s.showQuickActionsUI() + // Suppress the on-boot Quick Actions popup when the daemon + // reports DisableAutoConnect=true — that flag carries both the + // user's "Connect on Startup = off" preference AND any MDM- + // enforced override (applyMDMPolicy writes the policy value + // into the same Config field). See netbirdio/netbird#5744. + if !s.disableAutoConnectFromDaemon() { + s.showQuickActionsUI() + } case args.showUpdate: s.showUpdateProgress(ctx, args.showUpdateVersion) } @@ -645,7 +652,7 @@ func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) ( } req := &proto.SetConfigRequest{ - ProfileName: activeProf.Name, + ProfileName: activeProf.ID.String(), Username: currUser.Username, } @@ -818,13 +825,15 @@ func (s *serviceClient) login(ctx context.Context, openURL bool) (*proto.LoginRe return nil, fmt.Errorf("get current user: %w", err) } + handle := activeProf.ID.String() + loginReq := &proto.LoginRequest{ IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd", - ProfileName: &activeProf.Name, + ProfileName: &handle, Username: &currUser.Username, } - profileState, err := s.profileManager.GetProfileState(activeProf.Name) + profileState, err := s.profileManager.GetProfileState(activeProf.ID) if err != nil { log.Debugf("failed to get profile state for login hint: %v", err) } else if profileState.Email != "" { @@ -1336,6 +1345,40 @@ func (s *serviceClient) getFeatures() (*proto.GetFeaturesResponse, error) { return features, nil } +// disableAutoConnectFromDaemon returns true when the daemon reports +// the active profile has DisableAutoConnect=true. Used by the +// --quick-actions startup path to suppress the on-boot popup when the +// user (or an MDM admin) opted out of auto-connecting; both cases +// converge on the same Config field because applyMDMPolicy writes the +// policy value into it. Returns false on any RPC / lookup failure so a +// daemon hiccup does not silently swallow the popup. +func (s *serviceClient) disableAutoConnectFromDaemon() bool { + activeProf, err := s.profileManager.GetActiveProfile() + if err != nil { + log.Warnf("disableAutoConnectFromDaemon: get active profile: %v", err) + return false + } + currUser, err := user.Current() + if err != nil { + log.Warnf("disableAutoConnectFromDaemon: get current user: %v", err) + return false + } + conn, err := s.getSrvClient(failFastTimeout) + if err != nil { + log.Warnf("disableAutoConnectFromDaemon: get daemon client: %v", err) + return false + } + srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ + ProfileName: activeProf.ID.String(), + Username: currUser.Username, + }) + if err != nil { + log.Warnf("disableAutoConnectFromDaemon: GetConfig RPC: %v", err) + return false + } + return srvCfg.GetDisableAutoConnect() +} + // getSrvConfig from the service to show it in the settings window. func (s *serviceClient) getSrvConfig() { s.managementURL = profilemanager.DefaultManagementURL @@ -1367,7 +1410,7 @@ func (s *serviceClient) getSrvConfig() { } srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.Name, + ProfileName: activeProf.ID.String(), Username: currUser.Username, }) if err != nil { @@ -1613,7 +1656,7 @@ func (s *serviceClient) loadSettings() { } cfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.Name, + ProfileName: activeProf.ID.String(), Username: currUser.Username, }) if err != nil { @@ -1813,7 +1856,7 @@ func (s *serviceClient) updateConfig() error { } req := proto.SetConfigRequest{ - ProfileName: activeProf.Name, + ProfileName: activeProf.ID.String(), Username: currUser.Username, DisableAutoConnect: &disableAutoStart, ServerSSHAllowed: &sshAllowed, diff --git a/client/ui/profile.go b/client/ui/profile.go index d3db17855..83b0ec18b 100644 --- a/client/ui/profile.go +++ b/client/ui/profile.go @@ -66,7 +66,7 @@ func (s *serviceClient) showProfilesUI() { } else { indicator.SetText("") } - nameLabel.SetText(profile.Name) + nameLabel.SetText(formatProfileLabel(profile, profiles)) // Configure Select/Active button selectBtn.SetText(func() string { @@ -88,7 +88,7 @@ func (s *serviceClient) showProfilesUI() { return } // switch - err = s.switchProfile(profile.Name) + err = s.switchProfile(profile.ID) if err != nil { log.Errorf("failed to switch profile: %v", err) dialog.ShowError(errors.New("failed to select profile"), s.wProfiles) @@ -130,7 +130,7 @@ func (s *serviceClient) showProfilesUI() { logoutBtn.Show() logoutBtn.SetText("Deregister") logoutBtn.OnTapped = func() { - s.handleProfileLogout(profile.Name, refresh) + s.handleProfileLogout(profile, refresh) } // Remove profile @@ -144,7 +144,7 @@ func (s *serviceClient) showProfilesUI() { return } - err = s.removeProfile(profile.Name) + err = s.removeProfile(profile.ID) if err != nil { log.Errorf("failed to remove profile: %v", err) dialog.ShowError(fmt.Errorf("failed to remove profile"), s.wProfiles) @@ -250,7 +250,7 @@ func (s *serviceClient) addProfile(profileName string) error { return nil } -func (s *serviceClient) switchProfile(profileName string) error { +func (s *serviceClient) switchProfile(handle string) error { conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { return fmt.Errorf(getClientFMT, err) @@ -261,15 +261,15 @@ func (s *serviceClient) switchProfile(profileName string) error { return fmt.Errorf("get current user: %w", err) } - if _, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{ - ProfileName: &profileName, + resp, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{ + ProfileName: &handle, Username: &currUser.Username, - }); err != nil { + }) + if err != nil { return fmt.Errorf("switch profile failed: %w", err) } - err = s.profileManager.SwitchProfile(profileName) - if err != nil { + if err := s.profileManager.SwitchProfile(profilemanager.ID(resp.Id)); err != nil { return fmt.Errorf("switch profile: %w", err) } @@ -299,10 +299,27 @@ func (s *serviceClient) removeProfile(profileName string) error { } type Profile struct { + ID string Name string IsActive bool } +// formatProfileLabel returns the display label for a profile. Profiles can +// share the same Name, so when more than one profile in profiles carries this +// Name, a short form of the ID is appended to disambiguate the entries. +func formatProfileLabel(profile Profile, profiles []Profile) string { + count := 0 + for _, p := range profiles { + if p.Name == profile.Name { + count++ + } + } + if count <= 1 { + return profile.Name + } + return fmt.Sprintf("%s (%s)", profile.Name, profilemanager.ID(profile.ID).ShortID()) +} + func (s *serviceClient) getProfiles() ([]Profile, error) { conn, err := s.getSrvClient(defaultFailTimeout) if err != nil { @@ -324,6 +341,7 @@ func (s *serviceClient) getProfiles() ([]Profile, error) { for _, profile := range profilesResp.Profiles { profiles = append(profiles, Profile{ + ID: profile.Id, Name: profile.Name, IsActive: profile.IsActive, }) @@ -332,10 +350,10 @@ func (s *serviceClient) getProfiles() ([]Profile, error) { return profiles, nil } -func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback func()) { +func (s *serviceClient) handleProfileLogout(profile Profile, refreshCallback func()) { dialog.ShowConfirm( "Deregister", - fmt.Sprintf("Are you sure you want to deregister from '%s'?", profileName), + fmt.Sprintf("Are you sure you want to deregister from '%s'?", profile.Name), func(confirm bool) { if !confirm { return @@ -356,8 +374,10 @@ func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback } username := currUser.Username + // ProfileName is treated as a handle; send the ID so the + // daemon resolves to exactly this profile. _, err = conn.Logout(s.ctx, &proto.LogoutRequest{ - ProfileName: &profileName, + ProfileName: &profile.ID, Username: &username, }) if err != nil { @@ -368,7 +388,7 @@ func (s *serviceClient) handleProfileLogout(profileName string, refreshCallback dialog.ShowInformation( "Deregistered", - fmt.Sprintf("Successfully deregistered from '%s'", profileName), + fmt.Sprintf("Successfully deregistered from '%s'", profile.Name), s.wProfiles, ) @@ -461,6 +481,7 @@ func (p *profileMenu) getProfiles() ([]Profile, error) { for _, profile := range profilesResp.Profiles { profiles = append(profiles, Profile{ + ID: profile.Id, Name: profile.Name, IsActive: profile.IsActive, }) @@ -501,7 +522,7 @@ func (p *profileMenu) refresh() { } if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username { - activeProfState, err := p.profileManager.GetProfileState(activeProf.ProfileName) + activeProfState, err := p.profileManager.GetProfileState(profilemanager.ID(activeProf.Id)) if err != nil { log.Warnf("failed to get active profile state: %v", err) p.emailMenuItem.Hide() @@ -512,7 +533,7 @@ func (p *profileMenu) refresh() { } for _, profile := range profiles { - item := p.profileMenuItem.AddSubMenuItem(profile.Name, "") + item := p.profileMenuItem.AddSubMenuItem(formatProfileLabel(profile, profiles), "") if profile.IsActive { item.Check() } @@ -541,8 +562,8 @@ func (p *profileMenu) refresh() { return } - _, err = conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{ - ProfileName: &profile.Name, + switchResp, err := conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{ + ProfileName: &profile.ID, Username: &currUser.Username, }) if err != nil { @@ -552,7 +573,7 @@ func (p *profileMenu) refresh() { return } - err = p.profileManager.SwitchProfile(profile.Name) + err = p.profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)) if err != nil { log.Errorf("failed to switch profile '%s': %v", profile.Name, err) return @@ -727,7 +748,10 @@ func (p *profileMenu) updateMenu() { } sort.Slice(profiles, func(i, j int) bool { - return profiles[i].Name < profiles[j].Name + if profiles[i].Name != profiles[j].Name { + return profiles[i].Name < profiles[j].Name + } + return profiles[i].ID < profiles[j].ID }) p.mu.Lock() diff --git a/combined/Dockerfile b/combined/Dockerfile index 357e10cf8..ac88b8509 100644 --- a/combined/Dockerfile +++ b/combined/Dockerfile @@ -2,4 +2,5 @@ FROM ubuntu:24.04 RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt ENTRYPOINT [ "/go/bin/netbird-server" ] CMD ["--config", "/etc/netbird/config.yaml"] -COPY netbird-server /go/bin/netbird-server \ No newline at end of file +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-server /go/bin/netbird-server diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh new file mode 100755 index 000000000..5d2341cbe --- /dev/null +++ b/infrastructure_files/getting-started-enterprise.sh @@ -0,0 +1,616 @@ +#!/bin/bash + +set -e +set -o pipefail + +# NetBird Enterprise — Getting Started +# Single-node bootstrap for a self-hosted NetBird Enterprise stack with the +# embedded identity provider. Owner is created via first-login flow. + +SED_STRIP_PADDING='s/=//g' + +check_docker_compose() { + if command -v docker-compose &> /dev/null; then + echo "docker-compose" + return + fi + if docker compose --help &> /dev/null; then + echo "docker compose" + return + fi + echo "docker-compose is not installed or not in PATH. See https://docs.docker.com/engine/install/" > /dev/stderr + exit 1 +} + +check_openssl() { + if ! command -v openssl &> /dev/null; then + echo "openssl is not installed or not in PATH." > /dev/stderr + exit 1 + fi +} + +rand_secret() { + openssl rand -base64 32 | sed "$SED_STRIP_PADDING" +} + +rand_b64_key() { + openssl rand -base64 32 +} + +check_nb_domain() { + local domain="$1" + if [[ -z "$domain" ]]; then + echo "The domain cannot be empty." > /dev/stderr + return 1 + fi + if [[ "$domain" == "netbird.example.com" ]]; then + echo "The domain cannot be netbird.example.com" > /dev/stderr + return 1 + fi + if [[ "$domain" =~ ^[0-9.]+$ ]]; then + echo "An IP address is not allowed. A real DNS-resolvable domain is required for TLS and the embedded IdP issuer." > /dev/stderr + return 1 + fi + if [[ ! "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$ ]]; then + echo "The value '$domain' is not a valid FQDN. A real DNS-resolvable domain is required for TLS and the embedded IdP issuer." > /dev/stderr + return 1 + fi + return 0 +} + +check_domain_resolves() { + local domain="$1" + if command -v getent &> /dev/null && getent hosts "$domain" &> /dev/null; then return 0; fi + if command -v host &> /dev/null && host "$domain" &> /dev/null; then return 0; fi + if command -v dig &> /dev/null && [[ -n "$(dig +short "$domain" 2>/dev/null)" ]]; then return 0; fi + if command -v nslookup &> /dev/null && nslookup "$domain" &> /dev/null; then return 0; fi + return 1 +} + +read_nb_domain() { + local value="" + echo -n "Enter the FQDN for NetBird (must resolve via DNS, e.g. netbird.my-domain.com): " > /dev/stderr + read -r value < /dev/tty + if ! check_nb_domain "$value"; then + read_nb_domain + return + fi + if ! check_domain_resolves "$value"; then + echo "" > /dev/stderr + echo "Warning: '$value' does not resolve via DNS from this host." > /dev/stderr + echo "Caddy will not be able to issue TLS certificates until it does." > /dev/stderr + local confirm="" + echo -n "Continue anyway? [y/N]: " > /dev/stderr + read -r confirm < /dev/tty + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + read_nb_domain + return + fi + fi + echo "$value" +} + +read_required() { + local prompt="$1" + local value="" + while [[ -z "$value" ]]; do + echo -n "$prompt: " > /dev/stderr + read -r value < /dev/tty + if [[ -z "$value" ]]; then + echo "Value cannot be empty." > /dev/stderr + fi + done + echo "$value" +} + +read_secret() { + local prompt="$1" + local value="" + while [[ -z "$value" ]]; do + echo -n "$prompt: " > /dev/stderr + read -rs value < /dev/tty + echo "" > /dev/stderr + if [[ -z "$value" ]]; then + echo "Value cannot be empty." > /dev/stderr + fi + done + echo "$value" +} + +# read_yes_no "" [] +read_yes_no() { + local prompt="$1" + local default="${2:-n}" + local hint + if [[ "$default" == "y" ]]; then + hint="[Y/n]" + else + hint="[y/N]" + fi + echo -n "${prompt} ${hint}: " > /dev/stderr + local ans="" + read -r ans < /dev/tty + if [[ -z "$ans" ]]; then + ans="$default" + fi + case "$ans" in + [Yy] | [Yy][Ee][Ss]) echo "yes" ;; + *) echo "no" ;; + esac +} + +wait_postgres() { + set +e + echo -n "Waiting for postgres to become ready" + local counter=1 + while true; do + if $DOCKER_COMPOSE_COMMAND exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" &> /dev/null; then + break + fi + if [[ $counter -eq 60 ]]; then + echo "" + echo "Postgres is taking too long. Recent logs:" + $DOCKER_COMPOSE_COMMAND logs --tail=20 postgres + exit 1 + fi + echo -n " ." + sleep 2 + counter=$((counter + 1)) + done + echo " done" + set -e +} + +init_environment() { + check_openssl + DOCKER_COMPOSE_COMMAND=$(check_docker_compose) + + if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]] || [[ -f Caddyfile ]]; then + echo "Generated files already exist in $(pwd)." + echo "If you want to reinitialize the environment, please remove them first:" + echo " $DOCKER_COMPOSE_COMMAND down --volumes # removes all containers and volumes" + echo " rm -f .env docker-compose.yml Caddyfile config.yaml" + echo "Be aware this will remove all data from the database." + exit 1 + fi + + echo "NetBird Enterprise bootstrap" + echo "" + echo "Traffic flow:" + echo " Enables traffic events logging on the management server." + echo " When enabled, the NetBird stack also runs NATS along with two" + echo " additional containers: netbird-receiver (the traffic log receiver" + echo " service) and netbird-enricher (the traffic log enricher service)." + echo " It still has to be turned on from the dashboard settings afterwards." + echo " See https://docs.netbird.io/manage/activity/traffic-events-logging" + NETBIRD_TRAFFIC_FLOW=$(read_yes_no "Enable traffic flow" "n") + + echo "" + NETBIRD_DOMAIN=$(read_nb_domain) + + echo "" + + NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)") + + GHCR_USERNAME="netbirdExtAccess1" + GHCR_TOKEN=$(read_secret "Enter GHCR token (input hidden)") + + POSTGRES_USER="netbird" + POSTGRES_DB="netbird" + POSTGRES_PASSWORD=$(rand_secret) + NETBIRD_ENCRYPTION_KEY=$(rand_b64_key) + NETBIRD_RELAY_AUTH_SECRET=$(rand_secret) + + POSTGRES_DSN="host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB} port=5432 sslmode=disable TimeZone=UTC" + NETBIRD_RELAY_ENDPOINT="rels://${NETBIRD_DOMAIN}:443" + + echo "" + echo "Selected:" + echo " Traffic flow: ${NETBIRD_TRAFFIC_FLOW}" + echo " Domain: ${NETBIRD_DOMAIN}" + echo "" + echo "Rendering files into $(pwd) ..." + install -m 600 /dev/null .env + render_env >> .env + render_docker_compose > docker-compose.yml + + if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then + sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' docker-compose.yml && rm -f docker-compose.yml.bak + fi + render_caddyfile > Caddyfile + install -m 600 /dev/null config.yaml + render_config_yaml >> config.yaml + + echo "Logging in to ghcr.io ..." + printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin + unset GHCR_TOKEN + + echo "" + echo "Pulling images ..." + $DOCKER_COMPOSE_COMMAND pull + + echo "" + echo "Starting postgres ..." + $DOCKER_COMPOSE_COMMAND up -d postgres + sleep 2 + wait_postgres + + echo "" + echo "Starting remaining services ..." + $DOCKER_COMPOSE_COMMAND up -d + + echo "" + echo "Done." + echo "" + echo "Dashboard: https://${NETBIRD_DOMAIN}" + echo "" + echo "Open the dashboard in a browser to complete the first-login owner setup." + echo "All configuration and secrets are stored (mode 600) in $(pwd)/.env" + echo "" + echo "Tail logs:" + echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server caddy" +} + +# ------------------------------------------------------------------ +# Renderers +# ------------------------------------------------------------------ + +render_env() { + cat < /dev/null; then + echo "docker-compose" + return + fi + if docker compose --help &> /dev/null; then + echo "docker compose" + return + fi + echo "docker-compose is not installed or not in PATH." > /dev/stderr + exit 1 +} + +check_yq() { + if ! command -v yq &> /dev/null; then + cat > /dev/stderr <<'EOF' +yq is required to parse and update YAML safely. + + macOS: brew install yq + Linux: https://github.com/mikefarah/yq/releases (download binary into PATH) + Debian: apt-get install yq (Note: must be the mikefarah Go yq, not the Python wrapper.) + +EOF + exit 1 + fi + if ! yq --version 2>&1 | grep -q "mikefarah"; then + echo "yq is present but appears to be the wrong implementation. The mikefarah Go-based yq is required (https://github.com/mikefarah/yq)." > /dev/stderr + exit 1 + fi +} + +check_openssl() { + if ! command -v openssl &> /dev/null; then + echo "openssl is not installed or not in PATH." > /dev/stderr + exit 1 + fi +} + +rand_password() { + openssl rand -hex 32 +} + +read_required() { + local prompt="$1" + local value="" + while [[ -z "$value" ]]; do + echo -n "$prompt: " > /dev/stderr + read -r value < /dev/tty + if [[ -z "$value" ]]; then + echo "Value cannot be empty." > /dev/stderr + fi + done + echo "$value" +} + +read_secret() { + local prompt="$1" + local value="" + while [[ -z "$value" ]]; do + echo -n "$prompt: " > /dev/stderr + read -rs value < /dev/tty + echo "" > /dev/stderr + if [[ -z "$value" ]]; then + echo "Value cannot be empty." > /dev/stderr + fi + done + echo "$value" +} + +read_yes_no() { + local prompt="$1" + local default="${2:-n}" + local hint + if [[ "$default" == "y" ]]; then + hint="[Y/n]" + else + hint="[y/N]" + fi + echo -n "${prompt} ${hint}: " > /dev/stderr + local ans="" + read -r ans < /dev/tty + if [[ -z "$ans" ]]; then + ans="$default" + fi + case "$ans" in + [Yy] | [Yy][Ee][Ss]) echo "yes" ;; + *) echo "no" ;; + esac +} + +# --------------------------------------------------------------------------- +# Detection — read the operator's existing compose to find service names and +# paths we need to override. Bail loudly if shape isn't recognised. +# --------------------------------------------------------------------------- + +detect_combined_service() { + yq eval '.services | to_entries | map(select(.value.image | test("^netbirdio/netbird-server"))) | .[0].key // ""' "$COMPOSE_FILE" +} + +detect_dashboard_service() { + yq eval '.services | to_entries | map(select(.value.image | test("^netbirdio/dashboard"))) | .[0].key // ""' "$COMPOSE_FILE" +} + +detect_config_yaml_host_path() { + yq eval ".services[\"$COMBINED_SERVICE\"].volumes[] | select(. | test(\":/etc/netbird/config.yaml\")) | sub(\":/etc/netbird/config.yaml.*\"; \"\") // \"\"" "$COMPOSE_FILE" | head -1 +} + +detect_data_volume() { + yq eval ".services[\"$COMBINED_SERVICE\"].volumes[] | select(. | test(\":/var/lib/netbird\")) | sub(\":/var/lib/netbird.*\"; \"\") // \"\"" "$COMPOSE_FILE" | head -1 +} + +detect_exposed_address() { + yq eval '.server.exposedAddress // ""' "$CONFIG_YAML_HOST" +} + +detect_compose_network() { + local tag + tag=$(yq eval ".services[\"$COMBINED_SERVICE\"].networks | tag" "$COMPOSE_FILE" 2>/dev/null) + case "$tag" in + "!!seq") + yq eval ".services[\"$COMBINED_SERVICE\"].networks[0]" "$COMPOSE_FILE" + ;; + "!!map") + yq eval ".services[\"$COMBINED_SERVICE\"].networks | keys | .[0]" "$COMPOSE_FILE" + ;; + *) + echo "default" + ;; + esac +} + +# --------------------------------------------------------------------------- +# Renderers +# --------------------------------------------------------------------------- + +# Build docker-compose.override.yml from the steps the operator selected. +# Service names match what we detected on the operator's side. +render_override() { + cat < "$ENTERPRISE_CONFIG_FILE" + + if [[ "$ENABLE_FLOW" == "yes" ]]; then + local flow_addr="${NETBIRD_DOMAIN}" + yq eval -i " + .server.trafficFlow.enabled = true | + .server.trafficFlow.address = \"$flow_addr\" | + .server.trafficFlow.interval = \"60s\" + " "$ENTERPRISE_CONFIG_FILE" + fi +} + +# --------------------------------------------------------------------------- +# Execution steps +# --------------------------------------------------------------------------- + +resolve_data_volume() { + local short="$1" + local actual + # Resolve project-prefixed volume name from Docker Compose config first. + actual=$($DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval ".volumes.\"$short\".name" - 2>/dev/null) + if [[ -n "$actual" && "$actual" != "null" ]]; then + echo "$actual" + return + fi + # Relative bind mount: docker-compose resolves it against the compose + # file's directory, but `docker run -v` resolves it against the current + # working directory. Normalize to an absolute path so both interpretations + # agree (and the printed revert command works from any CWD). + if [[ "$short" == ./* || "$short" == ../* ]]; then + local compose_dir + compose_dir="$(cd "$(dirname "$COMPOSE_FILE")" && pwd)" + ( + cd "$compose_dir" + cd "$(dirname "$short")" + printf '%s/%s\n' "$(pwd)" "$(basename "$short")" + ) + return + fi + # Not a named volume (e.g. an absolute bind-mount path) — use it as-is. + echo "$short" +} + +backup_sqlite() { + BACKUP_DIR="$(pwd)/backups/sqlite-pre-enterprise-$(date +%Y%m%d-%H%M%S)" + mkdir -p "$BACKUP_DIR" + local data_volume_actual + data_volume_actual=$(resolve_data_volume "$DATA_VOLUME") + echo "Backing up SQLite store from volume '$data_volume_actual' to $BACKUP_DIR ..." + docker run --rm \ + -v "${data_volume_actual}:/var/lib/netbird:ro" \ + -v "${BACKUP_DIR}:/backup" \ + busybox \ + sh -c 'cp -a /var/lib/netbird/. /backup/ 2>/dev/null || true' + local copied + copied=$(find "$BACKUP_DIR" -mindepth 1 | head -1) + if [[ -z "$copied" ]]; then + echo " ⚠ Backup directory is empty — the volume '$data_volume_actual' didn't contain data. Aborting." > /dev/stderr + exit 1 + fi + echo " done" +} + +run_migrate_store() { + echo "Running migrate-store (SQLite → Postgres) ..." + $DOCKER_COMPOSE_COMMAND run --rm "$COMBINED_SERVICE" migrate-store --config /etc/netbird/config.yaml.enterprise --verify + echo " done" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +init_migration() { + DOCKER_COMPOSE_COMMAND=$(check_docker_compose) + check_yq + check_openssl + + COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" + + if [[ ! -f "$COMPOSE_FILE" ]]; then + echo "$COMPOSE_FILE not found in $(pwd)." > /dev/stderr + exit 1 + fi + if [[ -f "$OVERRIDE_FILE" ]] || [[ -f "$ENTERPRISE_CONFIG_FILE" ]]; then + echo "Migration artifacts already exist in $(pwd):" + [[ -f "$OVERRIDE_FILE" ]] && echo " $OVERRIDE_FILE" + [[ -f "$ENTERPRISE_CONFIG_FILE" ]] && echo " $ENTERPRISE_CONFIG_FILE" + echo "" + echo "Either you've already migrated, or a previous run was interrupted." + echo "To re-run cleanly: rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE" + exit 1 + fi + + COMBINED_SERVICE=$(detect_combined_service) + DASHBOARD_SERVICE=$(detect_dashboard_service) + CONFIG_YAML_HOST=$(detect_config_yaml_host_path) + DATA_VOLUME=$(detect_data_volume) + COMPOSE_NETWORK=$(detect_compose_network) + + if [[ -z "$COMBINED_SERVICE" ]]; then + echo "Could not find a service running netbirdio/netbird-server* in $COMPOSE_FILE." > /dev/stderr + echo "This script targets the community combined-server deployment." > /dev/stderr + exit 1 + fi + if [[ -z "$DASHBOARD_SERVICE" ]]; then + echo "Could not find a service running netbirdio/dashboard* in $COMPOSE_FILE." > /dev/stderr + exit 1 + fi + if [[ -z "$CONFIG_YAML_HOST" ]]; then + echo "Could not find a config.yaml mount on $COMBINED_SERVICE (expected to bind-mount to /etc/netbird/config.yaml)." > /dev/stderr + exit 1 + fi + if [[ ! -f "$CONFIG_YAML_HOST" ]]; then + echo "config.yaml host file not found at $CONFIG_YAML_HOST." > /dev/stderr + exit 1 + fi + if [[ -z "$DATA_VOLUME" ]]; then + echo "Could not find a volume mounted at /var/lib/netbird on $COMBINED_SERVICE." > /dev/stderr + exit 1 + fi + + echo "Detected existing deployment:" + echo " Combined service: $COMBINED_SERVICE" + echo " Dashboard: $DASHBOARD_SERVICE" + echo " config.yaml: $CONFIG_YAML_HOST" + echo " Data volume: $DATA_VOLUME" + echo " Network: $COMPOSE_NETWORK" + echo "" + + local proceed + proceed=$(read_yes_no "Proceed with migration?" "y") + if [[ "$proceed" != "yes" ]]; then + echo "Aborted." + exit 0 + fi + + # Step 1 — always (this is the point of the script) + MIGRATE_IMAGES="yes" + echo "" + echo "Step 1: Image swap (community → Enterprise). License key required." + NB_LICENSE_KEY=$(read_secret " License key") + GHCR_USERNAME="netbirdExtAccess1" + GHCR_TOKEN=$(read_secret " GHCR token (input hidden)") + + # Step 2 — optional + echo "" + MIGRATE_POSTGRES=$(read_yes_no "Step 2: Migrate storage from SQLite to Postgres? (recommended)" "n") + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + echo "" + echo " ⚠ Data will be migrated from SQLite to Postgres. The SQLite store" + echo " will be backed up automatically. To fully revert later, restore" + echo " that backup and delete docker-compose.override.yml +" + echo " config.yaml.enterprise." + local confirm + confirm=$(read_yes_no " Continue?" "y") + if [[ "$confirm" != "yes" ]]; then + MIGRATE_POSTGRES="no" + echo " Skipping Postgres migration." + else + POSTGRES_PASSWORD=$(rand_password) + fi + fi + + # Step 3 — optional, only if Postgres is on (flow requires Postgres) + echo "" + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + ENABLE_FLOW=$(read_yes_no "Step 3: Enable traffic flow? (requires Postgres)" "n") + if [[ "$ENABLE_FLOW" == "yes" ]]; then + # Auth secret MUST match server.authSecret from config.yaml + NB_FLOW_AUTH_SECRET=$(yq eval '.server.authSecret // ""' "$CONFIG_YAML_HOST") + if [[ -z "$NB_FLOW_AUTH_SECRET" ]] || [[ "$NB_FLOW_AUTH_SECRET" == "null" ]]; then + echo "Could not read server.authSecret from $CONFIG_YAML_HOST." > /dev/stderr + echo "Flow receiver auth must match the combined server's authSecret." > /dev/stderr + exit 1 + fi + + NETBIRD_DOMAIN=$(detect_exposed_address) + if [[ -z "$NETBIRD_DOMAIN" ]] || [[ "$NETBIRD_DOMAIN" == "null" ]]; then + NETBIRD_DOMAIN=$(read_required " Public NetBird URL (e.g. https://netbird.example.com)") + fi + # Strip protocol + port to leave just the hostname for the Traefik Host() rule. + NETBIRD_HOSTNAME=$(echo "$NETBIRD_DOMAIN" | sed -E 's,^https?://,,' | sed 's,:.*,,' | sed 's,/.*,,') + + # We need the encryption key from the existing config.yaml for the enricher + NETBIRD_ENCRYPTION_KEY=$(yq eval '.server.store.encryptionKey // ""' "$CONFIG_YAML_HOST") + if [[ -z "$NETBIRD_ENCRYPTION_KEY" ]] || [[ "$NETBIRD_ENCRYPTION_KEY" == "null" ]]; then + echo "Could not read server.store.encryptionKey from $CONFIG_YAML_HOST." > /dev/stderr + exit 1 + fi + fi + else + ENABLE_FLOW="no" + echo "Step 3 (traffic flow) skipped — requires Postgres." + fi +} + +apply_changes() { + echo "" + echo "Writing $OVERRIDE_FILE ..." + install -m 644 /dev/null "$OVERRIDE_FILE" + render_override > "$OVERRIDE_FILE" + + if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then + sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' "$OVERRIDE_FILE" && rm -f "$OVERRIDE_FILE.bak" + fi + + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + echo "Writing $ENTERPRISE_CONFIG_FILE ..." + install -m 600 /dev/null "$ENTERPRISE_CONFIG_FILE" + render_enterprise_config + fi + + # Persist secrets that the override file references via env interpolation. + # We write them to a .env file in the current directory; docker compose + # picks it up automatically. + echo "Writing .env additions (mode 600) ..." + local ENV_FILE=".env" + touch "$ENV_FILE" + chmod 600 "$ENV_FILE" + { + echo "" + echo "# Added by migrate-to-enterprise.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "NB_LICENSE_KEY=${NB_LICENSE_KEY}" + if [[ -n "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then + echo "NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}" + fi + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" + fi + if [[ "$ENABLE_FLOW" == "yes" ]]; then + echo "NB_FLOW_AUTH_SECRET=${NB_FLOW_AUTH_SECRET}" + echo "NETBIRD_ENCRYPTION_KEY=${NETBIRD_ENCRYPTION_KEY}" + fi + } >> "$ENV_FILE" + + echo "" + echo "Logging in to ghcr.io ..." + printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin + unset GHCR_TOKEN + + echo "" + echo "Pulling enterprise images ..." + $DOCKER_COMPOSE_COMMAND pull + + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + echo "" + echo "Stopping existing services (volumes preserved) ..." + $DOCKER_COMPOSE_COMMAND down + + backup_sqlite + + echo "" + echo "Starting Postgres ..." + $DOCKER_COMPOSE_COMMAND up -d postgres + + # Wait for healthy + local counter=0 + echo -n "Waiting for Postgres to become ready" + while ! $DOCKER_COMPOSE_COMMAND exec -T postgres pg_isready -U netbird -d netbird &> /dev/null; do + echo -n " ." + sleep 2 + counter=$((counter + 1)) + if [[ $counter -ge 60 ]]; then + echo "" + echo "Postgres did not become ready in 120s. Recent logs:" + $DOCKER_COMPOSE_COMMAND logs --tail=20 postgres + exit 1 + fi + done + echo " done" + + run_migrate_store + fi + + echo "" + echo "Bringing up all services ..." + $DOCKER_COMPOSE_COMMAND up -d + + echo "" + echo "Migration complete." +} + +print_summary() { + echo "" + echo "──────────────────────────────────────────────────────────────────────" + echo " Summary" + echo "──────────────────────────────────────────────────────────────────────" + echo " Images: swapped to enterprise" + [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " Storage: Postgres (data migrated from SQLite)" + [[ "$MIGRATE_POSTGRES" != "yes" ]] && echo " Storage: SQLite (unchanged)" + [[ "$ENABLE_FLOW" == "yes" ]] && echo " Traffic flow: enabled" + [[ "$ENABLE_FLOW" != "yes" ]] && echo " Traffic flow: disabled" + echo "" + echo " Generated files (next to your docker-compose.yml):" + echo " $OVERRIDE_FILE" + [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE" + echo " .env (license key + secrets, mode 600)" + [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " backups/sqlite-pre-enterprise-*/ (SQLite backup)" + echo "" + echo " Tail logs:" + echo " $DOCKER_COMPOSE_COMMAND logs -f $COMBINED_SERVICE" + echo "" + echo "──────────────────────────────────────────────────────────────────────" + echo " To revert" + echo "──────────────────────────────────────────────────────────────────────" + echo " $DOCKER_COMPOSE_COMMAND down" + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + # Resolve project-prefixed volume names now (before override is removed). + local pg_volume data_volume_actual + pg_volume=$(resolve_data_volume "netbird_postgres") + data_volume_actual=$(resolve_data_volume "$DATA_VOLUME") + echo " # Remove the Postgres volume FIRST, before deleting the override file:" + echo " docker volume rm $pg_volume" + echo " # Restore SQLite from the backup created during this run:" + echo " docker run --rm -v ${data_volume_actual}:/var/lib/netbird -v ${BACKUP_DIR}:/backup busybox sh -c 'cp -a /backup/. /var/lib/netbird/'" + fi + echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE" + echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)" + echo " $DOCKER_COMPOSE_COMMAND up -d" + echo "──────────────────────────────────────────────────────────────────────" +} + +# --------------------------------------------------------------------------- +# Run +# --------------------------------------------------------------------------- + +init_migration +apply_changes +print_summary diff --git a/management/Dockerfile b/management/Dockerfile index 3b2df2623..fe414158c 100644 --- a/management/Dockerfile +++ b/management/Dockerfile @@ -2,4 +2,5 @@ FROM ubuntu:24.04 RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt ENTRYPOINT [ "/go/bin/netbird-mgmt","management"] CMD ["--log-file", "console"] -COPY netbird-mgmt /go/bin/netbird-mgmt +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-mgmt /go/bin/netbird-mgmt diff --git a/management/Dockerfile.debug b/management/Dockerfile.debug deleted file mode 100644 index 4d9730bd7..000000000 --- a/management/Dockerfile.debug +++ /dev/null @@ -1,5 +0,0 @@ -FROM ubuntu:24.04 -RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt -ENTRYPOINT [ "/go/bin/netbird-mgmt","management","--log-level","debug"] -CMD ["--log-file", "console"] -COPY netbird-mgmt /go/bin/netbird-mgmt diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 81dc62f49..2a81160a3 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -591,7 +591,7 @@ func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID st c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation)) } - log.WithContext(ctx).Tracef("buffer updating %d affected peers for account %s from %s", len(peerIDs), accountID, util.GetCallerName()) + log.WithContext(ctx).Tracef("buffer updating %d affected peers for account %s from %s with reason %s/%s", len(peerIDs), accountID, util.GetCallerName(), reason.Operation, reason.Resource) bufUpd, _ := c.affectedPeerUpdateLocks.LoadOrStore(accountID, &bufferAffectedUpdate{ peerIDs: make(map[string]struct{}), @@ -704,12 +704,10 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr return nil, nil, 0, err } - startPosture := time.Now() postureChecks, err := c.getPeerPostureChecks(account, peerID) if err != nil { return nil, nil, 0, err } - log.WithContext(ctx).Debugf("getPeerPostureChecks took %s", time.Since(startPosture)) accountZones, err := c.repo.GetAccountZones(ctx, account.Id) if err != nil { diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go index ace105b31..29a117921 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -434,7 +434,7 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) { t.Helper() tokenStore := nbgrpc.NewOneTimeTokenStore(context.Background(), testCacheStore(t)) pkceStore := nbgrpc.NewPKCEVerifierStore(context.Background(), testCacheStore(t)) - srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil) + srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) return srv } @@ -723,7 +723,7 @@ func setupIntegrationTest(t *testing.T) (*Manager, store.Store) { tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t)) pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t)) - proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil) + proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter("")) require.NoError(t, err) @@ -1147,7 +1147,7 @@ func TestDeleteService_DeletesTargets(t *testing.T) { tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t)) pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t)) - proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil) + proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter("")) require.NoError(t, err) diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 46e475143..ae82b60fe 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -219,7 +219,7 @@ func (s *BaseServer) GRPCServer() *grpc.Server { func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer { return Create(s, func() *nbgrpc.ProxyServiceServer { - proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.ProxyManager(), s.Store()) + proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store()) s.AfterInit(func(s *BaseServer) { proxyService.SetServiceManager(s.ServiceManager()) proxyService.SetProxyController(s.ServiceProxyController()) diff --git a/management/internals/shared/grpc/loginfilter.go b/management/internals/shared/grpc/loginfilter.go index 59f69dd90..86eaabf10 100644 --- a/management/internals/shared/grpc/loginfilter.go +++ b/management/internals/shared/grpc/loginfilter.go @@ -11,7 +11,7 @@ import ( const ( reconnThreshold = 5 * time.Minute - baseBlockDuration = 10 * time.Minute // Duration for which a peer is banned after exceeding the reconnection limit + baseBlockDuration = 30 * time.Minute // Duration for which a peer is banned after exceeding the reconnection limit reconnLimitForBan = 30 // Number of reconnections within the reconnTreshold that triggers a ban metaChangeLimit = 3 // Number of reconnections with different metadata that triggers a ban of one peer ) @@ -139,22 +139,13 @@ func (l *loginFilter) addLogin(wgPubKey string, metaHash uint64) { state.lastSeen = now } -func metaHash(meta nbpeer.PeerSystemMeta, pubip string) uint64 { +func metaHash(meta nbpeer.PeerSystemMeta) uint64 { h := fnv.New64a() - h.Write([]byte(meta.WtVersion)) h.Write([]byte(meta.OSVersion)) h.Write([]byte(meta.KernelVersion)) h.Write([]byte(meta.Hostname)) h.Write([]byte(meta.SystemSerialNumber)) - h.Write([]byte(pubip)) - macs := uint64(0) - for _, na := range meta.NetworkAddresses { - for _, r := range na.Mac { - macs += uint64(r) - } - } - - return h.Sum64() + macs + return h.Sum64() } diff --git a/management/internals/shared/grpc/loginfilter_test.go b/management/internals/shared/grpc/loginfilter_test.go index 797879ae7..d9df26420 100644 --- a/management/internals/shared/grpc/loginfilter_test.go +++ b/management/internals/shared/grpc/loginfilter_test.go @@ -164,9 +164,7 @@ func BenchmarkHashingMethods(b *testing.B) { KernelVersion: "5.15.0-76-generic", Hostname: "prod-server-database-01", SystemSerialNumber: "PC-1234567890", - NetworkAddresses: []nbpeer.NetworkAddress{{Mac: "00:1B:44:11:3A:B7"}, {Mac: "00:1B:44:11:3A:B8"}}, } - pubip := "8.8.8.8" var resultString string var resultUint uint64 @@ -175,7 +173,7 @@ func BenchmarkHashingMethods(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - resultString = builderString(meta, pubip) + resultString = builderString(meta) } }) @@ -183,7 +181,7 @@ func BenchmarkHashingMethods(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - resultString = fnvHashToString(meta, pubip) + resultString = fnvHashToString(meta) } }) @@ -191,7 +189,7 @@ func BenchmarkHashingMethods(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - resultUint = metaHash(meta, pubip) + resultUint = metaHash(meta) } }) @@ -199,29 +197,20 @@ func BenchmarkHashingMethods(b *testing.B) { _ = resultUint } -func fnvHashToString(meta nbpeer.PeerSystemMeta, pubip string) string { +func fnvHashToString(meta nbpeer.PeerSystemMeta) string { h := fnv.New64a() - if len(meta.NetworkAddresses) != 0 { - for _, na := range meta.NetworkAddresses { - h.Write([]byte(na.Mac)) - } - } - h.Write([]byte(meta.WtVersion)) h.Write([]byte(meta.OSVersion)) h.Write([]byte(meta.KernelVersion)) h.Write([]byte(meta.Hostname)) h.Write([]byte(meta.SystemSerialNumber)) - h.Write([]byte(pubip)) return strconv.FormatUint(h.Sum64(), 16) } -func builderString(meta nbpeer.PeerSystemMeta, pubip string) string { - mac := getMacAddress(meta.NetworkAddresses) - estimatedSize := len(meta.WtVersion) + len(meta.OSVersion) + len(meta.KernelVersion) + len(meta.Hostname) + len(meta.SystemSerialNumber) + - len(pubip) + len(mac) + 6 +func builderString(meta nbpeer.PeerSystemMeta) string { + estimatedSize := len(meta.WtVersion) + len(meta.OSVersion) + len(meta.KernelVersion) + len(meta.Hostname) + len(meta.SystemSerialNumber) + 4 var b strings.Builder b.Grow(estimatedSize) @@ -235,23 +224,10 @@ func builderString(meta nbpeer.PeerSystemMeta, pubip string) string { b.WriteString(meta.Hostname) b.WriteByte('|') b.WriteString(meta.SystemSerialNumber) - b.WriteByte('|') - b.WriteString(pubip) return b.String() } -func getMacAddress(nas []nbpeer.NetworkAddress) string { - if len(nas) == 0 { - return "" - } - macs := make([]string, 0, len(nas)) - for _, na := range nas { - macs = append(macs, na.Mac) - } - return strings.Join(macs, "/") -} - func BenchmarkLoginFilter_ParallelLoad(b *testing.B) { filter := newLoginFilterWithCfg(testAdvancedCfg()) numKeys := 100000 diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index 0feb807f6..76663f898 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -33,6 +33,8 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/users" proxyauth "github.com/netbirdio/netbird/proxy/auth" @@ -82,6 +84,9 @@ type ProxyServiceServer struct { // Manager for users usersManager users.Manager + // Manager for IdP-enriched user data (may be nil when no IdP is configured) + idpManager idp.Manager + // Store for one-time authentication tokens tokenStore *OneTimeTokenStore @@ -157,7 +162,7 @@ func enforceAccountScope(ctx context.Context, requestAccountID string) error { } // NewProxyServiceServer creates a new proxy service server. -func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer { +func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, idpManager idp.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer { ctx, cancel := context.WithCancel(context.Background()) s := &ProxyServiceServer{ accessLogManager: accessLogMgr, @@ -166,6 +171,7 @@ func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeT pkceVerifierStore: pkceStore, peersManager: peersManager, usersManager: usersManager, + idpManager: idpManager, proxyManager: proxyMgr, tokenChecker: tokenChecker, snapshotBatchSize: snapshotBatchSizeFromEnv(), @@ -1702,22 +1708,7 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto. } groupIDs, groupNames := pairGroupIDsAndNames(peerGroups) - - // Resolve the principal: when the peer is linked to a user, the human - // is the principal so multiple peers owned by the same user share a - // single identity. Unlinked peers (machine agents) are their own - // principal keyed on peer.ID. displayIdentity is what upstream gateways - // tag spend with — user.Email when linked, peer.Name when not. - principalID := peer.ID - displayIdentity := peer.Name - if peer.UserID != "" { - if user, uerr := s.usersManager.GetUser(ctx, peer.UserID); uerr == nil && user != nil { - principalID = user.Id - if user.Email != "" { - displayIdentity = user.Email - } - } - } + principalID, displayIdentity := s.getTunnelPeerInfo(ctx, domain, service, peer) if err := checkPeerGroupAccess(service, groupIDs); err != nil { log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: access denied") @@ -1754,6 +1745,45 @@ func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto. }, nil } +// getTunnelPeerInfo returns the principal ID and display name for a peer, e.g. a +// user or peer ID, and peer name or user email. +func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain string, service *rpservice.Service, peer *peer.Peer) (string, string) { + // Resolve the principal: when the peer is linked to a user, the human is the + // principal so multiple peers owned by the same user share a single + // identity. Unlinked peers (machine agents) are their own principal keyed on + // peer.ID. displayIdentity is what upstream gateways tag spend with — + // user.Email when linked, peer.Name when not. + + // If the peer isn't associated with a user, return the peer info directly. + if peer.UserID == "" { + return peer.ID, peer.Name + } + + // Otherwise, if the peer is linked to a user, the user is the principal and + // if an IdP is available, we gather details on the user from it. + principalID := peer.UserID + displayIdentity := peer.Name + // Stored column first (cheap, but often empty for OIDC-provisioned users). + if user, uerr := s.usersManager.GetUser(ctx, peer.UserID); uerr == nil && user != nil { + principalID = user.Id + if user.Email != "" { + displayIdentity = user.Email + } + } + // IdP enrichment wins when available — the stored email column is a + // best-effort cache and is frequently empty for OIDC users. Enrichment + // failures must never fail the RPC; we simply keep the stored/peer identity. + if s.idpManager != nil { + if ud, uerr := s.idpManager.GetUserDataByID(ctx, peer.UserID, idp.AppMetadata{WTAccountID: service.AccountID}); uerr == nil && ud != nil && ud.Email != "" { + displayIdentity = ud.Email + } else if uerr != nil { + log.WithFields(log.Fields{"domain": domain, "user_id": peer.UserID, "error": uerr.Error()}).Debug("ValidateTunnelPeer: IdP user enrichment failed; using stored/peer identity") + } + } + + return principalID, displayIdentity +} + // checkPeerGroupAccess gates ValidateTunnelPeer by the service's required // groups. Private services authorise against AccessGroups (empty list fails // closed — Validate() rejects that at save time but the RPC is the security diff --git a/management/internals/shared/grpc/proxy_group_access_test.go b/management/internals/shared/grpc/proxy_group_access_test.go index 76da7ddbc..532cb7cc3 100644 --- a/management/internals/shared/grpc/proxy_group_access_test.go +++ b/management/internals/shared/grpc/proxy_group_access_test.go @@ -3,14 +3,19 @@ package grpc import ( "context" "errors" + "net" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/management/internals/modules/peers" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/proto" ) type mockReverseProxyManager struct { @@ -137,6 +142,52 @@ func (m *mockUsersManager) GetUserWithGroups(ctx context.Context, userID string) return user, nil, nil } +// mockTunnelPeersManager implements only the two peers.Manager methods that +// ValidateTunnelPeer calls; the embedded interface satisfies the rest (and +// panics if any unexpected method is invoked). +type mockTunnelPeersManager struct { + peers.Manager + peer *peer.Peer + peerErr error + groups []*types.Group + groupsErr error +} + +func (m *mockTunnelPeersManager) GetPeerByTunnelIP(_ context.Context, _ string, _ net.IP) (*peer.Peer, error) { + return m.peer, m.peerErr +} + +func (m *mockTunnelPeersManager) GetPeerWithGroups(_ context.Context, _, _ string) (*peer.Peer, []*types.Group, error) { + return m.peer, m.groups, m.groupsErr +} + +// mockTunnelIdpManager implements only GetUserDataByID; the embedded interface +// satisfies the rest of idp.Manager. hasData==false returns (nil, nil) to model +// an IdP that knows nothing about the user. +type mockTunnelIdpManager struct { + idp.Manager + email string + hasData bool + err error + gotCalls int + gotMeta []idp.AppMetadata +} + +func (m *mockTunnelIdpManager) GetUserDataByID(_ context.Context, userID string, meta idp.AppMetadata) (*idp.UserData, error) { + m.gotCalls++ + m.gotMeta = append(m.gotMeta, meta) + if m.err != nil { + return nil, m.err + } + if !m.hasData { + // This might not be a thing any of the actual IDP implementations do, + // i.e. return a nil value with no error, but it seems valuable to test + // that behavior here. + return nil, nil //nolint:nilnil + } + return &idp.UserData{ID: userID, Email: m.email}, nil +} + func TestValidateUserGroupAccess(t *testing.T) { tests := []struct { name string @@ -354,6 +405,163 @@ func TestValidateUserGroupAccess(t *testing.T) { } } +// TestValidateTunnelPeerUserEmailEnrichment verifies the UserEmail/UserId +// resolution in ValidateTunnelPeer, including the IdP-enrichment fallback order +// (IdP email -> stored User.Email -> peer.Name). +func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) { + const ( + domain = "app.example.com" + accountID = "account1" + peerID = "peer1" + peerName = "peer-display-name" + userID = "user1" + ) + + storedUser := map[string]*types.User{userID: {Id: userID, AccountID: accountID, Email: "stored@example.com"}} + storedUserNoEmail := map[string]*types.User{userID: {Id: userID, AccountID: accountID, Email: ""}} + + tests := []struct { + name string + peerUserID string + storedUsers map[string]*types.User + storedErr error + noIdP bool + idpEmail string + idpHasData bool + idpErr error + expectEmail string + expectUserID string + expectIdPHit bool + }{ + { + name: "idp email wins over stored email", + peerUserID: userID, + storedUsers: storedUser, + idpEmail: "idp@example.com", + idpHasData: true, + expectEmail: "idp@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "stored email when idp returns empty email", + peerUserID: userID, + storedUsers: storedUser, + idpEmail: "", + idpHasData: true, + expectEmail: "stored@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "stored email when idp has no data", + peerUserID: userID, + storedUsers: storedUser, + idpHasData: false, + expectEmail: "stored@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "stored email when idp errors", + peerUserID: userID, + storedUsers: storedUser, + idpErr: errors.New("idp unreachable"), + expectEmail: "stored@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "stored email when no idp manager", + peerUserID: userID, + storedUsers: storedUser, + noIdP: true, + expectEmail: "stored@example.com", + expectUserID: userID, + }, + { + name: "idp email when stored email is empty", + peerUserID: userID, + storedUsers: storedUserNoEmail, + idpEmail: "idp@example.com", + idpHasData: true, + expectEmail: "idp@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "idp email when stored user missing keeps peer.UserID as principal", + peerUserID: userID, + storedUsers: map[string]*types.User{}, + idpEmail: "idp@example.com", + idpHasData: true, + expectEmail: "idp@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "unlinked peer uses peer name and never consults idp", + peerUserID: "", + storedUsers: storedUser, + idpEmail: "idp@example.com", + idpHasData: true, + expectEmail: peerName, + expectUserID: peerID, + expectIdPHit: false, + }, + { + name: "linked peer with empty stored email and no idp falls back to peer name", + peerUserID: userID, + storedUsers: storedUserNoEmail, + noIdP: true, + expectEmail: peerName, + expectUserID: userID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &service.Service{Domain: domain, AccountID: accountID} + server := &ProxyServiceServer{ + serviceManager: &mockReverseProxyManager{ + proxiesByAccount: map[string][]*service.Service{accountID: {svc}}, + }, + peersManager: &mockTunnelPeersManager{ + peer: &peer.Peer{ID: peerID, Name: peerName, UserID: tt.peerUserID}, + }, + usersManager: &mockUsersManager{users: tt.storedUsers, err: tt.storedErr}, + } + + var idpMock *mockTunnelIdpManager + if !tt.noIdP { + idpMock = &mockTunnelIdpManager{email: tt.idpEmail, hasData: tt.idpHasData, err: tt.idpErr} + server.idpManager = idpMock + } + + resp, err := server.ValidateTunnelPeer(context.Background(), &proto.ValidateTunnelPeerRequest{ + Domain: domain, + TunnelIp: "100.64.0.1", + }) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.True(t, resp.GetValid(), "expected access granted") + assert.Equal(t, tt.expectEmail, resp.GetUserEmail()) + assert.Equal(t, tt.expectUserID, resp.GetUserId()) + + if idpMock != nil { + if tt.expectIdPHit { + assert.Equal(t, 1, idpMock.gotCalls, "expected IdP to be consulted") + require.Len(t, idpMock.gotMeta, 1) + assert.Equal(t, accountID, idpMock.gotMeta[0].WTAccountID) + } else { + assert.Equal(t, 0, idpMock.gotCalls, "expected IdP to not be consulted") + } + } + }) + } +} + func TestGetAccountProxyByDomain(t *testing.T) { tests := []struct { name string diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 5c92de552..942cca9b2 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -254,7 +254,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S return mapError(ctx, err) } - metahashed := metaHash(peerMeta, sRealIP) + metahashed := metaHash(peerMeta) if userID == "" && !s.loginFilter.allowLogin(peerKey.String(), metahashed) { if s.appMetrics != nil { s.appMetrics.GRPCMetrics().CountSyncRequestBlocked() @@ -306,7 +306,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S log.WithContext(ctx).Tracef("peer system meta has to be provided on sync. Peer %s, remote addr %s", peerKey.String(), realIP) } - metahash := metaHash(peerMeta, realIP.String()) + metahash := metaHash(peerMeta) s.loginFilter.addLogin(peerKey.String(), metahash) peer, netMap, postureChecks, dnsFwdPort, err := s.accountManager.SyncAndMarkPeer(ctx, accountID, peerKey.String(), peerMeta, realIP, syncStart) @@ -732,7 +732,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto } peerMeta := extractPeerMeta(ctx, loginReq.GetMeta()) - metahashed := metaHash(peerMeta, sRealIP) + metahashed := metaHash(peerMeta) if !s.loginFilter.allowLogin(peerKey.String(), metahashed) { if s.logBlockedPeers { log.WithContext(ctx).Tracef("peer %s with meta hash %d is blocked from login", peerKey.String(), metahashed) @@ -788,7 +788,11 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto ExtraDNSLabels: loginReq.GetDnsLabels(), }) if err != nil { - log.WithContext(ctx).Warnf("failed logging in peer %s: %s", peerKey, err) + if errors.Is(err, internalStatus.ErrNoAuthMethodProvided) { + log.WithContext(ctx).Tracef("failed logging in peer %s: %s", peerKey, err) + } else { + log.WithContext(ctx).Warnf("failed logging in peer %s: %s", peerKey, err) + } return nil, mapError(ctx, err) } @@ -1229,7 +1233,7 @@ func (s *Server) SyncMeta(ctx context.Context, req *proto.EncryptedMessage) (*pr return nil, msg } - err = s.accountManager.SyncPeerMeta(ctx, peerKey.String(), extractPeerMeta(ctx, syncMetaReq.GetMeta())) + err = s.accountManager.SyncPeerMeta(ctx, peerKey.String(), extractPeerMeta(ctx, syncMetaReq.GetMeta()), realIP) if err != nil { return nil, mapError(ctx, err) } @@ -1278,7 +1282,10 @@ func (s *Server) Logout(ctx context.Context, req *proto.EncryptedMessage) (*prot func toProtocolChecks(ctx context.Context, postureChecks []*posture.Checks) []*proto.Checks { protoChecks := make([]*proto.Checks, 0, len(postureChecks)) for _, postureCheck := range postureChecks { - protoChecks = append(protoChecks, toProtocolCheck(postureCheck)) + check := toProtocolCheck(postureCheck) + if check != nil { + protoChecks = append(protoChecks, check) + } } return protoChecks @@ -1302,5 +1309,9 @@ func toProtocolCheck(postureCheck *posture.Checks) *proto.Checks { } } + if len(protoCheck.Files) == 0 { + return nil + } + return protoCheck } diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go index 27d9a65e7..d649102a1 100644 --- a/management/internals/shared/grpc/validate_session_test.go +++ b/management/internals/shared/grpc/validate_session_test.go @@ -42,7 +42,7 @@ func setupValidateSessionTest(t *testing.T) *validateSessionTestSetup { tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t)) pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) - proxyService := NewProxyServiceServer(nil, tokenStore, pkceStore, ProxyOIDCConfig{}, nil, usersManager, proxyManager, nil) + proxyService := NewProxyServiceServer(nil, tokenStore, pkceStore, ProxyOIDCConfig{}, nil, usersManager, nil, proxyManager, nil) proxyService.SetServiceManager(serviceManager) createTestProxies(t, ctx, testStore) diff --git a/management/server/account.go b/management/server/account.go index 3bcb211a7..4d5343d87 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -1897,12 +1897,12 @@ func domainIsUpToDate(domain string, domainCategory string, userAuth auth.UserAu // concurrent stream that started earlier loses the optimistic-lock race // in MarkPeerConnected and bails without writing. func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { - peer, netMap, postureChecks, dnsfwdPort, err := am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta}, accountID) + peer, netMap, postureChecks, dnsfwdPort, err := am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, RealIP: realIP}, accountID) if err != nil { return nil, nil, nil, 0, fmt.Errorf("error syncing peer: %w", err) } - if err := am.MarkPeerConnected(ctx, peerPubKey, realIP, accountID, syncTime.UnixNano(), netMap); err != nil { + if err := am.MarkPeerConnected(ctx, peerPubKey, accountID, syncTime.UnixNano(), netMap); err != nil { log.WithContext(ctx).Warnf("failed marking peer as connected %s %v", peerPubKey, err) } @@ -1922,13 +1922,13 @@ func (am *DefaultAccountManager) OnPeerDisconnected(ctx context.Context, account return nil } -func (am *DefaultAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error { +func (am *DefaultAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error { accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, peerPubKey) if err != nil { return err } - _, _, _, _, err = am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, UpdateAccountPeers: true}, accountID) + _, _, _, _, err = am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, RealIP: realIP, UpdateAccountPeers: true}, accountID) if err != nil { return err } diff --git a/management/server/account/manager.go b/management/server/account/manager.go index 784e432f6..1e738c274 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -62,7 +62,7 @@ type Manager interface { GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsers(ctx context.Context, accountID string) ([]*types.User, error) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) - MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error + MarkPeerConnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error DeletePeer(ctx context.Context, accountID, peerID, userID string) error UpdatePeer(ctx context.Context, accountID, userID string, p *nbpeer.Peer) (*nbpeer.Peer, error) @@ -123,7 +123,7 @@ type Manager interface { GetValidatedPeers(ctx context.Context, accountID string) (map[string]struct{}, map[string]string, error) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error - SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error + SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error FindExistingPostureCheck(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) GetAccountIDForPeerKey(ctx context.Context, peerKey string) (string, error) GetAccountSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error) diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go index 145e6e00f..274e4c683 100644 --- a/management/server/account/manager_mock.go +++ b/management/server/account/manager_mock.go @@ -1323,17 +1323,17 @@ func (mr *MockManagerMockRecorder) ExtendPeerSession(ctx, peerPubKey, userID int } // MarkPeerConnected mocks base method. -func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { +func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, realIP, accountID, sessionStartedAt, nmap) + ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, accountID, sessionStartedAt, nmap) ret0, _ := ret[0].(error) return ret0 } // MarkPeerConnected indicates an expected call of MarkPeerConnected. -func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, realIP, accountID, sessionStartedAt, nmap interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, accountID, sessionStartedAt, nmap interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, realIP, accountID, sessionStartedAt, nmap) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, accountID, sessionStartedAt, nmap) } // MarkPeerDisconnected mocks base method. @@ -1586,17 +1586,17 @@ func (mr *MockManagerMockRecorder) SyncPeer(ctx, sync, accountID interface{}) *g } // SyncPeerMeta mocks base method. -func (m *MockManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta peer.PeerSystemMeta) error { +func (m *MockManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta peer.PeerSystemMeta, realIP net.IP) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SyncPeerMeta", ctx, peerPubKey, meta) + ret := m.ctrl.Call(m, "SyncPeerMeta", ctx, peerPubKey, meta, realIP) ret0, _ := ret[0].(error) return ret0 } // SyncPeerMeta indicates an expected call of SyncPeerMeta. -func (mr *MockManagerMockRecorder) SyncPeerMeta(ctx, peerPubKey, meta interface{}) *gomock.Call { +func (mr *MockManagerMockRecorder) SyncPeerMeta(ctx, peerPubKey, meta, realIP interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncPeerMeta", reflect.TypeOf((*MockManager)(nil).SyncPeerMeta), ctx, peerPubKey, meta) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncPeerMeta", reflect.TypeOf((*MockManager)(nil).SyncPeerMeta), ctx, peerPubKey, meta, realIP) } // SyncUserJWTGroups mocks base method. diff --git a/management/server/account_test.go b/management/server/account_test.go index 5bfe7c44f..e51afada8 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -1836,7 +1836,7 @@ func TestDefaultAccountManager_UpdatePeer_PeerLoginExpiration(t *testing.T) { accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err, "unable to get the account") - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano(), nil) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), accountID, time.Now().UTC().UnixNano(), nil) require.NoError(t, err, "unable to mark peer connected") _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ @@ -1907,7 +1907,7 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing. require.NoError(t, err, "unable to get the account") // when we mark peer as connected, the peer login expiration routine should trigger - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano(), nil) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), accountID, time.Now().UTC().UnixNano(), nil) require.NoError(t, err, "unable to mark peer connected") failed := waitTimeout(wg, time.Second) @@ -1916,6 +1916,117 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing. } } +func TestDefaultAccountManager_MarkPeerDisconnected_SchedulesInactivityExpiration(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + key, err := wgtypes.GenerateKey() + require.NoError(t, err, "unable to generate WireGuard key") + peerPubKey := key.PublicKey().String() + + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: peerPubKey, + Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, + InactivityExpirationEnabled: true, + }, false) + require.NoError(t, err, "unable to add peer") + + _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ + PeerLoginExpiration: time.Hour, + PeerLoginExpirationEnabled: true, + PeerInactivityExpiration: time.Hour, + PeerInactivityExpirationEnabled: true, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err, "expecting to update account settings successfully but got error") + + // Establish a session so the matching-token disconnect is actually applied. + streamStartTime := time.Now().UTC() + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano(), nil) + require.NoError(t, err, "unable to mark peer connected") + + // Install the mock only now, so the assertion observes the disconnect, not + // the earlier connect. + scheduled := make(chan struct{}, 1) + manager.peerInactivityExpiry = &MockScheduler{ + CancelFunc: func(ctx context.Context, IDs []string) {}, + ScheduleFunc: func(ctx context.Context, in time.Duration, ID string, job func() (nextRunIn time.Duration, reschedule bool)) { + select { + case scheduled <- struct{}{}: + default: + } + }, + } + + err = manager.MarkPeerDisconnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano()) + require.NoError(t, err, "unable to mark peer disconnected") + + select { + case <-scheduled: + // expected: disconnect re-armed the inactivity expiry timer + case <-time.After(time.Second): + t.Fatal("expected inactivity expiration to be rescheduled when an eligible peer disconnects") + } +} + +func TestDefaultAccountManager_MarkPeerDisconnected_SkipsInactivityExpirationWhenDisabled(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + key, err := wgtypes.GenerateKey() + require.NoError(t, err, "unable to generate WireGuard key") + peerPubKey := key.PublicKey().String() + + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: peerPubKey, + Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, + InactivityExpirationEnabled: true, + }, false) + require.NoError(t, err, "unable to add peer") + + // Peer is eligible (SSO + inactivity enabled) but the account-level setting + // stays disabled, so disconnect must not schedule anything. + _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ + PeerLoginExpiration: time.Hour, + PeerLoginExpirationEnabled: true, + PeerInactivityExpiration: time.Hour, + PeerInactivityExpirationEnabled: false, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err, "expecting to update account settings successfully but got error") + + streamStartTime := time.Now().UTC() + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano(), nil) + require.NoError(t, err, "unable to mark peer connected") + + scheduled := make(chan struct{}, 1) + manager.peerInactivityExpiry = &MockScheduler{ + CancelFunc: func(ctx context.Context, IDs []string) {}, + ScheduleFunc: func(ctx context.Context, in time.Duration, ID string, job func() (nextRunIn time.Duration, reschedule bool)) { + select { + case scheduled <- struct{}{}: + default: + } + }, + } + + err = manager.MarkPeerDisconnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano()) + require.NoError(t, err, "unable to mark peer disconnected") + + select { + case <-scheduled: + t.Fatal("inactivity expiration must not be scheduled while the account-level setting is disabled") + case <-time.After(200 * time.Millisecond): + // expected: nothing scheduled + } +} + func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") @@ -1935,7 +2046,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { t.Run("disconnect peer when session token matches", func(t *testing.T) { streamStartTime := time.Now().UTC() - err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano(), nil) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano(), nil) require.NoError(t, err, "unable to mark peer connected") peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) @@ -1956,7 +2067,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { t.Run("skip disconnect when stored session is newer (zombie stream protection)", func(t *testing.T) { // Newer stream wins on connect (sets SessionStartedAt = now ns). streamStartTime := time.Now().UTC() - err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano(), nil) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano(), nil) require.NoError(t, err, "unable to mark peer connected") peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) @@ -1980,7 +2091,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { t.Run("skip stale connect when stored session is newer (blocked goroutine protection)", func(t *testing.T) { node2SyncTime := time.Now().UTC() - err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node2SyncTime.UnixNano(), nil) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, node2SyncTime.UnixNano(), nil) require.NoError(t, err, "node 2 should connect peer") peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) @@ -1990,7 +2101,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { "SessionStartedAt should equal node2SyncTime token") node1StaleSyncTime := node2SyncTime.Add(-1 * time.Minute) - err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node1StaleSyncTime.UnixNano(), nil) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, node1StaleSyncTime.UnixNano(), nil) require.NoError(t, err, "stale connect should not return error") peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) @@ -2052,7 +2163,7 @@ func TestDefaultAccountManager_MarkPeerConnected_ConcurrentRace(t *testing.T) { defer done.Done() ready.Done() start.Wait() - errs <- manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, token, nil) + errs <- manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, token, nil) }() } @@ -2093,7 +2204,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *test account, err := manager.Store.GetAccount(context.Background(), accountID) require.NoError(t, err, "unable to get the account") - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano(), nil) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), accountID, time.Now().UTC().UnixNano(), nil) require.NoError(t, err, "unable to mark peer connected") wg := &sync.WaitGroup{} @@ -3225,7 +3336,7 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU return nil, nil, err } - proxyGrpcServer := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, peersManager, nil, proxyManager, nil) + proxyGrpcServer := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, peersManager, nil, nil, proxyManager, nil) proxyController, err := proxymanager.NewGRPCController(proxyGrpcServer, noop.Meter{}) if err != nil { return nil, nil, err diff --git a/management/server/affected_peers_coverage_test.go b/management/server/affected_peers_coverage_test.go index 56917905f..ae5b92f49 100644 --- a/management/server/affected_peers_coverage_test.go +++ b/management/server/affected_peers_coverage_test.go @@ -41,7 +41,7 @@ func TestAffectedPeers_DependencyCoverageMatrix(t *testing.T) { _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) require.NoError(t, err) return affectedpeers.Change{ChangedPeerIDs: []string{s.routerPeerID}}, - []string{s.sourcePeerID}, []string{s.unrelatedPeerID} + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} }, }, { @@ -106,11 +106,9 @@ func TestAffectedPeers_DependencyCoverageMatrix(t *testing.T) { change, mustContain, mustExclude := r.build(t, s, ctx) affected := resolveAffected(t, s.manager.Store, s.accountID, change) - for _, id := range mustContain { - assert.Contains(t, affected, id, "expected peer to be affected") - } - for _, id := range mustExclude { - assert.NotContains(t, affected, id, "peer must not be affected") + assert.ElementsMatch(t, affected, mustContain, "expected peer to be affected") + for _, peerID := range mustExclude { + assert.NotContains(t, affected, peerID, "peer must not be affected") } }) } diff --git a/management/server/affected_peers_router_paths_test.go b/management/server/affected_peers_router_paths_test.go index 11313c387..5d83367fd 100644 --- a/management/server/affected_peers_router_paths_test.go +++ b/management/server/affected_peers_router_paths_test.go @@ -251,7 +251,9 @@ func TestAffectedPeers_E2E_UpdateResource_DestinationResourcePolicy_RefreshesSou } } -func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouter_StillBridged(t *testing.T) { +// A disabled sibling router routes to nobody, so updating a resource on its network +// must NOT refresh its peer (the enabled router carries the bridge instead). +func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouterNotBridged(t *testing.T) { s := setupRouterScenario(t, true) ctx := context.Background() @@ -274,13 +276,18 @@ func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouter_StillBridged(t * require.NoError(t, err) disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID) - t.Cleanup(func() { s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID) }) + enabledCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) - settleAffectedUpdates(disabledCh) + settleAffectedUpdates(disabledCh, enabledCh) done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, disabledCh) + peerShouldReceiveUpdate(t, enabledCh) + peerShouldNotReceiveUpdate(t, disabledCh) close(done) }() @@ -298,7 +305,7 @@ func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouter_StillBridged(t * select { case <-done: case <-time.After(peerUpdateTimeout): - t.Error("timeout: resource update did not refresh the disabled sibling router's peer") + t.Error("timeout") } } diff --git a/management/server/affected_peers_router_test.go b/management/server/affected_peers_router_test.go index dc064e787..cc9df0a6a 100644 --- a/management/server/affected_peers_router_test.go +++ b/management/server/affected_peers_router_test.go @@ -682,6 +682,9 @@ func TestAffectedPeers_AllRoutingPeers_Network(t *testing.T) { assert.Contains(t, affected, secondRouterPeer.ID, "second routing peer on the same network must also be affected") } +// A disabled router in the snapshot routes to nobody, so it is skipped when the +// walk scans existing account data: a policy edit still folds the literal source +// group, but not the disabled router's peer. func TestAffectedPeers_DisabledRouter(t *testing.T) { s := setupRouterScenario(t, true) ctx := context.Background() @@ -694,11 +697,13 @@ func TestAffectedPeers_DisabledRouter(t *testing.T) { affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) - assert.Contains(t, affected, s.sourcePeerID, "source peer must be affected") - assert.Contains(t, affected, s.routerPeerID, - "disabled router's peer must still be affected: Enabled must not gate affected-peers") + assert.Contains(t, affected, s.sourcePeerID, "source peer (literal policy source group) must be affected") + assert.NotContains(t, affected, s.routerPeerID, + "a disabled router routes to nobody, so its peer must not be folded from snapshot data") } +// A disabled resource in the snapshot is skipped: the policy edit still folds the +// literal source group, but the resource no longer bridges to its network's router. func TestAffectedPeers_DisabledResource(t *testing.T) { s := setupRouterScenario(t, true) ctx := context.Background() @@ -710,9 +715,9 @@ func TestAffectedPeers_DisabledResource(t *testing.T) { affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) - assert.Contains(t, affected, s.sourcePeerID, "source peer must be affected") - assert.Contains(t, affected, s.routerPeerID, - "disabled resource must still resolve the routing peer: Enabled must not gate affected-peers") + assert.Contains(t, affected, s.sourcePeerID, "source peer (literal policy source group) must be affected") + assert.NotContains(t, affected, s.routerPeerID, + "a disabled resource routes to nobody, so its network's router must not be folded from snapshot data") } func TestAffectedPeers_DisabledRule(t *testing.T) { diff --git a/management/server/affected_peers_test.go b/management/server/affected_peers_test.go index e2dcd830b..235128693 100644 --- a/management/server/affected_peers_test.go +++ b/management/server/affected_peers_test.go @@ -96,33 +96,54 @@ func affectedGroupID(i int) string { return fmt.Sprintf("affected-grp-%d", i) func affectedGroupName(i int) string { return fmt.Sprintf("AffectedGroup%d", i) } func TestCollectGroupChange_PolicyLinked(t *testing.T) { - manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) ctx := context.Background() _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ Enabled: true, Rules: []*types.PolicyRule{ { - Enabled: true, - Sources: []string{groupIDs[0]}, - Destinations: []string{groupIDs[1]}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + SourceResource: types.Resource{ID: peerIDs[0], Type: types.ResourceTypePeer}, + DestinationResource: types.Resource{ID: peerIDs[1], Type: types.ResourceTypePeer}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + SourceResource: types.Resource{ID: peerIDs[2], Type: types.ResourceTypeHost}, + DestinationResource: types.Resource{ID: peerIDs[3], Type: types.ResourceTypeHost}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + SourceResource: types.Resource{ID: "", Type: types.ResourceTypePeer}, + DestinationResource: types.Resource{ID: "", Type: types.ResourceTypePeer}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, }, }, }, true) require.NoError(t, err) - groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) - assert.Contains(t, groups, groupIDs[0]) - assert.Contains(t, groups, groupIDs[1]) + groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) + assert.ElementsMatch(t, directPeers, []string{peerIDs[1]}) - groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) - assert.Contains(t, groups, groupIDs[0]) - assert.Contains(t, groups, groupIDs[1]) + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) + assert.ElementsMatch(t, directPeers, []string{peerIDs[0]}) - groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]}) + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]}) assert.Empty(t, groups) + assert.Empty(t, directPeers) } func TestCollectGroupChange_PolicyWithDirectPeerResource(t *testing.T) { @@ -133,20 +154,44 @@ func TestCollectGroupChange_PolicyWithDirectPeerResource(t *testing.T) { Enabled: true, Rules: []*types.PolicyRule{ { - Enabled: true, - Sources: []string{groupIDs[0]}, - SourceResource: types.Resource{ID: peerIDs[3], Type: types.ResourceTypePeer}, - Destinations: []string{groupIDs[1]}, - Action: types.PolicyTrafficActionAccept, + Enabled: true, + Sources: []string{groupIDs[0]}, + SourceResource: types.Resource{ID: peerIDs[3], Type: types.ResourceTypePeer}, + DestinationResource: types.Resource{ID: peerIDs[4], Type: types.ResourceTypePeer}, + Destinations: []string{groupIDs[1]}, + Action: types.PolicyTrafficActionAccept, + }, + { + Enabled: true, + Sources: []string{groupIDs[0]}, + SourceResource: types.Resource{ID: peerIDs[1], Type: types.ResourceTypeHost}, + DestinationResource: types.Resource{ID: peerIDs[2], Type: types.ResourceTypeHost}, + Destinations: []string{groupIDs[1]}, + Action: types.PolicyTrafficActionAccept, + }, + { + Enabled: true, + Sources: []string{groupIDs[0]}, + SourceResource: types.Resource{ID: "", Type: types.ResourceTypePeer}, + DestinationResource: types.Resource{ID: "", Type: types.ResourceTypePeer}, + Destinations: []string{groupIDs[1]}, + Action: types.PolicyTrafficActionAccept, }, }, }, true) require.NoError(t, err) groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) - assert.Contains(t, groups, groupIDs[0]) - assert.Contains(t, groups, groupIDs[1]) - assert.Contains(t, directPeers, peerIDs[3]) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) + assert.ElementsMatch(t, directPeers, []string{peerIDs[4]}) + + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) + assert.ElementsMatch(t, directPeers, []string{peerIDs[3]}) + + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]}) + assert.Empty(t, groups) + assert.Empty(t, directPeers) } func TestCollectGroupChange_PolicyWithNonPeerResource_NoDirectPeers(t *testing.T) { @@ -168,8 +213,7 @@ func TestCollectGroupChange_PolicyWithNonPeerResource_NoDirectPeers(t *testing.T require.NoError(t, err) groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) - assert.Contains(t, groups, groupIDs[0]) - assert.Contains(t, groups, groupIDs[1]) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) assert.Empty(t, directPeers, "non-peer resources should not produce direct peer IDs") } @@ -294,6 +338,7 @@ func TestCollectGroupChange_NetworkRouterLinked(t *testing.T) { AccountID: accountID, PeerGroups: []string{groupIDs[0]}, Peer: peerIDs[3], + Enabled: true, }) require.NoError(t, err) @@ -324,6 +369,7 @@ func TestCollectGroupChange_NetworkRouterPeerOnlyNoGroups(t *testing.T) { NetworkID: net1.ID, AccountID: accountID, Peer: peerIDs[4], + Enabled: true, }) require.NoError(t, err) @@ -373,17 +419,11 @@ func TestCollectGroupChange_MultipleEntities(t *testing.T) { require.NoError(t, err) groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) - assert.Contains(t, groups, groupIDs[0]) - assert.Contains(t, groups, groupIDs[1]) - assert.NotContains(t, groups, groupIDs[2]) - assert.NotContains(t, groups, groupIDs[3]) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) assert.Empty(t, directPeers) groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[3]}) - assert.Contains(t, groups, groupIDs[2]) - assert.Contains(t, groups, groupIDs[3]) - assert.NotContains(t, groups, groupIDs[0]) - assert.NotContains(t, groups, groupIDs[1]) + assert.ElementsMatch(t, groups, []string{groupIDs[2], groupIDs[3]}) assert.Empty(t, directPeers) } @@ -452,8 +492,9 @@ func TestResolveAffectedPeers_PolicyBetweenTwoGroups(t *testing.T) { result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]}) assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + // peerIDs[2] is unrelated to the route; only its own map can change. result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) - assert.Empty(t, result) + assert.ElementsMatch(t, []string{peerIDs[2]}, result) } func TestResolveAffectedPeers_PolicyThreeGroups(t *testing.T) { @@ -474,7 +515,7 @@ func TestResolveAffectedPeers_PolicyThreeGroups(t *testing.T) { require.NoError(t, err) result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) - assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2]}, result) } func TestResolveAffectedPeers_RoutePeerGroups(t *testing.T) { @@ -506,8 +547,9 @@ func TestResolveAffectedPeers_RoutePeerGroups(t *testing.T) { result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]}) assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + // peerIDs[2] is in no policy; only its own map can change, so it refreshes itself. result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) - assert.Empty(t, result) + assert.ElementsMatch(t, []string{peerIDs[2]}, result) } func TestResolveAffectedPeers_RouteWithDirectPeer(t *testing.T) { @@ -564,9 +606,9 @@ func TestResolveAffectedPeers_RouteWithAccessControlGroups(t *testing.T) { result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result) - // peer3 is unrelated + // peer3 is unrelated to the route; only its own map can change. result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[3]}) - assert.Empty(t, result) + assert.ElementsMatch(t, []string{peerIDs[3]}, result) } func TestResolveAffectedPeers_NetworkRouter(t *testing.T) { @@ -587,6 +629,7 @@ func TestResolveAffectedPeers_NetworkRouter(t *testing.T) { AccountID: accountID, PeerGroups: []string{groupIDs[0]}, Peer: peerIDs[3], + Enabled: true, }) require.NoError(t, err) @@ -659,9 +702,13 @@ func TestResolveAffectedPeers_PeerInMultipleGroups(t *testing.T) { }, true) require.NoError(t, err) - // peer0 is in group0 AND group1, so both policies apply + // peer0 is in group0 AND group1, so both policies apply. A peer change folds + // only the changed peer plus the opposite side of each rule: group2 (peer2) via + // the group0 policy and group3 (peer3) via the group1 policy. peer1, a co-member + // of group1, is a sibling of the changed peer and must NOT refresh. result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) - assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2], peerIDs[3]}, result) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2], peerIDs[3]}, result) + assert.NotContains(t, result, peerIDs[1], "co-member of the changed peer's group must not refresh") } func TestResolveAffectedPeers_MultipleChangedPeers(t *testing.T) { @@ -697,7 +744,7 @@ func TestResolveAffectedPeers_MultipleChangedPeers(t *testing.T) { require.NoError(t, err) result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0], peerIDs[2]}) - assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2], peerIDs[3]}, result) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2], peerIDs[1], peerIDs[3]}, result) } func TestResolveAffectedPeers_SharedGroupAcrossPolicyAndRoute(t *testing.T) { @@ -854,8 +901,9 @@ func TestAffectedPeers_IsolatedPolicies(t *testing.T) { assert.NotContains(t, result, peerIDs[0]) assert.NotContains(t, result, peerIDs[1]) + // peerIDs[4] is in neither isolated policy; only its own map can change. result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[4]}) - assert.Empty(t, result) + assert.ElementsMatch(t, []string{peerIDs[4]}, result) } func TestAffectedPeers_IsolatedRouteAndPolicy(t *testing.T) { @@ -977,12 +1025,13 @@ func TestAffectedPeers_GroupUpdateOnlyAffectsLinkedPeers(t *testing.T) { }) } -func TestAffectedPeers_UnlinkedGroupChange_NoUpdates(t *testing.T) { +// A peer in no policy/route refreshes only itself — no other peer is affected. +func TestAffectedPeers_UnlinkedPeerChange_RefreshesSelfOnly(t *testing.T) { manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t) ctx := context.Background() result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) - assert.Empty(t, result) + assert.ElementsMatch(t, []string{peerIDs[0]}, result) } // TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate verifies that creating/deleting a @@ -1332,6 +1381,7 @@ func TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate(t *testing.T) { NetworkID: net1.ID, AccountID: accountID, PeerGroups: []string{"nr-grpA"}, + Enabled: true, }) require.NoError(t, err) @@ -1755,7 +1805,9 @@ func TestCollectAffectedFromProxyServices_GroupContainingTargetPeerChanged(t *te assert.Contains(t, directPeers, peerIDs[1], "target peer must be refreshed") } -func TestCollectAffectedFromProxyServices_DisabledServiceStillMatches(t *testing.T) { +// A disabled service in the snapshot proxies nothing, so it is skipped: a changed +// target peer does not pull in the service's proxy peer. +func TestCollectAffectedFromProxyServices_DisabledServiceSkipped(t *testing.T) { manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t) ctx := context.Background() @@ -1781,8 +1833,7 @@ func TestCollectAffectedFromProxyServices_DisabledServiceStillMatches(t *testing require.NoError(t, s.CreateService(ctx, svc)) _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[1]}) - assert.Contains(t, directPeers, peerIDs[0], "disabled service should still trigger a refresh so peers are ready when re-enabled") - assert.Contains(t, directPeers, peerIDs[1], "disabled target should still trigger a refresh") + assert.NotContains(t, directPeers, peerIDs[0], "a disabled service proxies nothing, so its proxy peer must not be folded") } func TestCollectAffectedFromProxyServices_NonPeerTargetType(t *testing.T) { diff --git a/management/server/affectedpeers/resolver.go b/management/server/affectedpeers/resolver.go index 4ef986345..94e24ced6 100644 --- a/management/server/affectedpeers/resolver.go +++ b/management/server/affectedpeers/resolver.go @@ -6,7 +6,12 @@ // and before a delete/removal severs the old state). // - Snapshot.Expand: in-memory walk, no store access. Run AFTER the tx commits. // -// Enabled is never consulted: toggling it is itself an observable change. +// Enabled handling differs by source. Disabled objects in the SNAPSHOT (existing +// account policies/resources/routers/routes/proxy services and their rules/targets) +// route to nobody and are skipped — they cannot affect any peer's map. Objects in +// the CHANGE itself are processed regardless of Enabled, so disabling one still +// refreshes the peers that lose access (the toggle is the observable change, and the +// update carries the old∪new state). package affectedpeers import ( @@ -61,7 +66,8 @@ func Load(ctx context.Context, s store.Store, accountID string, c Change) (*Snap // loadCollections reads the policy/route/nameserver/dns/router/resource/proxy // collections a Change can touch, gated to what the walk needs. func (snap *Snapshot) loadCollections(ctx context.Context, s store.Store, accountID string, c Change) error { - hasGroupOrPeerChange := len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.Resources) > 0 + // LinkGroups drive the same policy/route/dns walk as a changed group or peer. + hasGroupOrPeerChange := len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 0 || len(c.Resources) > 0 hasNetworkObject := len(c.Routers) > 0 || len(c.Resources) > 0 || len(c.Networks) > 0 // the resource<->router bridge can fire for any of these needsRoutersResources := hasGroupOrPeerChange || len(c.PostureCheckIDs) > 0 || len(c.Policies) > 0 || hasNetworkObject @@ -76,7 +82,7 @@ func (snap *Snapshot) loadCollections(ctx context.Context, s store.Store, accoun return err } } - if len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 { + if len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 0 { if err := snap.loadDNS(ctx, s, accountID); err != nil { return err } @@ -174,6 +180,24 @@ type Change struct { // folded in — but only when the group is linked (an unlinked group has no map // impact), matching how current members are handled. RemovedPeersByGroup map[string][]string + + // OutputPeerIDs are peers folded straight into the result without seeding their + // group memberships into the walk. Use for the peer whose group membership changed: + // the peer itself must refresh, but its OTHER groups did not change, so they must + // not be walked. Contrast ChangedPeerIDs, which seeds ALL of the peer's groups + // (correct when the peer's own attributes changed, e.g. IP/status). + OutputPeerIDs []string + + // LinkGroups are groups used ONLY to match policies/routes/routers and walk to the + // OPPOSITE side — they are never expanded to their own members. Use this when a + // peer's group membership changed: pass the peer in ChangedPeerIDs and its + // group(s) here. The opposite side of the policies the group participates in + // refreshes, but the group's other members (siblings) do not — nothing changed for + // them. For an intra-group policy (A→A) the opposite side IS the group, so its + // members still refresh via the opposite-side fold, exactly when they genuinely + // gain/lose the changed peer. Unlike ChangedGroupIDs, a LinkGroup is not added to + // the output, so a one-sided membership change never wakes the whole group. + LinkGroups []string } func (c Change) isEmpty() bool { @@ -186,7 +210,9 @@ func (c Change) isEmpty() bool { len(c.Networks) == 0 && len(c.PostureCheckIDs) == 0 && len(c.DistributionGroupIDs) == 0 && - len(c.RemovedPeersByGroup) == 0 + len(c.RemovedPeersByGroup) == 0 && + len(c.LinkGroups) == 0 && + len(c.OutputPeerIDs) == 0 } // Expand returns the deduplicated affected peer IDs from the preloaded Snapshot, @@ -197,8 +223,8 @@ func (snap *Snapshot) Expand(ctx context.Context, accountID string, c Change) [] return nil } r := newResolver(ctx, snap, accountID, c) - log.WithContext(ctx).Tracef("affectedpeers expand start: account=%s changedGroups=%v changedPeers=%v policies=%d routes=%d routers=%d resources=%d networks=%d postureChecks=%v distributionGroups=%v", - accountID, c.ChangedGroupIDs, c.ChangedPeerIDs, len(c.Policies), len(c.Routes), len(c.Routers), len(c.Resources), len(c.Networks), c.PostureCheckIDs, c.DistributionGroupIDs) + log.WithContext(ctx).Tracef("affectedpeers expand start: account=%s changedGroups=%v changedPeers=%v linkGroups=%v policies=%d routes=%d routers=%d resources=%d networks=%d postureChecks=%v distributionGroups=%v", + accountID, c.ChangedGroupIDs, c.ChangedPeerIDs, c.LinkGroups, len(c.Policies), len(c.Routes), len(c.Routers), len(c.Resources), len(c.Networks), c.PostureCheckIDs, c.DistributionGroupIDs) r.walk() return r.expand() } @@ -216,57 +242,84 @@ func Collect(ctx context.Context, s store.Store, accountID string, c Change) (gr } r := newResolver(ctx, snap, accountID, c) r.walk() - return setToSlice(r.groupSet), setToSlice(r.peerSet) + return setToSlice(r.affectedGroups), setToSlice(r.affectedPeers) } func newResolver(ctx context.Context, snap *Snapshot, accountID string, c Change) *resolver { r := &resolver{ - ctx: ctx, - snap: snap, - accountID: accountID, - change: c, - changedGroupSet: toSet(c.ChangedGroupIDs), - changedPeerSet: toSet(c.ChangedPeerIDs), - groupSet: make(map[string]struct{}), - peerSet: make(map[string]struct{}), - networkIDs: make(map[string]struct{}), + ctx: ctx, + snap: snap, + accountID: accountID, + change: c, + linkGroups: toSet(c.ChangedGroupIDs), + outputGroups: toSet(c.ChangedGroupIDs), + changedPeers: toSet(c.ChangedPeerIDs), + affectedGroups: make(map[string]struct{}), + affectedPeers: make(map[string]struct{}), } + // LinkGroups match policies/routes to find the opposite side but are NOT output: + // they go into linkGroups only, never outputGroups, so their members never fold in. + addAll(r.linkGroups, c.LinkGroups) // Resolve each changed peer to its groups here so callers pass only ChangedPeerIDs. r.seedChangedGroupsFromPeers() - r.matchedPolicies = append(r.matchedPolicies, c.Policies...) return r } -// seedChangedGroupsFromPeers adds each changed peer's groups to changedGroupSet so +// seedChangedGroupsFromPeers adds each changed peer's groups to linkGroups so // the group-driven walkers fire for memberships, not just direct peer references. +// These seeded groups are for MATCHING only — folding the changed entity's own +// side is gated on outputGroups (the caller-reported groups), so a seeded group +// never folds its whole membership; only the changed peer itself folds in. func (r *resolver) seedChangedGroupsFromPeers() { - if len(r.changedPeerSet) == 0 { + if len(r.changedPeers) == 0 { return } for groupID, members := range r.snap.groupPeers { - for pID := range r.changedPeerSet { + for pID := range r.changedPeers { if _, ok := members[pID]; ok { - r.changedGroupSet[groupID] = struct{}{} + r.linkGroups[groupID] = struct{}{} break } } } } +// policySide selects which side of a policy rule to walk. +type policySide int + +const ( + sideSource policySide = iota + sideDestination +) + +func (s policySide) opposite() policySide { + if s == sideSource { + return sideDestination + } + return sideSource +} + +// walk resolves affected peers in two buckets, by how far each change propagates. +// +// BOTH-SIDES — the rule itself changed (an explicit policy edit, or a policy whose +// posture check changed). Source AND destination refresh, so each such policy is +// walked on both sides. +// +// OPPOSITE-SIDE — an endpoint moved but no rule changed. For each policy the change +// touches we fold only the side AWAY from the change: +// - a changed peer/group sits ON a policy side -> fold the opposite side; +// - a changed router/resource/network sits on a NETWORK -> fold the SOURCE side of +// the policies whose destination reaches it (and the routers it implies). +// +// Routes, nameserver groups, DNS and embedded-proxy services distribute to their own +// member peers, outside the policy graph, and are folded here too. func (r *resolver) walk() { - r.collectFromExplicitPolicies() - r.collectFromExplicitRoutes(r.change.Routes) - r.collectFromExplicitRouters(r.change.Routers) - r.collectFromExplicitResources(r.change.Resources) - r.collectFromExplicitNetworks(r.change.Networks) - r.collectFromPostureChecks(r.change.PostureCheckIDs) + for _, policy := range r.bothSidesPolicies() { + r.foldPolicySide(policy, sideSource) + r.foldPolicySide(policy, sideDestination) + } - // Distribution groups (nameserver/DNS) affect only their member peers: fold them - // straight into groupSet so expand() maps them to members, without the policy/ - // route walk that changedGroupSet would trigger. - addAll(r.groupSet, r.change.DistributionGroupIDs) - - if len(r.changedGroupSet) > 0 || len(r.changedPeerSet) > 0 { + if len(r.linkGroups) > 0 || len(r.changedPeers) > 0 { r.collectFromPolicies() r.collectFromRoutes() r.collectFromNameServers() @@ -275,7 +328,31 @@ func (r *resolver) walk() { r.collectFromProxyServices() } - r.collectResourceRouterBridge() + r.collectFromChangedRoutes(r.change.Routes) + r.collectFromChangedRouters(r.change.Routers) + r.collectFromChangedResources(r.change.Resources) + r.collectFromChangedNetworks(r.change.Networks) + + // The explicitly changed peers always refresh their own maps. OnPeersUpdated only + // refreshes the resolver's output (it ignores the separately-passed changed peers), + // so the changed peer reaches its own new map only via here. An offline/deleted + // peer in the set is filtered downstream (filterConnectedAffectedPeers). + addAll(r.affectedPeers, setToSlice(r.changedPeers)) + // OutputPeerIDs refresh themselves too, but unlike changedPeers their group + // memberships were not seeded into the walk (only the changed group was). + addAll(r.affectedPeers, r.change.OutputPeerIDs) + + // Distribution groups (nameserver/DNS) affect only their member peers: fold them + // straight into affectedGroups so expand() maps them to members, without the + // policy/route walk that linkGroups would trigger. + addAll(r.affectedGroups, r.change.DistributionGroupIDs) +} + +// bothSidesPolicies are the policies whose rule changed: the explicitly edited ones +// plus those gated by a changed posture check. walk folds both their sides. +func (r *resolver) bothSidesPolicies() []*types.Policy { + policies := append([]*types.Policy(nil), r.change.Policies...) + return r.appendPoliciesForPostureChecks(policies, r.change.PostureCheckIDs) } type resolver struct { @@ -284,27 +361,71 @@ type resolver struct { accountID string change Change - changedGroupSet map[string]struct{} - changedPeerSet map[string]struct{} + // Inputs — what changed. Set once at construction, read-only during the walk + // (except linkGroups, which collectFromExplicitResources also seeds). + // + // linkGroups is the MATCH set: caller-changed groups ∪ the groups of changed + // peers ∪ changed-resource groups. A rule/route/router matches the change when + // one of its groups is here — used only to find the opposite side to fold. + // + // outputGroups is the FOLD-WHOLE-GROUP set: ONLY Change.ChangedGroupIDs. When a + // matched group is here, its whole membership is affected. A peer-seeded group + // is in linkGroups but NOT outputGroups, so it folds only the changed peer + // (changedPeers), never its siblings. + linkGroups map[string]struct{} + outputGroups map[string]struct{} + changedPeers map[string]struct{} - groupSet map[string]struct{} - peerSet map[string]struct{} - - matchedPolicies []*types.Policy - networkIDs map[string]struct{} + // Outputs — the answer. The only sets the walk accumulates into. affectedGroups + // is expanded to its member peers in expand(). + affectedGroups map[string]struct{} + affectedPeers map[string]struct{} } -func (r *resolver) policies() []*types.Policy { return r.snap.policies } +// policies returns the account's ENABLED policies from the snapshot. Disabled +// policies grant no access, so the walk skips them when scanning existing account +// data. Explicitly changed policies (Change.Policies, via bothSidesPolicies) are +// processed regardless of Enabled, so disabling one still refreshes its peers. +func (r *resolver) policies() []*types.Policy { + enabled := make([]*types.Policy, 0, len(r.snap.policies)) + for _, policy := range r.snap.policies { + if policy != nil && policy.Enabled { + enabled = append(enabled, policy) + } + } + return enabled +} -func (r *resolver) networkResources() []*resourceTypes.NetworkResource { return r.snap.resources } +// networkResources / networkRouters return the account's ENABLED resources/routers +// from the snapshot. Disabled objects route to nobody, so the walk skips them when +// it scans existing account data. The explicitly changed objects in the Change are +// processed regardless of Enabled (collectFromChanged*), so disabling one still +// refreshes the peers that lose access. +func (r *resolver) networkResources() []*resourceTypes.NetworkResource { + enabled := make([]*resourceTypes.NetworkResource, 0, len(r.snap.resources)) + for _, resource := range r.snap.resources { + if resource.Enabled { + enabled = append(enabled, resource) + } + } + return enabled +} -func (r *resolver) networkRouters() []*routerTypes.NetworkRouter { return r.snap.routers } +func (r *resolver) networkRouters() []*routerTypes.NetworkRouter { + enabled := make([]*routerTypes.NetworkRouter, 0, len(r.snap.routers)) + for _, router := range r.snap.routers { + if router.Enabled { + enabled = append(enabled, router) + } + } + return enabled +} // peerIDsForGroups maps a group set to its member peer IDs via the preloaded index. -func (r *resolver) peerIDsForGroups(groupSet map[string]struct{}) []string { +func (r *resolver) peerIDsForGroups(groups map[string]struct{}) []string { seen := make(map[string]struct{}) var ids []string - for gID := range groupSet { + for gID := range groups { for pID := range r.snap.groupPeers[gID] { if _, ok := seen[pID]; ok { continue @@ -317,25 +438,25 @@ func (r *resolver) peerIDsForGroups(groupSet map[string]struct{}) []string { } func (r *resolver) expand() []string { - peerIDs := r.peerIDsForGroups(r.groupSet) + peerIDs := r.peerIDsForGroups(r.affectedGroups) log.WithContext(r.ctx).Tracef("affectedpeers expand: account=%s affectedGroups=%v -> %d group-member peers; direct peers=%v", - r.accountID, setToSlice(r.groupSet), len(peerIDs), setToSlice(r.peerSet)) + r.accountID, setToSlice(r.affectedGroups), len(peerIDs), setToSlice(r.affectedPeers)) seen := make(map[string]struct{}, len(peerIDs)) for _, id := range peerIDs { seen[id] = struct{}{} } - for id := range r.peerSet { + for id := range r.affectedPeers { if _, ok := seen[id]; !ok { peerIDs = append(peerIDs, id) seen[id] = struct{}{} } } - // Fold in removed peers only when their group is linked (in groupSet). + // Fold in removed peers only when their group is linked (in affectedGroups). for groupID, removed := range r.change.RemovedPeersByGroup { - if _, linked := r.groupSet[groupID]; !linked { + if _, linked := r.affectedGroups[groupID]; !linked { continue } for _, id := range removed { @@ -351,169 +472,349 @@ func (r *resolver) expand() []string { return peerIDs } -func (r *resolver) collectFromExplicitPolicies() { - for _, policy := range r.matchedPolicies { - if policy == nil { - continue +// ruleSideGroups / ruleSideResource return the groups and the resource on the given +// side of a rule. +func ruleSideGroups(rule *types.PolicyRule, side policySide) []string { + if side == sideDestination { + return rule.Destinations + } + return rule.Sources +} + +func ruleSideResource(rule *types.PolicyRule, side policySide) types.Resource { + if side == sideDestination { + return rule.DestinationResource + } + return rule.SourceResource +} + +// foldPolicySide folds one side of a policy down to affected peers: its groups +// (resolved to members in expand) and its direct peer. When the side is the +// DESTINATION and references a network resource (directly or via a destination +// group's resources), it also folds the routers that serve that resource's network +// — a destination resource is reached through its routers. A resource on the SOURCE +// side routes to nobody (GetPoliciesForNetworkResource matches destinations only), +// so the router hop is destination-only. +func (r *resolver) foldPolicySide(policy *types.Policy, side policySide) { + if policy == nil { + return + } + for _, rule := range policy.Rules { + addAll(r.affectedGroups, ruleSideGroups(rule, side)) + res := ruleSideResource(rule, side) + if res.Type == types.ResourceTypePeer && res.ID != "" { + r.affectedPeers[res.ID] = struct{}{} } - log.WithContext(r.ctx).Tracef("collectFromExplicitPolicies: changed policy %s (%s) -> folding rule groups %v + direct peers", - policy.ID, policy.Name, policy.RuleGroups()) - addAll(r.groupSet, policy.RuleGroups()) - collectPolicyDirectPeers(policy, r.peerSet) + } + if side == sideDestination { + r.foldRoutersForResources(r.policyDestinationResourceIDs(policy)) } } -func (r *resolver) collectFromExplicitRoutes(routes []*route.Route) { +// appendPoliciesForPostureChecks appends every policy that references a changed +// posture check (a rule change, so walk both sides). +func (r *resolver) appendPoliciesForPostureChecks(policies []*types.Policy, postureCheckIDs []string) []*types.Policy { + if len(postureCheckIDs) == 0 { + return policies + } + ids := toSet(postureCheckIDs) + for _, policy := range r.policies() { + if !policyReferencesPostureChecks(policy, ids) || !policy.Enabled { + continue + } + log.WithContext(r.ctx).Tracef("appendPoliciesForPostureChecks: policy %s (%s) references changed posture checks %v -> both-sides policy", + policy.ID, policy.Name, postureCheckIDs) + policies = append(policies, policy) + } + return policies +} + +// collectFromPolicies folds, for every policy whose rule a changed group or peer +// touches, only the OPPOSITE side (down to peers, incl. destination routers), plus +// the changed entity's own side: the changed group's whole membership when the +// group itself changed (outputGroups), or the changed peer alone when matched via a +// peer-seeded group (never its co-members). +func (r *resolver) collectFromPolicies() { + for _, policy := range r.policies() { + for _, rule := range policy.Rules { + if !rule.Enabled { + continue // a disabled rule grants no access + } + r.foldRuleSideIfChanged(policy, rule, sideSource) + r.foldRuleSideIfChanged(policy, rule, sideDestination) + } + } +} + +// foldRuleSideIfChanged: when a changed group or direct peer sits on `side` of the +// rule, fold the opposite side fully (groups/peers + destination routers) and fold +// the changed entity's own side (the whole changed group, or the changed peer alone). +func (r *resolver) foldRuleSideIfChanged(policy *types.Policy, rule *types.PolicyRule, side policySide) { + nearGroups := ruleSideGroups(rule, side) + nearResource := ruleSideResource(rule, side) + + matchedByGroup := anyInSet(nearGroups, r.linkGroups) + matchedByPeer := isDirectPeerInSet(nearResource, r.changedPeers) + if !matchedByGroup && !matchedByPeer { + return + } + + // Opposite side, fully down to peers (a destination opposite also folds routers). + r.foldPolicySideForRule(policy, rule, side.opposite()) + + // Own side: fold the whole changed group's members only when the group itself + // changed (outputGroups). A peer-seeded or link-only group is not folded here — + // its siblings never refresh. The changed peers themselves are folded once, after + // the walk (see walk()). + for _, gID := range nearGroups { + if _, ok := r.outputGroups[gID]; ok { + r.affectedGroups[gID] = struct{}{} + } + } + + // When the changed side IS a destination, the resources it targets are reached + // through their network's routers, so those routers refresh too (e.g. attaching a + // resource to a destination group, or a changed destination group/resource). + if side == sideDestination { + r.foldRoutersForResources(r.ruleDestinationResourceIDs(rule)) + } +} + +// foldPolicySideForRule folds one side of a single rule (groups + direct peer), and +// for a destination side the routers of that rule's destination resources. +func (r *resolver) foldPolicySideForRule(policy *types.Policy, rule *types.PolicyRule, side policySide) { + addAll(r.affectedGroups, ruleSideGroups(rule, side)) + res := ruleSideResource(rule, side) + if res.Type == types.ResourceTypePeer && res.ID != "" { + r.affectedPeers[res.ID] = struct{}{} + } + if side == sideDestination { + r.foldRoutersForResources(r.ruleDestinationResourceIDs(rule)) + } +} + +// collectFromChangedRoutes folds an explicitly changed route's own groups and peer. +func (r *resolver) collectFromChangedRoutes(routes []*route.Route) { for _, rt := range routes { if rt == nil { continue } - log.WithContext(r.ctx).Tracef("collectFromExplicitRoutes: changed route %s -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q", + log.WithContext(r.ctx).Tracef("collectFromChangedRoutes: changed route %s -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q", rt.ID, rt.Groups, rt.PeerGroups, rt.AccessControlGroups, rt.Peer) - addAll(r.groupSet, rt.Groups, rt.PeerGroups, rt.AccessControlGroups) + addAll(r.affectedGroups, rt.Groups, rt.PeerGroups, rt.AccessControlGroups) if rt.Peer != "" { - r.peerSet[rt.Peer] = struct{}{} + r.affectedPeers[rt.Peer] = struct{}{} } } } -// collectFromExplicitRouters folds changed routers' peers and marks their networks -// for the bridge. Passing the old router keeps a repointed router's previous peers -// affected without a post-commit read. -func (r *resolver) collectFromExplicitRouters(routers []*routerTypes.NetworkRouter) { +// collectFromChangedRouters: a changed router refreshes its OWN backing peer/groups +// (the changed entity) and the SOURCE side of every policy reaching a resource on +// its network (the router serves the whole network). Sibling routers on the network +// are independent and are NOT folded. Passing the old router state keeps a repointed +// router's previous backing affected without a post-commit read. +func (r *resolver) collectFromChangedRouters(routers []*routerTypes.NetworkRouter) { for _, router := range routers { if router == nil { continue } - log.WithContext(r.ctx).Tracef("collectFromExplicitRouters: changed router %s on network %s -> folding peerGroups=%v peer=%q and marking network for source bridge", + log.WithContext(r.ctx).Tracef("collectFromChangedRouters: changed router %s on network %s -> folding its own peerGroups=%v peer=%q + sources reaching network resources", router.ID, router.NetworkID, router.PeerGroups, router.Peer) - addAll(r.groupSet, router.PeerGroups) + addAll(r.affectedGroups, router.PeerGroups) if router.Peer != "" { - r.peerSet[router.Peer] = struct{}{} + r.affectedPeers[router.Peer] = struct{}{} } if router.NetworkID != "" { - r.networkIDs[router.NetworkID] = struct{}{} + r.foldPolicySourcesForResources(r.networkResourceIDs(router.NetworkID)) } } } -// collectFromExplicitResources marks changed resources' networks for the bridge and -// treats their group IDs as changed, so policies targeting the resource via a -// now-detached (old) group still refresh. -func (r *resolver) collectFromExplicitResources(resources []*resourceTypes.NetworkResource) { +// collectFromChangedResources: a changed resource refreshes the SOURCE side of the +// policies targeting EXACTLY that resource — directly, or via one of the resource's +// own groups (old∪new across the change, so a now-detached group's sources still +// refresh) — plus the routers serving its network (the resource is reached through +// them). It does not touch sibling resources on the same network. +func (r *resolver) collectFromChangedResources(resources []*resourceTypes.NetworkResource) { for _, resource := range resources { if resource == nil { continue } - log.WithContext(r.ctx).Tracef("collectFromExplicitResources: changed resource %s on network %s -> marking network for bridge and treating groups %v as changed", + log.WithContext(r.ctx).Tracef("collectFromChangedResources: changed resource %s on network %s (groups %v) -> folding sources of policies targeting it + its network's routers", resource.ID, resource.NetworkID, resource.GroupIDs) - addAll(r.changedGroupSet, resource.GroupIDs) + r.foldPolicySourcesForResource(resource.ID, resource.GroupIDs) if resource.NetworkID != "" { - r.networkIDs[resource.NetworkID] = struct{}{} + r.foldRoutersOnNetworks(map[string]struct{}{resource.NetworkID: {}}) } } } -// collectFromExplicitNetworks marks changed networks for the bridge. A network has -// no groups/peers of its own. -func (r *resolver) collectFromExplicitNetworks(networks []*networkTypes.Network) { - for _, network := range networks { - if network == nil { +// foldPolicySourcesForResource folds the source side of every policy whose +// destination is the given resource — referenced directly, or via any of the given +// groups (the resource's own old∪new groups, which captures a detached group). +func (r *resolver) foldPolicySourcesForResource(resourceID string, groupIDs []string) { + groups := toSet(groupIDs) + for _, policy := range r.policies() { + if !policyTargetsResourceOrGroups(policy, resourceID, groups) { continue } - log.WithContext(r.ctx).Tracef("collectFromExplicitNetworks: changed network %s -> marking for bridge", network.ID) - if network.ID != "" { - r.networkIDs[network.ID] = struct{}{} - } + log.WithContext(r.ctx).Tracef("foldPolicySourcesForResource: policy %s (%s) targets changed resource %s -> folding its source groups/peers", policy.ID, policy.Name, resourceID) + collectPolicySources(policy, r.affectedGroups, r.affectedPeers) } } -func (r *resolver) collectFromPostureChecks(postureCheckIDs []string) { - if len(postureCheckIDs) == 0 { +// policyTargetsResourceOrGroups reports whether a policy's destination is the given +// resource directly, or one of the given destination groups. +func policyTargetsResourceOrGroups(policy *types.Policy, resourceID string, groups map[string]struct{}) bool { + if policy == nil { + return false + } + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID == resourceID && resourceID != "" { + return true + } + if anyInSet(rule.Destinations, groups) { + return true + } + } + return false +} + +// collectFromChangedNetworks: a changed network refreshes the SOURCE side of the +// policies reaching any of its resources, plus its routers. A network has no +// groups/peers of its own. +func (r *resolver) collectFromChangedNetworks(networks []*networkTypes.Network) { + for _, network := range networks { + if network == nil || network.ID == "" { + continue + } + log.WithContext(r.ctx).Tracef("collectFromChangedNetworks: changed network %s -> folding sources reaching its resources + its routers", network.ID) + resourceIDs := r.networkResourceIDs(network.ID) + r.foldPolicySourcesForResources(resourceIDs) + r.foldRoutersOnNetworks(map[string]struct{}{network.ID: {}}) + } +} + +// foldPolicySourcesForResources folds the source groups/peers of every policy whose +// destination targets one of resourceIDs (directly or via a destination group). +func (r *resolver) foldPolicySourcesForResources(resourceIDs map[string]struct{}) { + if len(resourceIDs) == 0 { return } - ids := toSet(postureCheckIDs) for _, policy := range r.policies() { - if !policyReferencesPostureChecks(policy, ids) { - continue + if r.policyTargetsResources(policy, resourceIDs) { + log.WithContext(r.ctx).Tracef("foldPolicySourcesForResources: policy %s (%s) targets a changed resource -> folding its source groups/peers", policy.ID, policy.Name) + collectPolicySources(policy, r.affectedGroups, r.affectedPeers) } - log.WithContext(r.ctx).Tracef("collectFromPostureChecks: policy %s (%s) references changed posture checks %v -> folding rule groups %v + direct peers", - policy.ID, policy.Name, postureCheckIDs, policy.RuleGroups()) - addAll(r.groupSet, policy.RuleGroups()) - collectPolicyDirectPeers(policy, r.peerSet) - r.matchedPolicies = append(r.matchedPolicies, policy) - } -} - -func (r *resolver) collectFromPolicies() { - for _, policy := range r.policies() { - matchedByGroup := policyReferencesGroups(policy, r.changedGroupSet) - matchedByPeer := len(r.changedPeerSet) > 0 && policyReferencesDirectPeers(policy, r.changedPeerSet) - if !matchedByGroup && !matchedByPeer { - continue - } - log.WithContext(r.ctx).Tracef("collectFromPolicies: policy %s (%s) matched (byGroup=%t byPeer=%t) -> folding rule groups %v + direct peers", - policy.ID, policy.Name, matchedByGroup, matchedByPeer, policy.RuleGroups()) - addAll(r.groupSet, policy.RuleGroups()) - collectPolicyDirectPeers(policy, r.peerSet) - r.matchedPolicies = append(r.matchedPolicies, policy) } } +// collectFromRoutes folds, per matched route, the OPPOSITE side(s) fully and the +// matched side's own groups only on a whole-group change (outputGroups). A route has +// three peer sides — routing (Peer/PeerGroups), consumer (Groups) and ACL +// (AccessControlGroups) — that each refresh the others; the changed side's own group +// folds its siblings only when the group itself changed, never on a one-peer move. func (r *resolver) collectFromRoutes() { for _, rt := range r.snap.routes { - matchedByGroup := anyInSet(rt.Groups, r.changedGroupSet) || anyInSet(rt.PeerGroups, r.changedGroupSet) || anyInSet(rt.AccessControlGroups, r.changedGroupSet) - matchedByPeer := rt.Peer != "" && len(r.changedPeerSet) > 0 && isInSet(rt.Peer, r.changedPeerSet) - if !matchedByGroup && !matchedByPeer { + if !rt.Enabled { + continue // disabled routes route to nobody; skip existing account data + } + routing := anyInSet(rt.PeerGroups, r.linkGroups) || (rt.Peer != "" && isInSet(rt.Peer, r.changedPeers)) + consumer := anyInSet(rt.Groups, r.linkGroups) + acl := anyInSet(rt.AccessControlGroups, r.linkGroups) + if !routing && !consumer && !acl { continue } - log.WithContext(r.ctx).Tracef("collectFromRoutes: route %s matched (byGroup=%t byPeer=%t) -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q", - rt.ID, matchedByGroup, matchedByPeer, rt.Groups, rt.PeerGroups, rt.AccessControlGroups, rt.Peer) - addAll(r.groupSet, rt.Groups, rt.PeerGroups, rt.AccessControlGroups) - if rt.Peer != "" { - r.peerSet[rt.Peer] = struct{}{} + log.WithContext(r.ctx).Tracef("collectFromRoutes: route %s matched (routing=%t consumer=%t acl=%t) -> folding opposite sides; own side gated on outputGroups", + rt.ID, routing, consumer, acl) + r.foldRouteSide(rt.PeerGroups, routing) + r.foldRouteSide(rt.Groups, consumer) + r.foldRouteSide(rt.AccessControlGroups, acl) + // The single routing Peer folds when the routing side is the OPPOSITE of the + // match (consumer/acl need it), or when that very peer is the change. + if rt.Peer != "" && (consumer || acl || isInSet(rt.Peer, r.changedPeers)) { + r.affectedPeers[rt.Peer] = struct{}{} + } + } +} + +// foldRouteSide folds a route side: when this side is the one that matched, fold its +// groups only on a whole-group change (outputGroups) so siblings of a single moved +// peer stay put; otherwise it is an opposite side and folds fully. +func (r *resolver) foldRouteSide(groups []string, matchedHere bool) { + if matchedHere { + r.foldOutputGroups(groups) + return + } + addAll(r.affectedGroups, groups) +} + +// foldOutputGroups folds only the groups that the caller reported as wholly changed +// (outputGroups). Used for a matched object's OWN side, where a peer-seeded or +// link-only group must not pull in its siblings. +func (r *resolver) foldOutputGroups(groups ...[]string) { + for _, gs := range groups { + for _, gID := range gs { + if _, ok := r.outputGroups[gID]; ok { + r.affectedGroups[gID] = struct{}{} + } } } } func (r *resolver) collectFromNameServers() { - if len(r.changedGroupSet) == 0 { + if len(r.linkGroups) == 0 { return } for _, ns := range r.snap.nsGroups { - if anyInSet(ns.Groups, r.changedGroupSet) { - log.WithContext(r.ctx).Tracef("collectFromNameServers: nameserver group %s references a changed group -> folding its groups %v", ns.ID, ns.Groups) - addAll(r.groupSet, ns.Groups) + if anyInSet(ns.Groups, r.linkGroups) { + // A nameserver group has no opposite side: a peer's DNS config depends only + // on its own membership, so a one-peer move refreshes that peer alone (folded + // elsewhere). Fold the referenced groups only on a whole-group change. + log.WithContext(r.ctx).Tracef("collectFromNameServers: nameserver group %s references a linked group -> folding its groups %v (outputGroups only)", ns.ID, ns.Groups) + r.foldOutputGroups(ns.Groups) } } } func (r *resolver) collectFromDNSSettings() { - if len(r.changedGroupSet) == 0 || r.snap.dnsSettings == nil { + if len(r.linkGroups) == 0 || r.snap.dnsSettings == nil { return } for _, gID := range r.snap.dnsSettings.DisabledManagementGroups { - if _, ok := r.changedGroupSet[gID]; ok { + if _, ok := r.linkGroups[gID]; ok { log.WithContext(r.ctx).Tracef("collectFromDNSSettings: changed group %s is in DisabledManagementGroups -> folding it", gID) - r.groupSet[gID] = struct{}{} + r.affectedGroups[gID] = struct{}{} } } } +// collectFromNetworkRouters handles a changed group/peer that BACKS a router (the +// routing peer set moved): the router's own peers refresh and so do the sources of +// the policies reaching its network's resources. Sibling routers on the network are +// independent and are not folded. func (r *resolver) collectFromNetworkRouters() { for _, router := range r.networkRouters() { - matchedByGroup := anyInSet(router.PeerGroups, r.changedGroupSet) - matchedByPeer := router.Peer != "" && len(r.changedPeerSet) > 0 && isInSet(router.Peer, r.changedPeerSet) + matchedByGroup := anyInSet(router.PeerGroups, r.linkGroups) + matchedByPeer := router.Peer != "" && len(r.changedPeers) > 0 && isInSet(router.Peer, r.changedPeers) if !matchedByGroup && !matchedByPeer { continue } - log.WithContext(r.ctx).Tracef("collectFromNetworkRouters: router %s on network %s matched (byGroup=%t byPeer=%t) -> folding peerGroups=%v peer=%q and marking network for source bridge", + log.WithContext(r.ctx).Tracef("collectFromNetworkRouters: router %s on network %s matched (byGroup=%t byPeer=%t) -> folding its peerGroups=%v peer=%q (own groups on outputGroups) + sources reaching network resources", router.ID, router.NetworkID, matchedByGroup, matchedByPeer, router.PeerGroups, router.Peer) - addAll(r.groupSet, router.PeerGroups) + // The backing PeerGroups are the matched (own) side: fold them only on a + // whole-group change so a one-peer move does not wake sibling backing peers. The + // opposite side (policy sources reaching the network) is folded below. + r.foldOutputGroups(router.PeerGroups) if router.Peer != "" { - r.peerSet[router.Peer] = struct{}{} + r.affectedPeers[router.Peer] = struct{}{} + } + if router.NetworkID != "" { + r.foldPolicySourcesForResources(r.networkResourceIDs(router.NetworkID)) } - r.networkIDs[router.NetworkID] = struct{}{} } } @@ -526,42 +827,48 @@ func (r *resolver) collectFromProxyServices() { expanded := r.expandChangedPeersWithGroups() for _, svc := range services { - if svc == nil { - continue + if svc == nil || !svc.Enabled { + continue // a disabled service proxies nothing; skip existing account data } proxyPeers := proxyByCluster[svc.ProxyCluster] if len(proxyPeers) == 0 { continue } matchedByPeer := serviceMatchesChangedPeers(svc, proxyPeers, expanded) - matchedByAccessGroup := anyInSet(svc.AccessGroups, r.changedGroupSet) + matchedByAccessGroup := anyInSet(svc.AccessGroups, r.linkGroups) if !matchedByPeer && !matchedByAccessGroup { continue } - log.WithContext(r.ctx).Tracef("collectFromProxyServices: service %s (cluster=%s) matched (byProxyOrTargetPeer=%t byAccessGroup=%t) -> folding %d proxy peers, peer targets and access groups %v", + log.WithContext(r.ctx).Tracef("collectFromProxyServices: service %s (cluster=%s) matched (byProxyOrTargetPeer=%t byAccessGroup=%t) -> folding %d proxy peers, peer targets; access groups %v on outputGroups only", svc.ID, svc.ProxyCluster, matchedByPeer, matchedByAccessGroup, len(proxyPeers), svc.AccessGroups) for _, pid := range proxyPeers { - r.peerSet[pid] = struct{}{} + r.affectedPeers[pid] = struct{}{} } for _, target := range svc.Targets { + if !target.Enabled { + continue // a disabled target forwards nothing + } if target.TargetType == rpservice.TargetTypePeer && target.TargetId != "" { - r.peerSet[target.TargetId] = struct{}{} + r.affectedPeers[target.TargetId] = struct{}{} } } - addAll(r.groupSet, svc.AccessGroups) + // AccessGroups are the matched (own) side with no opposite to fold: a member's + // proxy access is self-contained, so a one-peer move refreshes that peer alone. + // Fold the groups only on a whole-group change. + r.foldOutputGroups(svc.AccessGroups) } } func (r *resolver) expandChangedPeersWithGroups() map[string]struct{} { - if len(r.changedGroupSet) == 0 { - return r.changedPeerSet + if len(r.linkGroups) == 0 { + return r.changedPeers } - ids := r.peerIDsForGroups(r.changedGroupSet) + ids := r.peerIDsForGroups(r.linkGroups) if len(ids) == 0 { - return r.changedPeerSet + return r.changedPeers } - merged := make(map[string]struct{}, len(r.changedPeerSet)+len(ids)) - for id := range r.changedPeerSet { + merged := make(map[string]struct{}, len(r.changedPeers)+len(ids)) + for id := range r.changedPeers { merged[id] = struct{}{} } for _, id := range ids { @@ -570,54 +877,36 @@ func (r *resolver) expandChangedPeersWithGroups() map[string]struct{} { return merged } -// collectResourceRouterBridge crosses between source peers and routing peers, which -// are reachable only via resource -> network -> router, not through the policy's own -// groups: source -> router (targeted resources' networks), then router -> source. -func (r *resolver) collectResourceRouterBridge() { - r.bridgeSourceToRouters() - r.bridgeRoutersToSources() -} - -func (r *resolver) bridgeSourceToRouters() { - resourceIDs := r.policyDestinationResourceIDs(r.matchedPolicies...) +// foldRoutersForResources folds the routers serving the networks of the given +// resources (a destination resource is reached through its network's routers). It is +// the resource -> network -> router hop used by foldPolicySide for a destination. +func (r *resolver) foldRoutersForResources(resourceIDs map[string]struct{}) { if len(resourceIDs) == 0 { return } - - networkIDs := r.resourceNetworkIDs(resourceIDs) - log.WithContext(r.ctx).Tracef("bridgeSourceToRouters: targeted resources %v -> networks %v (their routers become affected via the router->source pass)", - setToSlice(resourceIDs), setToSlice(networkIDs)) - for id := range networkIDs { - r.networkIDs[id] = struct{}{} - } + r.foldRoutersOnNetworks(r.resourceNetworkIDs(resourceIDs)) } -func (r *resolver) bridgeRoutersToSources() { - if len(r.networkIDs) == 0 { - return +// ruleDestinationResourceIDs returns the destination resource IDs of a single rule: +// the direct DestinationResource plus the resources of its destination groups. +func (r *resolver) ruleDestinationResourceIDs(rule *types.PolicyRule) map[string]struct{} { + resourceIDs := make(map[string]struct{}) + if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID != "" { + resourceIDs[rule.DestinationResource.ID] = struct{}{} } + r.addGroupResourceIDs(toSet(rule.Destinations), resourceIDs) + return resourceIDs +} - log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: affected networks %v -> folding their routing peers and the source peers of policies targeting their resources", - setToSlice(r.networkIDs)) - - r.foldRoutersOnNetworks(r.networkIDs) - +// networkResourceIDs returns the IDs of all resources on the given network. +func (r *resolver) networkResourceIDs(networkID string) map[string]struct{} { resourceIDs := make(map[string]struct{}) for _, resource := range r.networkResources() { - if _, ok := r.networkIDs[resource.NetworkID]; ok { + if resource.NetworkID == networkID { resourceIDs[resource.ID] = struct{}{} } } - if len(resourceIDs) == 0 { - return - } - - for _, policy := range r.policies() { - if r.policyTargetsResources(policy, resourceIDs) { - log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: policy %s (%s) targets an affected-network resource -> folding its source groups/peers", policy.ID, policy.Name) - collectPolicySources(policy, r.groupSet, r.peerSet) - } - } + return resourceIDs } func (r *resolver) foldRoutersOnNetworks(networkIDs map[string]struct{}) { @@ -627,9 +916,9 @@ func (r *resolver) foldRoutersOnNetworks(networkIDs map[string]struct{}) { } log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: router %s serves affected network %s -> folding peerGroups=%v peer=%q", router.ID, router.NetworkID, router.PeerGroups, router.Peer) - addAll(r.groupSet, router.PeerGroups) + addAll(r.affectedGroups, router.PeerGroups) if router.Peer != "" { - r.peerSet[router.Peer] = struct{}{} + r.affectedPeers[router.Peer] = struct{}{} } } } @@ -650,6 +939,9 @@ func (r *resolver) policyTargetsResources(policy *types.Policy, resourceIDs map[ } destGroupSet := make(map[string]struct{}) for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } if rule.DestinationResource.Type != types.ResourceTypePeer && isInSet(rule.DestinationResource.ID, resourceIDs) { return true } @@ -714,44 +1006,20 @@ func (r *resolver) addGroupResourceIDs(groupIDs map[string]struct{}, resourceIDs } } -func collectPolicyDirectPeers(policy *types.Policy, peerSet map[string]struct{}) { +// collectPolicySources folds the source groups/peers of a snapshot policy's enabled +// rules (a disabled rule grants no access). +func collectPolicySources(policy *types.Policy, groups, peers map[string]struct{}) { for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + addAll(groups, rule.Sources) if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" { - peerSet[rule.SourceResource.ID] = struct{}{} - } - if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" { - peerSet[rule.DestinationResource.ID] = struct{}{} + peers[rule.SourceResource.ID] = struct{}{} } } } -func collectPolicySources(policy *types.Policy, groupSet, peerSet map[string]struct{}) { - for _, rule := range policy.Rules { - addAll(groupSet, rule.Sources) - if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" { - peerSet[rule.SourceResource.ID] = struct{}{} - } - } -} - -func policyReferencesGroups(policy *types.Policy, groupSet map[string]struct{}) bool { - for _, rule := range policy.Rules { - if anyInSet(rule.Sources, groupSet) || anyInSet(rule.Destinations, groupSet) { - return true - } - } - return false -} - -func policyReferencesDirectPeers(policy *types.Policy, changedSet map[string]struct{}) bool { - for _, rule := range policy.Rules { - if isDirectPeerInSet(rule.SourceResource, changedSet) || isDirectPeerInSet(rule.DestinationResource, changedSet) { - return true - } - } - return false -} - func policyReferencesPostureChecks(policy *types.Policy, ids map[string]struct{}) bool { for _, id := range policy.SourcePostureChecks { if _, ok := ids[id]; ok { @@ -776,7 +1044,7 @@ func serviceMatchesChangedPeers(svc *rpservice.Service, proxyPeers []string, cha } } for _, target := range svc.Targets { - if target.TargetType != rpservice.TargetTypePeer || target.TargetId == "" { + if !target.Enabled || target.TargetType != rpservice.TargetTypePeer || target.TargetId == "" { continue } if _, ok := changedPeers[target.TargetId]; ok { diff --git a/management/server/affectedpeers/resolver_test.go b/management/server/affectedpeers/resolver_test.go index dcd304a56..fe6ada347 100644 --- a/management/server/affectedpeers/resolver_test.go +++ b/management/server/affectedpeers/resolver_test.go @@ -10,8 +10,8 @@ import ( "github.com/netbirdio/netbird/management/server/types" ) -// policyGroupsAndPeers mirrors the explicit-policy extraction (RuleGroups + -// direct peers) the resolver folds in, for asserting the pure logic. +// policyGroupsAndPeers mirrors the both-sides extraction (RuleGroups + direct peers) +// the resolver folds in for a changed policy, for asserting the pure logic. func policyGroupsAndPeers(policies ...*types.Policy) (groups []string, peers []string) { peerSet := map[string]struct{}{} for _, p := range policies { @@ -19,7 +19,14 @@ func policyGroupsAndPeers(policies ...*types.Policy) (groups []string, peers []s continue } groups = append(groups, p.RuleGroups()...) - collectPolicyDirectPeers(p, peerSet) + for _, rule := range p.Rules { + if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" { + peerSet[rule.SourceResource.ID] = struct{}{} + } + if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" { + peerSet[rule.DestinationResource.ID] = struct{}{} + } + } } for id := range peerSet { peers = append(peers, id) @@ -80,26 +87,6 @@ func TestChangeIsEmpty(t *testing.T) { assert.False(t, Change{PostureCheckIDs: []string{"pc"}}.isEmpty()) } -func TestPolicyReferencesGroups(t *testing.T) { - policy := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g1", "g2"}, Destinations: []string{"g3"}}}} - - assert.True(t, policyReferencesGroups(policy, map[string]struct{}{"g1": {}})) - assert.True(t, policyReferencesGroups(policy, map[string]struct{}{"g3": {}})) - assert.False(t, policyReferencesGroups(policy, map[string]struct{}{"g4": {}})) - assert.False(t, policyReferencesGroups(policy, map[string]struct{}{})) -} - -func TestPolicyReferencesDirectPeers(t *testing.T) { - policy := &types.Policy{Rules: []*types.PolicyRule{{ - SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"}, - DestinationResource: types.Resource{Type: types.ResourceTypeHost, ID: "r1"}, - }}} - - assert.True(t, policyReferencesDirectPeers(policy, map[string]struct{}{"p1": {}})) - assert.False(t, policyReferencesDirectPeers(policy, map[string]struct{}{"r1": {}})) - assert.False(t, policyReferencesDirectPeers(policy, map[string]struct{}{"p2": {}})) -} - func TestPolicyReferencesPostureChecks(t *testing.T) { policy := &types.Policy{SourcePostureChecks: []string{"pc1", "pc2"}} @@ -107,24 +94,9 @@ func TestPolicyReferencesPostureChecks(t *testing.T) { assert.False(t, policyReferencesPostureChecks(policy, map[string]struct{}{"pc3": {}})) } -func TestCollectPolicyDirectPeers(t *testing.T) { - policy := &types.Policy{Rules: []*types.PolicyRule{{ - SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"}, - DestinationResource: types.Resource{Type: types.ResourceTypePeer, ID: "p2"}, - }, { - DestinationResource: types.Resource{Type: types.ResourceTypeHost, ID: "r1"}, - }}} - - peerSet := map[string]struct{}{} - collectPolicyDirectPeers(policy, peerSet) - - assert.Contains(t, peerSet, "p1") - assert.Contains(t, peerSet, "p2") - assert.NotContains(t, peerSet, "r1") -} - func TestCollectPolicySources(t *testing.T) { policy := &types.Policy{Rules: []*types.PolicyRule{{ + Enabled: true, Sources: []string{"g1"}, SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"}, Destinations: []string{"g2"}, diff --git a/management/server/group.go b/management/server/group.go index 95ca85733..1cbf88315 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -539,7 +539,12 @@ func collectDeletableGroups(ctx context.Context, transaction store.Store, accoun // GroupAddPeer appends peer to the group func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, groupID, peerID string) error { var snap *affectedpeers.Snapshot - change := affectedpeers.Change{ChangedGroupIDs: []string{groupID}} + // A membership change affects only the peer itself and the opposite side of THIS + // group's policies — not the group's other members, and not the peer's other + // groups. LinkGroups walks only this group (matched, not expanded); OutputPeerIDs + // refreshes the peer without seeding its other group memberships. For an + // intra-group policy the opposite side is the group, so its members still refresh. + change := affectedpeers.Change{OutputPeerIDs: []string{peerID}, LinkGroups: []string{groupID}} err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil { @@ -605,10 +610,11 @@ func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID // GroupDeletePeer removes peer from the group func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, groupID, peerID string) error { var snap *affectedpeers.Snapshot - change := affectedpeers.Change{ - ChangedGroupIDs: []string{groupID}, - RemovedPeersByGroup: map[string][]string{groupID: {peerID}}, - } + // Same as GroupAddPeer: the removed peer and the opposite side of THIS group's + // policies refresh, not the group's other members or the peer's other groups. The + // peer is no longer in the group's index, but LinkGroups still drives the + // opposite-side walk, and OutputPeerIDs refreshes the removed peer itself. + change := affectedpeers.Change{OutputPeerIDs: []string{peerID}, LinkGroups: []string{groupID}} err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err := transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil { @@ -619,8 +625,6 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, return err } - // The removed peer is carried in change.RemovedPeersByGroup and folded in - // only when the group is linked, so loading post-removal is correct. var err error if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err diff --git a/management/server/http/handlers/proxy/auth_callback_integration_test.go b/management/server/http/handlers/proxy/auth_callback_integration_test.go index f08d5daf1..a24857066 100644 --- a/management/server/http/handlers/proxy/auth_callback_integration_test.go +++ b/management/server/http/handlers/proxy/auth_callback_integration_test.go @@ -217,6 +217,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup { usersManager, nil, nil, + nil, ) proxyService.SetServiceManager(&testServiceManager{store: testStore}) diff --git a/management/server/http/handlers/users/users_handler.go b/management/server/http/handlers/users/users_handler.go index 40ad585d2..179d433f1 100644 --- a/management/server/http/handlers/users/users_handler.go +++ b/management/server/http/handlers/users/users_handler.go @@ -220,7 +220,7 @@ func (h *handler) getAllUsers(w http.ResponseWriter, r *http.Request) { } includeServiceUser, err := strconv.ParseBool(serviceUser) - log.WithContext(r.Context()).Debugf("Should include service user: %v", includeServiceUser) + log.WithContext(r.Context()).Tracef("Should include service user: %v", includeServiceUser) if err != nil { util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid service_user query parameter"), w) return diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 8da9c7ad4..61584a615 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -110,7 +110,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee if err != nil { t.Fatalf("Failed to create proxy manager: %v", err) } - proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, proxyMgr, nil) + proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil) domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am) serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter) if err != nil { @@ -240,7 +240,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin if err != nil { t.Fatalf("Failed to create proxy manager: %v", err) } - proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, proxyMgr, nil) + proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil) domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am) serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter) if err != nil { diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index f81139f24..071e3771b 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -39,7 +39,7 @@ type MockAccountManager struct { GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error) GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) - MarkPeerConnectedFunc func(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error + MarkPeerConnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error MarkPeerDisconnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) DeletePeerFunc func(ctx context.Context, accountID, peerKey, userID string) error @@ -114,7 +114,7 @@ type MockAccountManager struct { GetIdpManagerFunc func() idp.Manager UpdateIntegratedValidatorFunc func(ctx context.Context, accountID, userID, validator string, groups []string) error GroupValidationFunc func(ctx context.Context, accountId string, groups []string) (bool, error) - SyncPeerMetaFunc func(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error + SyncPeerMetaFunc func(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error FindExistingPostureCheckFunc func(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) GetAccountIDForPeerKeyFunc func(ctx context.Context, peerKey string) (string, error) GetAccountByIDFunc func(ctx context.Context, accountID string, userID string) (*types.Account, error) @@ -345,9 +345,9 @@ func (am *MockAccountManager) GetAccountIDByUserID(ctx context.Context, userAuth } // MarkPeerConnected mock implementation of MarkPeerConnected from server.AccountManager interface -func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { +func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { if am.MarkPeerConnectedFunc != nil { - return am.MarkPeerConnectedFunc(ctx, peerKey, realIP, accountID, sessionStartedAt, nmap) + return am.MarkPeerConnectedFunc(ctx, peerKey, accountID, sessionStartedAt, nmap) } return status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented") } @@ -975,9 +975,9 @@ func (am *MockAccountManager) GroupValidation(ctx context.Context, accountId str } // SyncPeerMeta mocks SyncPeerMeta of the AccountManager interface -func (am *MockAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error { +func (am *MockAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error { if am.SyncPeerMetaFunc != nil { - return am.SyncPeerMetaFunc(ctx, peerPubKey, meta) + return am.SyncPeerMetaFunc(ctx, peerPubKey, meta, realIP) } return status.Errorf(codes.Unimplemented, "method SyncPeerMeta is not implemented") } diff --git a/management/server/peer.go b/management/server/peer.go index 9d78f597b..440e90044 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -74,7 +74,7 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID // // Disconnects use MarkPeerDisconnected and require the session to match // exactly; see PeerStatus.SessionStartedAt for the protocol. -func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { +func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { start := time.Now() defer func() { am.metrics.AccountManagerMetrics().RecordPeerStatusUpdateDuration(telemetry.PeerStatusConnect, time.Since(start)) @@ -102,10 +102,6 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK } am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusApplied) - if am.geo != nil && realIP != nil { - am.updatePeerLocationIfChanged(ctx, accountID, peer, realIP) - } - if err = am.schedulePeerExpirations(ctx, accountID, peer); err != nil { return err } @@ -192,27 +188,40 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP } } + if peer.AddedWithSSOLogin() && peer.InactivityExpirationEnabled { + settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + log.WithContext(ctx).Warnf("failed getting account settings to schedule inactivity expiration for peer %s: %v", peer.ID, err) + } else if settings.PeerInactivityExpirationEnabled { + am.checkAndSchedulePeerInactivityExpiration(ctx, accountID) + } + } + return nil } -// updatePeerLocationIfChanged refreshes the geolocation on a separate -// row update, only when the connection IP actually changed. Geo lookups -// are expensive so we skip same-IP reconnects. -func (am *DefaultAccountManager) updatePeerLocationIfChanged(ctx context.Context, accountID string, peer *nbpeer.Peer, realIP net.IP) { - if peer.Location.ConnectionIP != nil && peer.Location.ConnectionIP.Equal(realIP) { - return +// resolvePeerLocation looks up the geo location for realIP, returning nil when +// there is nothing to apply: geo disabled, no real IP, the IP is unchanged from +// what the peer already has, or the lookup failed. Geo lookups are skipped on +// same-IP reconnects since they are comparatively expensive. The returned value +// is applied by Peer.UpdateMetaIfNew so the change is persisted by its peer save. +func (am *DefaultAccountManager) resolvePeerLocation(ctx context.Context, peer *nbpeer.Peer, realIP net.IP) *nbpeer.Location { + if am.geo == nil || realIP == nil { + return nil } location, err := am.geo.Lookup(realIP) if err != nil { log.WithContext(ctx).Warnf("failed to get location for peer %s realip: [%s]: %v", peer.ID, realIP.String(), err) - return + return nil } - peer.Location.ConnectionIP = realIP - peer.Location.CountryCode = location.Country.ISOCode - peer.Location.CityName = location.City.Names.En - peer.Location.GeoNameID = location.City.GeonameID - if err := am.Store.SavePeerLocation(ctx, accountID, peer); err != nil { - log.WithContext(ctx).Warnf("could not store location for peer %s: %s", peer.ID, err) + if peer.Location.ConnectionIP != nil && peer.Location.ConnectionIP.Equal(realIP) && peer.Location.GeoNameID == location.City.GeonameID { + return nil + } + return &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: location.Country.ISOCode, + CityName: location.City.Names.En, + GeoNameID: location.City.GeonameID, } } @@ -721,7 +730,7 @@ func (am *DefaultAccountManager) handleSetupKeyAddedPeer(ctx context.Context, en func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { if setupKey == "" && userID == "" && !peer.ProxyMeta.Embedded { // no auth method provided => reject access - return nil, nil, nil, false, status.Errorf(status.Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login") + return nil, nil, nil, false, status.ErrNoAuthMethodProvided } upperKey := strings.ToUpper(setupKey) @@ -980,10 +989,9 @@ func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) { // SyncPeer checks whether peer is eligible for receiving NetworkMap (authenticated) and returns its NetworkMap if eligible func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { var peer *nbpeer.Peer - var updated, versionChanged, ipv6CapabilityChanged bool + var ipv6CapabilityChanged bool + var metaDiff nbpeer.MetaDiff var err error - var postureChecks []*posture.Checks - var peerGroupIDs []string settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { @@ -1011,25 +1019,16 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return status.NewPeerLoginExpiredError() } - peerGroupIDs, err = getPeerGroupIDs(ctx, transaction, accountID, peer.ID) - if err != nil { - return err - } - oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) - updated, versionChanged = peer.UpdateMetaIfNew(sync.Meta) + newLocation := am.resolvePeerLocation(ctx, peer, sync.RealIP) + metaDiff = peer.UpdateMetaIfNew(ctx, sync.Meta, newLocation) ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) - if updated { + if metaDiff.Updated() { am.metrics.AccountManagerMetrics().CountPeerMetUpdate() log.WithContext(ctx).Tracef("peer %s metadata updated", peer.ID) if err = transaction.SavePeer(ctx, accountID, peer); err != nil { return err } - - postureChecks, err = getPeerPostureChecks(ctx, transaction, accountID, peer.ID) - if err != nil { - return err - } } return nil }) @@ -1037,6 +1036,11 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return nil, nil, nil, 0, err } + peerGroupIDs, err := getPeerGroupIDs(ctx, am.Store, accountID, peer.ID) + if err != nil { + return nil, nil, nil, 0, err + } + peerNotValid, isStatusChanged, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra) if err != nil { return nil, nil, nil, 0, err @@ -1047,9 +1051,10 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return nil, nil, nil, 0, err } - if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || (updated && (len(postureChecks) > 0 || versionChanged)) { + metaDiffAffectsPosture := posture.AffectsPosture(ctx, &metaDiff, resPostureChecks) + if requiresPeerUpdate(ctx, isStatusChanged, sync.UpdateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, metaDiff.VersionChanged(), metaDiff.HostnameChanged()) { changedPeerIDs := []string{peer.ID} - affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, updated, len(postureChecks) > 0) + affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, metaDiffAffectsPosture) if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { return nil, nil, nil, 0, fmt.Errorf("notify network map controller of peer update: %w", err) } @@ -1058,6 +1063,29 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return peer, nmap, resPostureChecks, dnsFwdPort, nil } +func requiresPeerUpdate(ctx context.Context, isStatusChanged, updateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, versionChanged, hostname bool) bool { + var reason string + switch { + case isStatusChanged: + reason = "status changed" + case updateAccountPeers: + reason = "update account peers" + case ipv6CapabilityChanged: + reason = "ipv6 capability changed" + case metaDiffAffectsPosture: + reason = "meta diff affects posture" + case versionChanged: + reason = "version changed" + case hostname: + reason = "hostname changed" + default: + return false + } + + log.WithContext(ctx).Tracef("peer update required: %s", reason) + return true +} + // syncPeerAffectedPeers resolves the peers affected by a SyncPeer change. The // peer's own validated network map is bidirectional for policy and routing // reachability, so when the peer stays valid and no source-posture gate is in @@ -1066,8 +1094,8 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy // metadata change that flips a posture result removes this peer from others' // maps asymmetrically; that case (and an invalid peer, whose map is empty) falls // back to the resolver. -func (am *DefaultAccountManager) syncPeerAffectedPeers(ctx context.Context, accountID, peerID string, nmap *types.NetworkMap, peerNotValid, metaUpdated, hasPostureChecks bool) []string { - if peerNotValid || (metaUpdated && hasPostureChecks) { +func (am *DefaultAccountManager) syncPeerAffectedPeers(ctx context.Context, accountID, peerID string, nmap *types.NetworkMap, peerNotValid, metaChangeAffectedPosture bool) []string { + if peerNotValid || metaChangeAffectedPosture { return am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, []string{peerID}) } return affectedPeerIDsFromNetworkMap(nmap, peerID) @@ -1124,7 +1152,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer } var peer *nbpeer.Peer - var shouldStorePeer bool + var shouldStorePeer, shouldUpdatePeers bool var peerGroupIDs []string settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) @@ -1151,14 +1179,10 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer if changed { shouldStorePeer = true + shouldUpdatePeers = true } } - peerGroupIDs, err = getPeerGroupIDs(ctx, transaction, accountID, peer.ID) - if err != nil { - return err - } - if peer.SSHKey != login.SSHKey { peer.SSHKey = login.SSHKey shouldStorePeer = true @@ -1180,7 +1204,15 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return nil, nil, nil, false, err } - isRequiresApproval, isStatusChanged, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra) + // This is needed to keep in memory for the peer config. Otherwise browser client will end in a retry loop + peer.Meta = login.Meta + + peerGroupIDs, err = getPeerGroupIDs(ctx, am.Store, accountID, peer.ID) + if err != nil { + return nil, nil, nil, false, err + } + + isRequiresApproval, _, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra) if err != nil { return nil, nil, nil, false, err } @@ -1190,7 +1222,7 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return nil, nil, nil, false, err } - if isStatusChanged || shouldStorePeer { + if shouldUpdatePeers { changedPeerIDs := []string{peer.ID} affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { @@ -1286,12 +1318,22 @@ func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID st return network, nil, false, nil } - postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peer.ID) + policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) if err != nil { return nil, nil, false, err } - enableSSH, err := isPeerSSHEnabled(ctx, transaction, accountID, peer) + peerGroupIDs, err := transaction.GetPeerGroupIDs(ctx, store.LockingStrengthNone, accountID, peer.ID) + if err != nil { + return nil, nil, false, err + } + + postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peerGroupIDs, policies) + if err != nil { + return nil, nil, false, err + } + + enableSSH, err := isPeerSSHEnabled(ctx, peer, policies, peerGroupIDs) if err != nil { return nil, nil, false, err } @@ -1299,32 +1341,16 @@ func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID st return network, postureChecks, enableSSH, nil } -func isPeerSSHEnabled(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer) (bool, error) { - policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - if err != nil { - return false, err +func isPeerSSHEnabled(ctx context.Context, peer *nbpeer.Peer, policies []*types.Policy, peerGroupIDs []string) (bool, error) { + groupIDsMap := make(map[string]struct{}, len(peerGroupIDs)) + for _, peerID := range peerGroupIDs { + groupIDsMap[peerID] = struct{}{} } - - peerGroups, err := transaction.GetPeerGroups(ctx, store.LockingStrengthNone, accountID, peer.ID) - if err != nil { - return false, err - } - - peerGroupIDs := make(map[string]struct{}, len(peerGroups)) - for _, g := range peerGroups { - peerGroupIDs[g.ID] = struct{}{} - } - - return types.PeerSSHEnabledFromPolicies(policies, peer.ID, peerGroupIDs, peer.SSHEnabled), nil + return types.PeerSSHEnabledFromPolicies(policies, peer.ID, groupIDsMap, peer.SSHEnabled), nil } // getPeerPostureChecks returns the posture checks for the peer. -func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID, peerID string) ([]*posture.Checks, error) { - policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - if err != nil { - return nil, err - } - +func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID string, peerGroupIDs []string, policies []*types.Policy) ([]*posture.Checks, error) { if len(policies) == 0 { return nil, nil } @@ -1336,11 +1362,7 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI continue } - postureChecksIDs, err := processPeerPostureChecks(ctx, transaction, policy, accountID, peerID) - if err != nil { - return nil, err - } - + postureChecksIDs := processPeerPostureChecks(policy, peerGroupIDs) peerPostureChecksIDs = append(peerPostureChecksIDs, postureChecksIDs...) } @@ -1353,29 +1375,19 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI } // processPeerPostureChecks checks if the peer is in the source group of the policy and returns the posture checks. -func processPeerPostureChecks(ctx context.Context, transaction store.Store, policy *types.Policy, accountID, peerID string) ([]string, error) { +func processPeerPostureChecks(policy *types.Policy, peerGroupIDs []string) []string { for _, rule := range policy.Rules { if !rule.Enabled { continue } - sourceGroups, err := transaction.GetGroupsByIDs(ctx, store.LockingStrengthNone, accountID, rule.Sources) - if err != nil { - return nil, err - } - for _, sourceGroup := range rule.Sources { - group, ok := sourceGroups[sourceGroup] - if !ok { - return nil, fmt.Errorf("failed to check peer in policy source group") - } - - if slices.Contains(group.Peers, peerID) { - return policy.SourcePostureChecks, nil + if slices.Contains(peerGroupIDs, sourceGroup) { + return policy.SourcePostureChecks } } } - return nil, nil + return nil } // checkIFPeerNeedsLoginWithoutLock checks if the peer needs login without acquiring the account lock. The check validate if the peer was not added via SSO diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index a5fbdbd9a..3b157d15b 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -1,12 +1,16 @@ package peer import ( + "context" + "fmt" "net" "net/netip" "slices" - "sort" + "strings" "time" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/shared/management/http/api" ) @@ -104,6 +108,15 @@ type Location struct { GeoNameID uint // city level geoname id } +// equal reports whether two locations match. ConnectionIP is a net.IP slice, so it uses +// IP.Equal, not ==. +func (l Location) equal(other Location) bool { + return l.CountryCode == other.CountryCode && + l.CityName == other.CityName && + l.GeoNameID == other.GeoNameID && + l.ConnectionIP.Equal(other.ConnectionIP) +} + // NetworkAddress is the IP address with network and MAC address of a network interface type NetworkAddress struct { NetIP netip.Prefix `gorm:"serializer:json"` @@ -163,49 +176,7 @@ type PeerSystemMeta struct { //nolint:revive } func (p PeerSystemMeta) isEqual(other PeerSystemMeta) bool { - sort.Slice(p.NetworkAddresses, func(i, j int) bool { - return p.NetworkAddresses[i].Mac < p.NetworkAddresses[j].Mac - }) - sort.Slice(other.NetworkAddresses, func(i, j int) bool { - return other.NetworkAddresses[i].Mac < other.NetworkAddresses[j].Mac - }) - equalNetworkAddresses := slices.EqualFunc(p.NetworkAddresses, other.NetworkAddresses, func(addr NetworkAddress, oAddr NetworkAddress) bool { - return addr.Mac == oAddr.Mac && addr.NetIP == oAddr.NetIP - }) - if !equalNetworkAddresses { - return false - } - - sort.Slice(p.Files, func(i, j int) bool { - return p.Files[i].Path < p.Files[j].Path - }) - sort.Slice(other.Files, func(i, j int) bool { - return other.Files[i].Path < other.Files[j].Path - }) - equalFiles := slices.EqualFunc(p.Files, other.Files, func(file File, oFile File) bool { - return file.Path == oFile.Path && file.Exist == oFile.Exist && file.ProcessIsRunning == oFile.ProcessIsRunning - }) - if !equalFiles { - return false - } - - return p.Hostname == other.Hostname && - p.GoOS == other.GoOS && - p.Kernel == other.Kernel && - p.KernelVersion == other.KernelVersion && - p.Core == other.Core && - p.Platform == other.Platform && - p.OS == other.OS && - p.OSVersion == other.OSVersion && - p.WtVersion == other.WtVersion && - p.UIVersion == other.UIVersion && - p.SystemSerialNumber == other.SystemSerialNumber && - p.SystemProductName == other.SystemProductName && - p.SystemManufacturer == other.SystemManufacturer && - p.Environment.Cloud == other.Environment.Cloud && - p.Environment.Platform == other.Environment.Platform && - p.Flags.isEqual(other.Flags) && - capabilitiesEqual(p.Capabilities, other.Capabilities) + return len(metaDiff(p, other)) == 0 } func (p PeerSystemMeta) isEmpty() bool { @@ -303,26 +274,173 @@ func (p *Peer) Copy() *Peer { } } -// UpdateMetaIfNew updates peer's system metadata if new information is provided -// returns true if meta was updated, false otherwise -func (p *Peer) UpdateMetaIfNew(meta PeerSystemMeta) (updated, versionChanged bool) { +// UpdateMetaIfNew updates peer's system metadata and connection geo location if +// new information is provided. newLocation is the geo location resolved from the +// peer's current connection IP, or nil when there is nothing to apply (geo +// disabled, no real IP, or the IP is unchanged); the caller owns the expensive +// lookup and the same-IP guard. It returns a MetaDiff describing what changed; +// diff.Updated() reports whether the peer needs to be persisted. +func (p *Peer) UpdateMetaIfNew(ctx context.Context, meta PeerSystemMeta, newLocation *Location) MetaDiff { if meta.isEmpty() { - return updated, versionChanged + return MetaDiff{} } - versionChanged = p.Meta.WtVersion != meta.WtVersion - // Avoid overwriting UIVersion if the update was triggered sole by the CLI client if meta.UIVersion == "" { meta.UIVersion = p.Meta.UIVersion } - if p.Meta.isEqual(meta) { - return updated, versionChanged + effectiveLocation := p.Location + if newLocation != nil { + effectiveLocation = *newLocation } - p.Meta = meta - updated = true - return updated, versionChanged + + diff := diffMeta(p.Meta, meta, p.Location, effectiveLocation) + if diff.Updated() { + p.Meta = meta + } + p.Location = effectiveLocation + + if diff.Updated() { + log.WithContext(ctx).Debug(diff.LogSummary()) + } + + return diff +} + +// MetaDiff holds a peer's full before/after state across a sync: both metas and both +// connection locations (the location lives on Peer, not PeerSystemMeta, but posture +// checks read it). Changed lists what moved, for logging and the persistence decision; +// the snapshots let a posture check be replayed against old and new. Everything is derived +// from these fields, so there are no parallel per-field flags to keep in sync. +type MetaDiff struct { + OldMeta PeerSystemMeta + NewMeta PeerSystemMeta + OldLocation Location + NewLocation Location + + Changed []string +} + +// Updated reports whether anything changed and the peer must be persisted. diffMeta fills +// Changed in the pass that builds the diff, so this is a length check, not a re-comparison. +// Pointer receiver: MetaDiff embeds two metas, so copying it per call is wasteful. +func (d *MetaDiff) Updated() bool { + return len(d.Changed) != 0 +} + +// VersionChanged reports whether the WireGuard client version changed (a client upgrade). +func (d *MetaDiff) VersionChanged() bool { + return d.OldMeta.WtVersion != d.NewMeta.WtVersion +} + +// HostnameChanged reports whether the peer's hostname changed. +func (d *MetaDiff) HostnameChanged() bool { + return d.OldMeta.Hostname != d.NewMeta.Hostname +} + +// LogSummary renders the changed fields as a single human-readable line. +func (d *MetaDiff) LogSummary() string { + return fmt.Sprintf("peer meta updated, %d field(s) changed: %s", + len(d.Changed), strings.Join(d.Changed, ", ")) +} + +func metaDiff(oldMeta, newMeta PeerSystemMeta) []string { + return diffMeta(oldMeta, newMeta, Location{}, Location{}).Changed +} + +// diffMeta snapshots a peer's old and new state and records a Changed entry per field that +// moved. It is the single source of truth for the comparison: isEqual is an empty Changed +// list, so the log line and the persistence decision can never disagree. +func diffMeta(oldMeta, newMeta PeerSystemMeta, oldLocation, newLocation Location) MetaDiff { + d := MetaDiff{OldMeta: oldMeta, NewMeta: newMeta, OldLocation: oldLocation, NewLocation: newLocation} + add := func(field string, oldVal, newVal any) { + d.Changed = append(d.Changed, fmt.Sprintf("%s: %v -> %v", field, oldVal, newVal)) + } + + if oldMeta.Hostname != newMeta.Hostname { + add("hostname", oldMeta.Hostname, newMeta.Hostname) + } + if oldMeta.GoOS != newMeta.GoOS { + add("goos", oldMeta.GoOS, newMeta.GoOS) + } + if oldMeta.Kernel != newMeta.Kernel { + add("kernel", oldMeta.Kernel, newMeta.Kernel) + } + if oldMeta.KernelVersion != newMeta.KernelVersion { + add("kernel_version", oldMeta.KernelVersion, newMeta.KernelVersion) + } + if oldMeta.Core != newMeta.Core { + add("core", oldMeta.Core, newMeta.Core) + } + if oldMeta.Platform != newMeta.Platform { + add("platform", oldMeta.Platform, newMeta.Platform) + } + if oldMeta.OS != newMeta.OS { + add("os", oldMeta.OS, newMeta.OS) + } + if oldMeta.OSVersion != newMeta.OSVersion { + add("os_version", oldMeta.OSVersion, newMeta.OSVersion) + } + if oldMeta.WtVersion != newMeta.WtVersion { + add("wt_version", oldMeta.WtVersion, newMeta.WtVersion) + } + if oldMeta.UIVersion != newMeta.UIVersion { + add("ui_version", oldMeta.UIVersion, newMeta.UIVersion) + } + if oldMeta.SystemSerialNumber != newMeta.SystemSerialNumber { + add("system_serial_number", oldMeta.SystemSerialNumber, newMeta.SystemSerialNumber) + } + if oldMeta.SystemProductName != newMeta.SystemProductName { + add("system_product_name", oldMeta.SystemProductName, newMeta.SystemProductName) + } + if oldMeta.SystemManufacturer != newMeta.SystemManufacturer { + add("system_manufacturer", oldMeta.SystemManufacturer, newMeta.SystemManufacturer) + } + if oldMeta.Environment.Cloud != newMeta.Environment.Cloud { + add("environment_cloud", oldMeta.Environment.Cloud, newMeta.Environment.Cloud) + } + if oldMeta.Environment.Platform != newMeta.Environment.Platform { + add("environment_platform", oldMeta.Environment.Platform, newMeta.Environment.Platform) + } + if !oldMeta.Flags.isEqual(newMeta.Flags) { + add("flags", fmt.Sprintf("%+v", oldMeta.Flags), fmt.Sprintf("%+v", newMeta.Flags)) + } + if !capabilitiesEqual(oldMeta.Capabilities, newMeta.Capabilities) { + add("capabilities", oldMeta.Capabilities, newMeta.Capabilities) + } + if !sameMultiset(oldMeta.NetworkAddresses, newMeta.NetworkAddresses) { + add("network_addresses", fmt.Sprintf("%v", oldMeta.NetworkAddresses), fmt.Sprintf("%v", newMeta.NetworkAddresses)) + } + if !sameMultiset(oldMeta.Files, newMeta.Files) { + add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files)) + } + + if !oldLocation.equal(newLocation) { + add("connection_ip", oldLocation.ConnectionIP, newLocation.ConnectionIP) + } + + return d +} + +// sameMultiset reports whether two slices contain the same elements with the +// same multiplicity, ignoring order. The element type is the comparison key, so +// every field participates in equality. +func sameMultiset[T comparable](a, b []T) bool { + if len(a) != len(b) { + return false + } + counts := make(map[T]int, len(a)) + for _, v := range a { + counts[v]++ + } + for _, v := range b { + counts[v]-- + if counts[v] == 0 { + delete(counts, v) + } + } + return len(counts) == 0 } // GetLastLogin returns the last login time of the peer. diff --git a/management/server/peer/peer_metadiff_test.go b/management/server/peer/peer_metadiff_test.go new file mode 100644 index 000000000..1256cdb02 --- /dev/null +++ b/management/server/peer/peer_metadiff_test.go @@ -0,0 +1,113 @@ +package peer + +import ( + "net/netip" + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +// metaDiffExtraEntries accounts for PeerSystemMeta fields that metaDiff does not +// map 1:1 to a single diff entry. Today the only such field is Environment, which +// is exploded into two checks (Cloud, Platform) and therefore yields one extra +// entry beyond its single struct field. If you teach metaDiff to explode another +// field into N entries, bump this by N-1; if you collapse a field, lower it. +const metaDiffExtraEntries = 1 + +// TestMetaDiff_CoversAllFields fully populates a PeerSystemMeta with non-zero +// values and diffs it against the zero value, then asserts metaDiff emits exactly +// one entry per exported field (plus metaDiffExtraEntries for fields it explodes). +// +// The expected count is derived from the struct via reflection, so adding a field +// to PeerSystemMeta raises the expectation automatically — but the actual diff +// only grows if metaDiff was taught to compare the new field. A mismatch means +// someone changed the struct without updating metaDiff (or this test's +// extra-entry accounting), which is exactly what we want to catch. +func TestMetaDiff_CoversAllFields(t *testing.T) { + var full PeerSystemMeta + exported := populateAll(t, reflect.ValueOf(&full).Elem()) + require.NotZero(t, exported, "expected PeerSystemMeta to expose fields") + + diff := metaDiff(PeerSystemMeta{}, full) + + require.Len(t, diff, exported+metaDiffExtraEntries, + "metaDiff entry count no longer matches PeerSystemMeta's fields: a field was "+ + "likely added or removed without updating metaDiff (or metaDiffExtraEntries). "+ + "diff was: %v", diff) + + require.False(t, full.isEqual(PeerSystemMeta{}), + "isEqual must report a fully-populated meta as different from the zero value") +} + +// TestFlags_isEqualChecksEveryField guards the one field that the count-based +// TestMetaDiff_CoversAllFields cannot: metaDiff collapses all of Flags into a +// single "flags" diff entry, so a new Flags field that Flags.isEqual forgets to +// compare would not change the diff count. This flips each Flags field on its own +// and asserts Flags.isEqual notices, so adding a Flags field without comparing it +// fails here. +func TestFlags_isEqualChecksEveryField(t *testing.T) { + typ := reflect.TypeOf(Flags{}) + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + require.Equal(t, reflect.Bool, f.Type.Kind(), + "Flags.%s is not a bool; extend this test to set it non-zero", f.Name) + + var a, b Flags + reflect.ValueOf(&b).Elem().Field(i).SetBool(true) + require.False(t, a.isEqual(b), "Flags.isEqual ignores field %s", f.Name) + } +} + +// populateAll sets every exported field of the struct to a deterministic non-zero +// value, recursing into nested structs and the element type of struct slices so +// that each leaf differs from zero. It returns the number of exported fields on +// the top-level struct. netip.Prefix is treated as an opaque leaf (it has no +// settable exported fields and is comparable with ==). +func populateAll(t *testing.T, v reflect.Value) int { + t.Helper() + + typ := v.Type() + exported := 0 + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if f.PkgPath != "" { // unexported + continue + } + exported++ + setNonZero(t, v.Field(i)) + } + return exported +} + +// setNonZero assigns a deterministic non-zero value to a field based on its kind, +// recursing into nested structs and populating one element of slice fields. +func setNonZero(t *testing.T, field reflect.Value) { + t.Helper() + + if field.Type() == reflect.TypeOf(netip.Prefix{}) { + field.Set(reflect.ValueOf(netip.MustParsePrefix("10.0.0.0/24"))) + return + } + + switch field.Kind() { + case reflect.String: + field.SetString("non-zero") + case reflect.Bool: + field.SetBool(true) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + field.SetInt(7) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + field.SetUint(7) + case reflect.Float32, reflect.Float64: + field.SetFloat(7) + case reflect.Struct: + populateAll(t, field) + case reflect.Slice: + s := reflect.MakeSlice(field.Type(), 1, 1) + setNonZero(t, s.Index(0)) + field.Set(s) + default: + t.Fatalf("unhandled field kind %s; extend setNonZero", field.Kind()) + } +} diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 98cf10acf..6f139e43f 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -49,6 +49,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/geolocation" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/store" @@ -2893,3 +2894,141 @@ func TestUpdatePeer_DnsLabelUniqueName(t *testing.T) { require.NoError(t, err, "renaming to unique FQDN should succeed") assert.Equal(t, "api-server", updated.DNSLabel, "DNS label should be first label of FQDN") } + +// fakeGeo is a configurable geolocation.Geolocation implementation for tests. It +// returns a record built from the configured city geoname id, or an error when set. +type fakeGeo struct { + geoNameID uint + isoCode string + cityName string + err error +} + +func (g *fakeGeo) Lookup(net.IP) (*geolocation.Record, error) { + if g.err != nil { + return nil, g.err + } + record := &geolocation.Record{} + record.City.GeonameID = g.geoNameID + record.City.Names.En = g.cityName + record.Country.ISOCode = g.isoCode + return record, nil +} + +func (g *fakeGeo) GetAllCountries() ([]geolocation.Country, error) { return nil, nil } + +func (g *fakeGeo) GetCitiesByCountry(string) ([]geolocation.City, error) { return nil, nil } + +func (g *fakeGeo) Stop() error { return nil } + +func TestResolvePeerLocation(t *testing.T) { + realIP := net.ParseIP("203.0.113.10") + + tests := []struct { + name string + geo geolocation.Geolocation + peer *nbpeer.Peer + realIP net.IP + want *nbpeer.Location + wantNil bool + }{ + { + name: "no geo configured returns nil", + geo: nil, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: realIP, + wantNil: true, + }, + { + name: "nil real IP returns nil", + geo: &fakeGeo{geoNameID: 100}, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: nil, + wantNil: true, + }, + { + name: "lookup error returns nil", + geo: &fakeGeo{err: fmt.Errorf("lookup boom")}, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: realIP, + wantNil: true, + }, + { + name: "same IP and same geoname returns nil", + geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"}, + peer: &nbpeer.Peer{ + ID: "p1", + Location: nbpeer.Location{ + ConnectionIP: realIP, + GeoNameID: 100, + }, + }, + realIP: realIP, + wantNil: true, + }, + { + name: "same IP but changed geoname returns location", + geo: &fakeGeo{geoNameID: 200, isoCode: "US", cityName: "City B"}, + peer: &nbpeer.Peer{ + ID: "p1", + Location: nbpeer.Location{ + ConnectionIP: realIP, + GeoNameID: 100, + }, + }, + realIP: realIP, + want: &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: "US", + CityName: "City B", + GeoNameID: 200, + }, + }, + { + name: "different IP returns location", + geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"}, + peer: &nbpeer.Peer{ + ID: "p1", + Location: nbpeer.Location{ + ConnectionIP: net.ParseIP("198.51.100.7"), + GeoNameID: 100, + }, + }, + realIP: realIP, + want: &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: "US", + CityName: "City A", + GeoNameID: 100, + }, + }, + { + name: "no prior location returns location", + geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"}, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: realIP, + want: &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: "US", + CityName: "City A", + GeoNameID: 100, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + am := &DefaultAccountManager{geo: tt.geo} + got := am.resolvePeerLocation(context.Background(), tt.peer, tt.realIP) + if tt.wantNil { + assert.Nil(t, got, "resolved location should be nil") + return + } + require.NotNil(t, got, "resolved location should not be nil") + assert.True(t, tt.want.ConnectionIP.Equal(got.ConnectionIP), "connection IP should match") + assert.Equal(t, tt.want.CountryCode, got.CountryCode, "country code should match") + assert.Equal(t, tt.want.CityName, got.CityName, "city name should match") + assert.Equal(t, tt.want.GeoNameID, got.GeoNameID, "geoname id should match") + }) + } +} diff --git a/management/server/posture/affects_posture_test.go b/management/server/posture/affects_posture_test.go new file mode 100644 index 000000000..6aa54d892 --- /dev/null +++ b/management/server/posture/affects_posture_test.go @@ -0,0 +1,202 @@ +package posture + +import ( + "context" + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" +) + +// diffFrom builds a MetaDiff from the old/new snapshots AffectsPosture replays against. +func diffFrom(oldMeta, newMeta nbpeer.PeerSystemMeta, oldLoc, newLoc nbpeer.Location) *nbpeer.MetaDiff { + return &nbpeer.MetaDiff{ + OldMeta: oldMeta, + NewMeta: newMeta, + OldLocation: oldLoc, + NewLocation: newLoc, + } +} + +func checks(def ChecksDefinition) []*Checks { + return []*Checks{{Checks: def}} +} + +func TestAffectsPosture_NilDiff(t *testing.T) { + assert.False(t, AffectsPosture(context.Background(), nil, checks(ChecksDefinition{ + NBVersionCheck: &NBVersionCheck{MinVersion: "1.0.0"}, + }))) +} + +func TestAffectsPosture_NBVersion(t *testing.T) { + c := checks(ChecksDefinition{NBVersionCheck: &NBVersionCheck{MinVersion: "1.2.0"}}) + + tests := []struct { + name string + oldVer, newVer string + want bool + }{ + {"both above min, no flip", "1.3.0", "1.4.0", false}, + {"both below min, no flip", "1.0.0", "1.1.0", false}, + {"crosses up below->above", "1.1.0", "1.3.0", true}, + {"crosses down above->below", "1.3.0", "1.1.0", true}, + {"unparsable old only -> flip", "garbage", "1.3.0", true}, + {"unparsable both -> no flip", "garbage", "junk", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + diff := diffFrom( + nbpeer.PeerSystemMeta{WtVersion: tt.oldVer}, + nbpeer.PeerSystemMeta{WtVersion: tt.newVer}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.Equal(t, tt.want, AffectsPosture(context.Background(), diff, c)) + }) + } +} + +func TestAffectsPosture_OSVersion_KernelBumpWithinMin(t *testing.T) { + c := checks(ChecksDefinition{OSVersionCheck: &OSVersionCheck{ + Linux: &MinKernelVersionCheck{MinKernelVersion: "5.0.0"}, + }}) + + // Kernel moves but stays above the minimum: verdict stays pass -> not affected. + withinMin := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"}, + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.15.0-arch2"}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.False(t, AffectsPosture(context.Background(), withinMin, c)) + + // Kernel drops below the minimum: verdict flips pass -> fail -> affected. + crossesDown := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"}, + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0-arch1"}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.True(t, AffectsPosture(context.Background(), crossesDown, c)) +} + +func TestAffectsPosture_OSVersion_GoOSSwitchFlipsVerdict(t *testing.T) { + // Only Linux is constrained. An OS outside the switch (freebsd) passes; switching to a + // failing linux kernel flips the verdict pass -> fail. + c := checks(ChecksDefinition{OSVersionCheck: &OSVersionCheck{ + Linux: &MinKernelVersionCheck{MinKernelVersion: "6.0.0"}, + }}) + + diff := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "freebsd"}, + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0"}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.True(t, AffectsPosture(context.Background(), diff, c)) +} + +func TestAffectsPosture_Process_GoOSSwitchFlipsVerdict(t *testing.T) { + // Process runs at a linux path. Switching GoOS to windows (no WindowsPath configured) + // flips the verdict. + c := checks(ChecksDefinition{ProcessCheck: &ProcessCheck{ + Processes: []Process{{LinuxPath: "/usr/bin/foo"}}, + }}) + + files := []nbpeer.File{{Path: "/usr/bin/foo", ProcessIsRunning: true}} + diff := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "linux", Files: files}, + nbpeer.PeerSystemMeta{GoOS: "windows", Files: files}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.True(t, AffectsPosture(context.Background(), diff, c)) +} + +func TestAffectsPosture_Process_UnrelatedFileChange(t *testing.T) { + // A tracked process stays running while an unrelated file is added: the verdict does + // not move, so posture is not affected. + c := checks(ChecksDefinition{ProcessCheck: &ProcessCheck{ + Processes: []Process{{LinuxPath: "/usr/bin/foo"}}, + }}) + + diff := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{ + {Path: "/usr/bin/foo", ProcessIsRunning: true}, + }}, + nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{ + {Path: "/usr/bin/foo", ProcessIsRunning: true}, + {Path: "/usr/bin/bar", ProcessIsRunning: true}, + }}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.False(t, AffectsPosture(context.Background(), diff, c)) +} + +func TestAffectsPosture_GeoLocation(t *testing.T) { + c := checks(ChecksDefinition{GeoLocationCheck: &GeoLocationCheck{ + Action: CheckActionAllow, + Locations: []Location{{CountryCode: "DE"}}, + }}) + + // Moving within allowed countries keeps the verdict; moving out flips it. + stayAllowed := diffFrom( + nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{}, + nbpeer.Location{CountryCode: "DE", CityName: "Berlin"}, + nbpeer.Location{CountryCode: "DE", CityName: "Munich"}, + ) + assert.False(t, AffectsPosture(context.Background(), stayAllowed, c)) + + moveOut := diffFrom( + nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{}, + nbpeer.Location{CountryCode: "DE"}, + nbpeer.Location{CountryCode: "FR"}, + ) + assert.True(t, AffectsPosture(context.Background(), moveOut, c)) +} + +func TestAffectsPosture_PeerNetworkRange_ConnectionIP(t *testing.T) { + // The check reads the connection IP. Moving out of the allowed range flips the verdict; + // moving within it does not. + _, allowed, _ := net.ParseCIDR("10.0.0.0/8") + c := checks(ChecksDefinition{PeerNetworkRangeCheck: &PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{netip.MustParsePrefix(allowed.String())}, + }}) + + movesOutOfRange := diffFrom( + nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{}, + nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")}, + nbpeer.Location{ConnectionIP: net.ParseIP("8.8.8.8")}, + ) + assert.True(t, AffectsPosture(context.Background(), movesOutOfRange, c)) + + staysInRange := diffFrom( + nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{}, + nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")}, + nbpeer.Location{ConnectionIP: net.ParseIP("10.9.9.9")}, + ) + assert.False(t, AffectsPosture(context.Background(), staysInRange, c)) +} + +func TestAffectsPosture_IrrelevantFieldChange(t *testing.T) { + // Hostname changes but no check reads it: not affected even with checks present. + c := checks(ChecksDefinition{ + NBVersionCheck: &NBVersionCheck{MinVersion: "1.0.0"}, + GeoLocationCheck: &GeoLocationCheck{Action: CheckActionAllow, Locations: []Location{{CountryCode: "DE"}}}, + }) + + diff := diffFrom( + nbpeer.PeerSystemMeta{Hostname: "old", WtVersion: "1.5.0"}, + nbpeer.PeerSystemMeta{Hostname: "new", WtVersion: "1.5.0"}, + nbpeer.Location{CountryCode: "DE"}, nbpeer.Location{CountryCode: "DE"}, + ) + assert.False(t, AffectsPosture(context.Background(), diff, c)) +} + +func TestAffectsPosture_NoChecks(t *testing.T) { + diff := diffFrom( + nbpeer.PeerSystemMeta{WtVersion: "1.0.0"}, + nbpeer.PeerSystemMeta{WtVersion: "2.0.0"}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.False(t, AffectsPosture(context.Background(), diff, nil)) +} diff --git a/management/server/posture/checks.go b/management/server/posture/checks.go index a79cf6898..ec3e14c60 100644 --- a/management/server/posture/checks.go +++ b/management/server/posture/checks.go @@ -7,6 +7,8 @@ import ( "regexp" "github.com/hashicorp/go-version" + log "github.com/sirupsen/logrus" + nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/status" @@ -55,6 +57,46 @@ type Checks struct { Checks ChecksDefinition `gorm:"serializer:json"` } +// AffectsPosture reports whether the change in diff flips the verdict of any check. It +// replays each check against the peer's old and new state and compares verdicts, so a +// change that moves a field but stays the right side of a threshold (e.g. a kernel bump +// still above the minimum) does not force a re-evaluation. See verdictChanged for how an +// evaluation error counts. +func AffectsPosture(ctx context.Context, diff *nbpeer.MetaDiff, checks []*Checks) bool { + if diff == nil { + return false + } + + oldPeer := nbpeer.Peer{Meta: diff.OldMeta, Location: diff.OldLocation} + newPeer := nbpeer.Peer{Meta: diff.NewMeta, Location: diff.NewLocation} + + for _, c := range checks { + for _, check := range c.GetChecks() { + if verdictChanged(ctx, check, oldPeer, newPeer) { + return true + } + } + } + return false +} + +// verdictChanged replays check against old and new state and reports whether the verdict +// differs. Like callers, it treats an evaluation error as deny: two errors are the same +// verdict (no change), an error on one side only is a flip. +func verdictChanged(ctx context.Context, check Check, oldPeer, newPeer nbpeer.Peer) bool { + oldPass, oldErr := check.Check(ctx, oldPeer) + newPass, newErr := check.Check(ctx, newPeer) + + oldVerdict := oldPass && (oldErr == nil) + newVerdict := newPass && (newErr == nil) + changed := oldVerdict != newVerdict + + log.WithContext(ctx).Tracef("posture check %s replay: verdict %t -> %t (changed=%t), errs: %v -> %v", + check.Name(), oldVerdict, newVerdict, changed, oldErr, newErr) + + return changed +} + // HasSeqID reports whether the posture check has been persisted long enough // to have a per-account sequence id allocated. Wire encoders that key off // AccountSeqID must skip checks that return false here. diff --git a/management/server/posture_checks_test.go b/management/server/posture_checks_test.go index 19fa0139e..e6704b813 100644 --- a/management/server/posture_checks_test.go +++ b/management/server/posture_checks_test.go @@ -489,6 +489,7 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { policy := &types.Policy{ AccountID: account.Id, + Enabled: true, Rules: []*types.PolicyRule{ { Enabled: true, diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 51ca06332..89442af42 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -586,28 +586,6 @@ func (s *SqlStore) MarkPeerDisconnectedIfSameSession(ctx context.Context, accoun return result.RowsAffected > 0, nil } -func (s *SqlStore) SavePeerLocation(ctx context.Context, accountID string, peerWithLocation *nbpeer.Peer) error { - // To maintain data integrity, we create a copy of the peer's location to prevent unintended updates to other fields. - var peerCopy nbpeer.Peer - // Since the location field has been migrated to JSON serialization, - // updating the struct ensures the correct data format is inserted into the database. - peerCopy.Location = peerWithLocation.Location - - result := s.db.Model(&nbpeer.Peer{}). - Where(accountAndIDQueryCondition, accountID, peerWithLocation.ID). - Updates(peerCopy) - - if result.Error != nil { - return status.Errorf(status.Internal, "failed to save peer locations to store: %v", result.Error) - } - - if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, peerNotFoundFMT, peerWithLocation.ID) - } - - return nil -} - // ApproveAccountPeers marks all peers that currently require approval in the given account as approved. func (s *SqlStore) ApproveAccountPeers(ctx context.Context, accountID string) (int, error) { result := s.db.Model(&nbpeer.Peer{}). diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index ac136987e..92784af83 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -618,56 +618,6 @@ func TestSqlStore_SavePeerStatus(t *testing.T) { assert.WithinDurationf(t, newStatus.LastSeen, actual.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") } -func TestSqlStore_SavePeerLocation(t *testing.T) { - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - - account, err := store.GetAccount(context.Background(), "bf1c8084-ba50-4ce7-9439-34653001fc3b") - require.NoError(t, err) - - peer := &nbpeer.Peer{ - AccountID: account.Id, - ID: "testpeer", - Location: nbpeer.Location{ - ConnectionIP: net.ParseIP("0.0.0.0"), - CountryCode: "YY", - CityName: "City", - GeoNameID: 1, - }, - CreatedAt: time.Now().UTC(), - Meta: nbpeer.PeerSystemMeta{}, - } - // error is expected as peer is not in store yet - err = store.SavePeerLocation(context.Background(), account.Id, peer) - assert.Error(t, err) - - account.Peers[peer.ID] = peer - err = store.SaveAccount(context.Background(), account) - require.NoError(t, err) - - peer.Location.ConnectionIP = net.ParseIP("35.1.1.1") - peer.Location.CountryCode = "DE" - peer.Location.CityName = "Berlin" - peer.Location.GeoNameID = 2950159 - - err = store.SavePeerLocation(context.Background(), account.Id, account.Peers[peer.ID]) - assert.NoError(t, err) - - account, err = store.GetAccount(context.Background(), account.Id) - require.NoError(t, err) - - actual := account.Peers[peer.ID].Location - assert.Equal(t, peer.Location, actual) - - peer.ID = "non-existing-peer" - err = store.SavePeerLocation(context.Background(), account.Id, peer) - assert.Error(t, err) - parsedErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") -} - func Test_TestGetAccountByPrivateDomain(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("The SQLite store is not properly supported by Windows yet") diff --git a/management/server/store/store.go b/management/server/store/store.go index 156faaeb6..30d4c5e5d 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -185,7 +185,6 @@ type Store interface { // recorded by the database. Returns true when the update happened, // false when a newer session has taken over. MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error) - SavePeerLocation(ctx context.Context, accountID string, peer *nbpeer.Peer) error ApproveAccountPeers(ctx context.Context, accountID string) (int, error) DeletePeer(ctx context.Context, accountID string, peerID string) error diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 77a040ef3..5cfc7a2ee 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -2983,20 +2983,6 @@ func (mr *MockStoreMockRecorder) SavePeer(ctx, accountID, peer interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeer", reflect.TypeOf((*MockStore)(nil).SavePeer), ctx, accountID, peer) } -// SavePeerLocation mocks base method. -func (m *MockStore) SavePeerLocation(ctx context.Context, accountID string, peer *peer.Peer) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SavePeerLocation", ctx, accountID, peer) - ret0, _ := ret[0].(error) - return ret0 -} - -// SavePeerLocation indicates an expected call of SavePeerLocation. -func (mr *MockStoreMockRecorder) SavePeerLocation(ctx, accountID, peer interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeerLocation", reflect.TypeOf((*MockStore)(nil).SavePeerLocation), ctx, accountID, peer) -} - // SavePeerStatus mocks base method. func (m *MockStore) SavePeerStatus(ctx context.Context, accountID, peerID string, status peer.PeerStatus) error { m.ctrl.T.Helper() diff --git a/management/server/types/peer.go b/management/server/types/peer.go index 15d343793..885d67bba 100644 --- a/management/server/types/peer.go +++ b/management/server/types/peer.go @@ -12,6 +12,9 @@ type PeerSync struct { WireGuardPubKey string // Meta is the system information passed by peer, must be always present Meta nbpeer.PeerSystemMeta + // RealIP is the peer's connection IP, used to refresh its geo location. + // May be nil when the request has no associated connection IP. + RealIP net.IP // UpdateAccountPeers indicate updating account peers, // which occurs when the peer's metadata is updated UpdateAccountPeers bool diff --git a/management/server/user.go b/management/server/user.go index 412f15ce7..666d6d178 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -1059,8 +1059,8 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a if err != nil { return nil, err } - log.WithContext(ctx).Debugf("Got %d users from ExternalCache for account %s", len(usersFromIntegration), accountID) - log.WithContext(ctx).Debugf("Got %d users from InternalCache for account %s", len(queriedUsers), accountID) + log.WithContext(ctx).Tracef("Got %d users from ExternalCache for account %s", len(usersFromIntegration), accountID) + log.WithContext(ctx).Tracef("Got %d users from InternalCache for account %s", len(queriedUsers), accountID) queriedUsers = append(queriedUsers, usersFromIntegration...) } diff --git a/proxy/Dockerfile b/proxy/Dockerfile index e64680fd6..22c4cbfaa 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -7,7 +7,8 @@ RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/pas mkdir -p /tmp/certs FROM gcr.io/distroless/base:debug -COPY netbird-proxy /go/bin/netbird-proxy +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-proxy /go/bin/netbird-proxy COPY --from=builder /tmp/passwd /etc/passwd COPY --from=builder /tmp/group /etc/group COPY --from=builder --chown=1000:1000 /tmp/var/lib/netbird /var/lib/netbird diff --git a/proxy/management_byop_integration_test.go b/proxy/management_byop_integration_test.go index c0fbe682a..d075e47ec 100644 --- a/proxy/management_byop_integration_test.go +++ b/proxy/management_byop_integration_test.go @@ -125,6 +125,7 @@ func setupBYOPIntegrationTest(t *testing.T) *byopTestSetup { oidcConfig, nil, usersManager, + nil, realProxyManager, nil, ) diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index bf5067b85..cb82813b0 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -140,6 +140,7 @@ func setupIntegrationTest(t *testing.T) *integrationTestSetup { oidcConfig, nil, usersManager, + nil, proxyManager, nil, ) diff --git a/relay/Dockerfile b/relay/Dockerfile index f750027c3..757ee7b59 100644 --- a/relay/Dockerfile +++ b/relay/Dockerfile @@ -1,4 +1,5 @@ FROM gcr.io/distroless/base:debug ENTRYPOINT [ "/go/bin/netbird-relay" ] ENV NB_LOG_FILE=console -COPY netbird-relay /go/bin/netbird-relay +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-relay /go/bin/netbird-relay diff --git a/release_files/freebsd-port-diff.sh b/release_files/freebsd-port-diff.sh index b030b9164..6ffa141be 100755 --- a/release_files/freebsd-port-diff.sh +++ b/release_files/freebsd-port-diff.sh @@ -21,7 +21,8 @@ AWK_FIRST_FIELD='{print $1}' fetch_all_tags() { curl -sL "https://github.com/${GITHUB_REPO}/tags" 2>/dev/null | \ - grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+' | \ + grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+([^"]+)?' | \ + grep -iv 'rc' | \ sed 's/.*\/v//' | \ sort -u -V return 0 diff --git a/release_files/freebsd-port-issue-body.sh b/release_files/freebsd-port-issue-body.sh index b7ad0f5b1..1c23dbbbe 100755 --- a/release_files/freebsd-port-issue-body.sh +++ b/release_files/freebsd-port-issue-body.sh @@ -32,7 +32,8 @@ fetch_current_ports_version() { fetch_all_tags() { # Fetch tags from GitHub tags page (no rate limiting, no auth needed) curl -sL "https://github.com/${GITHUB_REPO}/tags" 2>/dev/null | \ - grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+' | \ + grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+([^"]+)?' | \ + grep -iv 'rc' | \ sed 's/.*\/v//' | \ sort -u -V return 0 diff --git a/shared/management/status/error.go b/shared/management/status/error.go index 78288aef3..1957c5591 100644 --- a/shared/management/status/error.go +++ b/shared/management/status/error.go @@ -48,6 +48,10 @@ type Type int32 var ( ErrExtraSettingsNotFound = errors.New("extra settings not found") ErrPeerAlreadyLoggedIn = errors.New("peer with the same public key is already logged in") + + // ErrNoAuthMethodProvided is returned when a peer login attempt carries neither a + // setup key nor an SSO token. Match it with errors.Is. + ErrNoAuthMethodProvided = Errorf(Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login") ) // Error is an internal error @@ -66,6 +70,16 @@ func (e *Error) Error() string { return e.Message } +// Is reports whether target is an *Error with the same type and message, +// enabling matching with errors.Is against sentinel errors. +func (e *Error) Is(target error) bool { + var t *Error + if !errors.As(target, &t) { + return false + } + return e.ErrorType == t.ErrorType && e.Message == t.Message +} + // Errorf returns Error(ErrorType, fmt.Sprintf(format, a...)). func Errorf(errorType Type, format string, a ...interface{}) error { return &Error{ diff --git a/shared/signal/client/client.go b/shared/signal/client/client.go index 9dc6ccd37..fb77cb90f 100644 --- a/shared/signal/client/client.go +++ b/shared/signal/client/client.go @@ -33,7 +33,7 @@ type Client interface { Receive(ctx context.Context, msgHandler func(msg *proto.Message) error) error Ready() bool IsHealthy() bool - WaitStreamConnected() + WaitStreamConnected(context.Context) SendToStream(msg *proto.EncryptedMessage) error Send(msg *proto.Message) error SetOnReconnectedListener(func()) diff --git a/shared/signal/client/client_test.go b/shared/signal/client/client_test.go index 1af34e37a..41def08a1 100644 --- a/shared/signal/client/client_test.go +++ b/shared/signal/client/client_test.go @@ -65,7 +65,10 @@ var _ = Describe("GrpcClient", func() { return } }() - clientA.WaitStreamConnected() + ctxA, cancelA := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelA() + clientA.WaitStreamConnected(ctxA) + Expect(clientA.StreamConnected()).To(BeTrue()) // connect PeerB to Signal keyB, _ := wgtypes.GenerateKey() @@ -91,7 +94,10 @@ var _ = Describe("GrpcClient", func() { } }() - clientB.WaitStreamConnected() + ctxB, cancelB := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelB() + clientB.WaitStreamConnected(ctxB) + Expect(clientB.StreamConnected()).To(BeTrue()) // PeerA initiates ping-pong err := clientA.Send(&sigProto.Message{ @@ -129,8 +135,10 @@ var _ = Describe("GrpcClient", func() { return } }() - client.WaitStreamConnected() - Expect(client).NotTo(BeNil()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client.WaitStreamConnected(ctx) + Expect(client.StreamConnected()).To(BeTrue()) }) }) diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go index b245b2296..2086e0fe6 100644 --- a/shared/signal/client/grpc.go +++ b/shared/signal/client/grpc.go @@ -2,9 +2,11 @@ package client import ( "context" + "errors" "fmt" "io" "sync" + "sync/atomic" "time" "github.com/cenkalti/backoff/v4" @@ -23,7 +25,23 @@ import ( "github.com/netbirdio/netbird/util/wsproxy" ) -const healthCheckTimeout = 5 * time.Second +const ( + // receiveInactivityThreshold is how long the receive stream may be silent + // before the watchdog actively probes it. The gRPC transport can stay + // healthy (keepalive satisfied) while the server stops delivering messages, + // which the transport layer cannot detect. + receiveInactivityThreshold = 30 * time.Second + // receiveProbeTimeout is how long the watchdog waits for its self-addressed + // probe to round-trip back on the stream before declaring the receive + // direction dead. + receiveProbeTimeout = 10 * time.Second + // receiveWatchdogInterval is how often the watchdog evaluates the stream. + receiveWatchdogInterval = 10 * time.Second +) + +// errReceiveStreamStalled is reported when the receive stream is transport-alive +// but no longer delivering messages, so the stream is torn down to reconnect. +var errReceiveStreamStalled = errors.New("signal receive stream stalled") // ConnStateNotifier is a wrapper interface of the status recorder type ConnStateNotifier interface { @@ -52,6 +70,14 @@ type GrpcClient struct { decryptionWorker *Worker decryptionWorkerCancel context.CancelFunc decryptionWg sync.WaitGroup + + // lastReceived holds the Unix-nano timestamp of the last message read from + // the receive stream, used by the receive watchdog. + lastReceived atomic.Int64 + // receiveStalled is set by the receive watchdog when the stream is + // transport-alive but no longer delivering messages. It is the source of + // truth IsHealthy reads, and is cleared once any frame is received again. + receiveStalled atomic.Bool } // NewClient creates a new Signal client @@ -148,9 +174,9 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes // connect to Signal stream identifying ourselves with a public WireGuard key // todo once the key rotation logic has been implemented, consider changing to some other identifier (received from management) - ctx, cancelStream := context.WithCancel(ctx) + streamCtx, cancelStream := context.WithCancel(ctx) defer cancelStream() - stream, err := c.connect(ctx, c.key.PublicKey().String()) + stream, err := c.connect(streamCtx, c.key.PublicKey().String()) if err != nil { log.Warnf("disconnected from the Signal Exchange due to an error: %v", err) return err @@ -164,9 +190,16 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes // Start worker pool if not already started c.startEncryptionWorker(msgHandler) + // Guard the receive direction: the transport can stay healthy while the + // server stops delivering messages. The watchdog reconnects via cancelStream. + c.markReceived() + go c.watchReceiveStream(streamCtx, cancelStream) + // start receiving messages from the Signal stream (from other peers through signal) err = c.receive(stream) if err != nil { + // Check the parent context, not streamCtx: a watchdog-triggered + // cancelStream must reconnect, only a parent cancel is shutdown. if ctx.Err() != nil { log.Debugf("signal connection context has been canceled, this usually indicates shutdown") return nil @@ -213,15 +246,6 @@ func (c *GrpcClient) notifyStreamConnected() { } } -func (c *GrpcClient) getStreamStatusChan() <-chan struct{} { - c.mux.Lock() - defer c.mux.Unlock() - if c.connectedCh == nil { - c.connectedCh = make(chan struct{}) - } - return c.connectedCh -} - func (c *GrpcClient) connect(ctx context.Context, key string) (proto.SignalExchange_ConnectStreamClient, error) { c.stream = nil @@ -252,7 +276,10 @@ func (c *GrpcClient) Ready() bool { return c.signalConn.GetState() == connectivity.Ready || c.signalConn.GetState() == connectivity.Idle } -// IsHealthy probes the gRPC connection and returns false on errors +// IsHealthy reports whether the Signal connection is usable, based on the +// transport state plus the receive watchdog's verdict, and updates the status +// recorder accordingly. It does not actively probe: the watchdog +// (watchReceiveStream) owns probing the receive path and reconnecting. func (c *GrpcClient) IsHealthy() bool { switch c.signalConn.GetState() { case connectivity.TransientFailure: @@ -265,16 +292,8 @@ func (c *GrpcClient) IsHealthy() bool { case connectivity.Ready: } - ctx, cancel := context.WithTimeout(c.ctx, healthCheckTimeout) - defer cancel() - _, err := c.realClient.Send(ctx, &proto.EncryptedMessage{ - Key: c.key.PublicKey().String(), - RemoteKey: "dummy", - Body: nil, - }) - if err != nil { - c.notifyDisconnected(err) - log.Warnf("health check returned: %s", err) + if c.receiveStalled.Load() { + c.notifyDisconnected(errReceiveStreamStalled) return false } c.notifyConnected() @@ -282,14 +301,24 @@ func (c *GrpcClient) IsHealthy() bool { } // WaitStreamConnected waits until the client is connected to the Signal stream -func (c *GrpcClient) WaitStreamConnected() { - +func (c *GrpcClient) WaitStreamConnected(ctx context.Context) { + // Check the status and obtain the wait channel atomically: otherwise + // notifyStreamConnected could flip the status and close/clear the channel + // between the check and the channel creation, leaving us waiting forever on + // a stale channel. + c.mux.Lock() if c.status == StreamConnected { + c.mux.Unlock() return } + if c.connectedCh == nil { + c.connectedCh = make(chan struct{}) + } + ch := c.connectedCh + c.mux.Unlock() - ch := c.getStreamStatusChan() select { + case <-ctx.Done(): case <-c.ctx.Done(): case <-ch: } @@ -398,6 +427,68 @@ func (c *GrpcClient) Send(msg *proto.Message) error { return err } +// markReceived records that a frame was just read from the receive stream and +// clears the stalled flag. +func (c *GrpcClient) markReceived() { + c.lastReceived.Store(time.Now().UnixNano()) + c.receiveStalled.Store(false) +} + +// idleSinceReceive returns how long the receive stream has been silent. +func (c *GrpcClient) idleSinceReceive() time.Duration { + return time.Since(time.Unix(0, c.lastReceived.Load())) +} + +// watchReceiveStream guards against a receive stream that is transport-alive but +// no longer delivering messages. While the stream is idle past +// receiveInactivityThreshold it sends a self-addressed probe that the Signal +// server routes back to this client. If the probe does not round-trip within +// receiveProbeTimeout the receive direction is considered dead and cancelStream +// is called so the retry loop reconnects. +func (c *GrpcClient) watchReceiveStream(ctx context.Context, cancelStream context.CancelFunc) { + ticker := time.NewTicker(receiveWatchdogInterval) + defer ticker.Stop() + + var probeSentAt time.Time + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if c.idleSinceReceive() < receiveInactivityThreshold { + probeSentAt = time.Time{} + continue + } + + if !probeSentAt.IsZero() && time.Since(probeSentAt) >= receiveProbeTimeout { + log.Warnf("signal receive stream stalled: no messages for %s and probe did not return, reconnecting", c.idleSinceReceive().Round(time.Second)) + c.receiveStalled.Store(true) + c.notifyDisconnected(errReceiveStreamStalled) + cancelStream() + return + } + + if probeSentAt.IsZero() { + if err := c.sendReceiveProbe(); err != nil { + log.Debugf("failed to send signal receive probe: %v", err) + } + probeSentAt = time.Now() + } + } + } +} + +// sendReceiveProbe sends a self-addressed heartbeat. The Signal server routes it +// back to this client, exercising the exact receive path the watchdog guards. +func (c *GrpcClient) sendReceiveProbe() error { + self := c.key.PublicKey().String() + return c.Send(&proto.Message{ + Key: self, + RemoteKey: self, + Body: &proto.Body{Type: proto.Body_HEARTBEAT}, + }) +} + // receive receives messages from other peers coming through the Signal Exchange // and distributes them to worker threads for processing func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) error { @@ -419,6 +510,9 @@ func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) er return err } + // Any frame from the server proves the receive direction is alive. + c.markReceived() + if msg == nil { continue } diff --git a/shared/signal/client/mock.go b/shared/signal/client/mock.go index 95381a5b0..0c8a083c5 100644 --- a/shared/signal/client/mock.go +++ b/shared/signal/client/mock.go @@ -55,7 +55,7 @@ func (sm *MockClient) Ready() bool { return sm.ReadyFunc() } -func (sm *MockClient) WaitStreamConnected() { +func (sm *MockClient) WaitStreamConnected(context.Context) { if sm.WaitStreamConnectedFunc == nil { return } diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go new file mode 100644 index 000000000..b780cb969 --- /dev/null +++ b/shared/signal/client/watchdog_test.go @@ -0,0 +1,84 @@ +package client + +import ( + "context" + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc" + + sigProto "github.com/netbirdio/netbird/shared/signal/proto" + "github.com/netbirdio/netbird/signal/server" +) + +func startTestSignalServer(t *testing.T) string { + t.Helper() + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + s := grpc.NewServer() + srv, err := server.NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + sigProto.RegisterSignalExchangeServer(s, srv) + + go func() { + _ = s.Serve(lis) + }() + t.Cleanup(s.Stop) + + return lis.Addr().String() +} + +// TestReceiveProbeRoundTrips verifies that the watchdog's self-addressed heartbeat +// is routed back to the same client through the signal server. This round-trip is +// what lets the watchdog confirm the receive direction is still delivering. +func TestReceiveProbeRoundTrips(t *testing.T) { + addr := startTestSignalServer(t) + + key, err := wgtypes.GenerateKey() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + client, err := NewClient(ctx, addr, key, false) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + + received := make(chan struct{}, 1) + go func() { + _ = client.Receive(ctx, func(msg *sigProto.Message) error { + if msg.GetBody().GetType() == sigProto.Body_HEARTBEAT && msg.GetKey() == key.PublicKey().String() { + select { + case received <- struct{}{}: + default: + } + } + return nil + }) + }() + + streamReady := make(chan struct{}) + go func() { + client.WaitStreamConnected(ctx) + close(streamReady) + }() + select { + case <-streamReady: + case <-time.After(5 * time.Second): + t.Fatal("signal stream did not connect within timeout") + } + + require.NoError(t, client.sendReceiveProbe()) + + select { + case <-received: + case <-time.After(3 * time.Second): + t.Fatal("self-addressed heartbeat did not round-trip back through the signal server") + } +} diff --git a/shared/signal/proto/signalexchange.pb.go b/shared/signal/proto/signalexchange.pb.go index 0c80fb489..8e07977f0 100644 --- a/shared/signal/proto/signalexchange.pb.go +++ b/shared/signal/proto/signalexchange.pb.go @@ -30,6 +30,7 @@ const ( Body_CANDIDATE Body_Type = 2 Body_MODE Body_Type = 4 Body_GO_IDLE Body_Type = 5 + Body_HEARTBEAT Body_Type = 6 ) // Enum value maps for Body_Type. @@ -40,6 +41,7 @@ var ( 2: "CANDIDATE", 4: "MODE", 5: "GO_IDLE", + 6: "HEARTBEAT", } Body_Type_value = map[string]int32{ "OFFER": 0, @@ -47,6 +49,7 @@ var ( "CANDIDATE": 2, "MODE": 4, "GO_IDLE": 5, + "HEARTBEAT": 6, } ) @@ -463,7 +466,7 @@ var file_signalexchange_proto_rawDesc = []byte{ 0x52, 0x09, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x52, - 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xc3, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d, + 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xd2, 0x04, 0x0a, 0x04, 0x42, 0x6f, 0x64, 0x79, 0x12, 0x2d, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x42, 0x6f, 0x64, 0x79, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, @@ -491,38 +494,39 @@ var file_signalexchange_proto_rawDesc = []byte{ 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x02, 0x52, 0x0d, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x43, 0x0a, 0x04, 0x54, 0x79, 0x70, + 0x72, 0x76, 0x65, 0x72, 0x49, 0x50, 0x88, 0x01, 0x01, 0x22, 0x52, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x46, 0x46, 0x45, 0x52, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x4e, 0x53, 0x57, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x44, 0x49, 0x44, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x4d, 0x4f, 0x44, 0x45, 0x10, - 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x42, 0x15, - 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, - 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, - 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, - 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, - 0x0a, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, - 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, - 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, - 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, - 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, - 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, - 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, - 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, - 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, - 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, - 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, - 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x04, 0x12, 0x0b, 0x0a, 0x07, 0x47, 0x4f, 0x5f, 0x49, 0x44, 0x4c, 0x45, 0x10, 0x05, 0x12, 0x0d, + 0x0a, 0x09, 0x48, 0x45, 0x41, 0x52, 0x54, 0x42, 0x45, 0x41, 0x54, 0x10, 0x06, 0x42, 0x15, 0x0a, + 0x13, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x49, 0x64, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x49, 0x50, 0x4a, 0x04, 0x08, 0x09, 0x10, 0x0a, 0x22, 0x2e, 0x0a, 0x04, 0x4d, 0x6f, + 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x08, 0x48, 0x00, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x88, 0x01, 0x01, 0x42, + 0x09, 0x0a, 0x07, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x6d, 0x0a, 0x0f, 0x52, 0x6f, + 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x0a, + 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, + 0x73, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x30, 0x0a, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, + 0x70, 0x61, 0x73, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x72, 0x6f, 0x73, 0x65, 0x6e, 0x70, 0x61, 0x73, 0x73, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x64, 0x64, 0x72, 0x32, 0xb9, 0x01, 0x0a, 0x0e, 0x53, 0x69, + 0x67, 0x6e, 0x61, 0x6c, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x4c, 0x0a, 0x04, + 0x53, 0x65, 0x6e, 0x64, 0x12, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, + 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, + 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, + 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x0d, 0x43, 0x6f, + 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x12, 0x20, 0x2e, 0x73, 0x69, + 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, 0x6e, 0x63, + 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x20, 0x2e, + 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x65, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x2e, 0x45, + 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, + 0x00, 0x28, 0x01, 0x30, 0x01, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/shared/signal/proto/signalexchange.proto b/shared/signal/proto/signalexchange.proto index 96a4001e3..8c304e37c 100644 --- a/shared/signal/proto/signalexchange.proto +++ b/shared/signal/proto/signalexchange.proto @@ -48,6 +48,7 @@ message Body { CANDIDATE = 2; MODE = 4; GO_IDLE = 5; + HEARTBEAT = 6; } Type type = 1; string payload = 2; diff --git a/signal/Dockerfile b/signal/Dockerfile index 4fd5fe4a3..f6504dc74 100644 --- a/signal/Dockerfile +++ b/signal/Dockerfile @@ -1,4 +1,5 @@ FROM gcr.io/distroless/base:debug ENTRYPOINT [ "/go/bin/netbird-signal","run" ] CMD ["--log-file", "console"] -COPY netbird-signal /go/bin/netbird-signal +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-signal /go/bin/netbird-signal diff --git a/signal/peer/peer.go b/signal/peer/peer.go index c9dd60fc0..c04654b8b 100644 --- a/signal/peer/peer.go +++ b/signal/peer/peer.go @@ -26,6 +26,10 @@ type Peer struct { // a gRpc connection stream to the Peer Stream proto.SignalExchange_ConnectStreamServer + // sendMu serializes writes to Stream. gRPC forbids concurrent SendMsg on + // the same ServerStream, and a peer can be the target of many senders at + // once. + sendMu sync.Mutex // registration time RegisteredAt time.Time @@ -33,6 +37,13 @@ type Peer struct { Cancel context.CancelFunc } +// Send writes a message to the peer's stream, serializing concurrent senders. +func (p *Peer) Send(msg *proto.EncryptedMessage) error { + p.sendMu.Lock() + defer p.sendMu.Unlock() + return p.Stream.Send(msg) +} + // NewPeer creates a new instance of a connected Peer func NewPeer(id string, stream proto.SignalExchange_ConnectStreamServer, cancel context.CancelFunc) *Peer { return &Peer{ diff --git a/signal/server/concurrent_send_test.go b/signal/server/concurrent_send_test.go new file mode 100644 index 000000000..b3830482d --- /dev/null +++ b/signal/server/concurrent_send_test.go @@ -0,0 +1,67 @@ +package server + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + + "github.com/netbirdio/netbird/shared/signal/proto" + "github.com/netbirdio/netbird/signal/peer" +) + +// concurrencyCheckStream records the maximum number of Send calls in flight at +// once. gRPC forbids concurrent SendMsg on the same ServerStream, so a correct +// server must never have more than one in flight per peer. +type concurrencyCheckStream struct { + proto.SignalExchange_ConnectStreamServer + ctx context.Context + inflight atomic.Int32 + maxSeen atomic.Int32 +} + +func (s *concurrencyCheckStream) Send(*proto.EncryptedMessage) error { + n := s.inflight.Add(1) + for { + old := s.maxSeen.Load() + if n <= old || s.maxSeen.CompareAndSwap(old, n) { + break + } + } + // Widen the window so overlapping callers are reliably observed. + time.Sleep(time.Millisecond) + s.inflight.Add(-1) + return nil +} + +func (s *concurrencyCheckStream) Context() context.Context { return s.ctx } + +// TestForwardMessageToPeerSerializesSend verifies that concurrent forwards to the +// same peer never call Stream.Send concurrently, which would violate the gRPC +// ServerStream contract. +func TestForwardMessageToPeerSerializesSend(t *testing.T) { + s, err := NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + + const peerID = "peerX" + stream := &concurrencyCheckStream{ctx: context.Background()} + _, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + require.NoError(t, s.registry.Register(peer.NewPeer(peerID, stream, cancel))) + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s.forwardMessageToPeer(context.Background(), &proto.EncryptedMessage{Key: "sender", RemoteKey: peerID}) + }() + } + wg.Wait() + + require.Equal(t, int32(1), stream.maxSeen.Load(), "Stream.Send must never run concurrently on the same peer stream") +} diff --git a/signal/server/signal.go b/signal/server/signal.go index c46df56d2..7edbb4d34 100644 --- a/signal/server/signal.go +++ b/signal/server/signal.go @@ -179,7 +179,7 @@ func (s *Server) forwardMessageToPeer(ctx context.Context, msg *proto.EncryptedM sendResultChan := make(chan error, 1) go func() { select { - case sendResultChan <- dstPeer.Stream.Send(msg): + case sendResultChan <- dstPeer.Send(msg): return case <-dstPeer.Stream.Context().Done(): return diff --git a/upload-server/Dockerfile b/upload-server/Dockerfile index a38c6fbb8..3713d6f2a 100644 --- a/upload-server/Dockerfile +++ b/upload-server/Dockerfile @@ -1,3 +1,4 @@ FROM gcr.io/distroless/base:debug ENTRYPOINT [ "/go/bin/netbird-upload" ] -COPY netbird-upload /go/bin/netbird-upload +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-upload /go/bin/netbird-upload