diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..85ed5cd3b --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: en-US +reviews: + profile: chill + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + auto_review: + enabled: true + drafts: false + path_filters: + - "!**/*.tsx" + - "!**/*.ts" + - "!**/*.js" + - "!**/*.svg" +chat: + auto_reply: true diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 80809e667..0661e0c71 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -6,7 +6,6 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ iptables=1.8.9-2 \ libgl1-mesa-dev=22.3.6-1+deb12u1 \ xorg-dev=1:7.7+23 \ - libayatana-appindicator3-dev=0.5.92-1 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ && go install -v golang.org/x/tools/gopls@latest diff --git a/.git-branches.toml b/.git-branches.toml index d1818090f..4c34d7928 100644 --- a/.git-branches.toml +++ b/.git-branches.toml @@ -3,7 +3,7 @@ [branches] main = "main" perennials = [] -perennial-regex = "" +perennial-regex = "^release-" [create] new-branch-type = "feature" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b78b1417a..647e04936 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,8 +3,8 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" - open-pull-requests-limit: 15 + interval: "weekly" + open-pull-requests-limit: 3 groups: actions: patterns: @@ -22,9 +22,12 @@ updates: directories: - "/" schedule: - interval: "daily" + interval: "weekly" open-pull-requests-limit: 15 groups: + golang-x-packages: + patterns: + - "golang.org/x/*" aws-sdk: patterns: - "github.com/aws/aws-sdk-go-v2/*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8e68054bd..9b796f262 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,6 +2,12 @@ ## Issue ticket number and link + + ## Stack @@ -12,7 +18,9 @@ - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) -- [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). +- [ ] I ran and tested this change locally — I did not rely on CI to find out whether it works +- [ ] This PR has a single purpose (not a fix + refactor + feature in one) +- [ ] This change is a trivial fix, **OR** it links an issue the NetBird team agreed on beforehand. Changes to the public API, gRPC protocols, functionality behavior, CLI / service flags, or new features always need that agreement first. See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#ticket-first-pr-second). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). diff --git a/.github/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml new file mode 100644 index 000000000..9501c5fba --- /dev/null +++ b/.github/workflows/agent-network-e2e.yml @@ -0,0 +1,91 @@ +name: Agent Network E2E + +on: + # Nightly at 03:00 UTC, plus on demand from the Actions tab. + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + inputs: + bedrock_model: + description: >- + Bedrock inference-profile id to drive the matrix with, exactly as + AWS issues it. Leave empty for the Sonnet 4.6 default. + required: false + default: "" + test_pattern: + description: >- + Package pattern to run. Defaults to the whole suite; narrow it to one + package (e.g. ./e2e/agentnetwork/...) when a run only needs that + package's answer and not the sixteen minutes the container suite costs. + required: false + default: "./e2e/..." + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + name: Agent Network E2E + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: "go.mod" + + # Container-driver builder so the harness can build the combined/proxy/ + # client images from source with a local layer cache. + - name: Set up Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + # Persist the Docker layer cache across runs. This caches the base, apt, + # and go-mod-download layers; the Go compile still re-runs, as BuildKit + # mount caches cannot be exported to the GitHub cache. + - name: Cache Docker layers + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-anet-e2e-buildx-${{ hashFiles('go.sum', 'combined/Dockerfile.multistage', 'proxy/Dockerfile.multistage', 'e2e/harness/Dockerfile.client') }} + restore-keys: | + ${{ runner.os }}-anet-e2e-buildx- + + - name: Run agent-network e2e + env: + # Build the images from source (this branch's code) with the shared + # local layer cache. + NB_E2E_BUILDX_CACHE: /tmp/.buildx-cache + # Provider credentials. Each provider scenario skips if its + # token (and URL, for gateways) is unset, so partial coverage is fine. + OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }} + ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }} + # Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire + # shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api. + KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }} + VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }} + VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }} + OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }} + OPENROUTER_TOKEN: ${{ secrets.E2E_OPENROUTER_TOKEN }} + CLOUDFLARE_URL: ${{ secrets.E2E_CLOUDFLARE_URL }} + CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }} + AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }} + AWS_REGION: ${{ secrets.E2E_AWS_REGION }} + # Bedrock model override: dispatch input wins, then the repo variable, else the test default. + AWS_BEDROCK_MODEL: ${{ inputs.bedrock_model || vars.E2E_AWS_BEDROCK_MODEL }} + # Vertex (Anthropic-on-Vertex): SA + project required; region defaults + # to "global", model to a pinned claude snapshot. + GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }} + GOOGLE_VERTEX_PROJECT: ${{ secrets.E2E_GOOGLE_VERTEX_PROJECT }} + GOOGLE_VERTEX_REGION: ${{ secrets.E2E_GOOGLE_VERTEX_REGION }} + GOOGLE_VERTEX_MODEL: ${{ secrets.E2E_GOOGLE_VERTEX_MODEL }} + # Read through an env var rather than interpolated into the run + # script: a dispatch input reaching a shell command directly is a + # script-injection seam, however trusted the dispatcher. + TEST_PATTERN: ${{ inputs.test_pattern || './e2e/...' }} + run: go test -tags e2e -timeout 40m -v "$TEST_PATTERN" diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml index 8acd645e2..81d293e4f 100644 --- a/.github/workflows/check-license-dependencies.yml +++ b/.github/workflows/check-license-dependencies.yml @@ -2,7 +2,7 @@ name: Check License Dependencies on: push: - branches: [main] + branches: [main, "release-*"] paths: - "go.mod" - "go.sum" @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -59,12 +59,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: true diff --git a/.github/workflows/frontend-ui.yml b/.github/workflows/frontend-ui.yml new file mode 100644 index 000000000..014c5c2ae --- /dev/null +++ b/.github/workflows/frontend-ui.yml @@ -0,0 +1,99 @@ +name: UI Frontend + +on: + pull_request: + paths: + - "client/ui/frontend/**" + - "client/ui/i18n/**" + - "client/ui/**/*.go" + - ".github/workflows/frontend-ui.yml" + push: + branches: + - main + - "release-*" + paths: + - "client/ui/frontend/**" + - "client/ui/i18n/**" + - "client/ui/**/*.go" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + cancel-in-progress: true + +jobs: + lint-and-build: + name: Lint & Build + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: client/ui/frontend + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Set up pnpm + uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + with: + version: 11 + + # Bindings are generated by wails3 from the Go service definitions and + # are not checked in (see client/ui/frontend/bindings/). Without them, + # typecheck/build fail on missing module imports. + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: "go.mod" + cache: false + + # wails3 CLI links against GTK4 / WebKitGTK 6.0 via its internal/operatingsystem + # package, so the dev libraries must be present before `go install`. + - name: Install Wails Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + pkg-config \ + libgtk-4-dev \ + libwebkitgtk-6.0-dev + + - name: Install wails3 CLI + # Version derived from go.mod so the binding generator always matches + # the wails runtime the daemon links against. + working-directory: ${{ github.workspace }} + run: | + WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3) + go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION + + - name: Get pnpm store directory + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('client/ui/frontend/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Generate Wails bindings + run: pnpm run bindings + + - name: Lint, typecheck, format + run: pnpm check + + - name: Build + run: pnpm build 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..c17d8e775 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: concurrency: @@ -16,18 +17,18 @@ jobs: runs-on: macos-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false - name: Cache Go modules - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/go/pkg/mod key: macos-gotest-${{ hashFiles('**/go.sum') }} @@ -45,10 +46,18 @@ jobs: run: git --no-pager diff --exit-code - name: Test - run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined) + # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, + # which fails to compile until the frontend has been built. The Wails UI + # has no Go-side unit tests, and its release pipeline runs `pnpm build` + # before goreleaser. + # `go list -e` lets the listing succeed even though the embed fails to + # resolve; the grep then drops the broken package by path. Without -e, + # go list aborts with empty stdout and `go test` falls back to the repo + # root, which has no Go files. + run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged) - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} slug: netbirdio/netbird diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml index 9a81d3e4c..65c39147a 100644 --- a/.github/workflows/golang-test-freebsd.yml +++ b/.github/workflows/golang-test-freebsd.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: concurrency: @@ -16,7 +17,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 +29,7 @@ jobs: id: test env: GO_VERSION: ${{ steps.goversion.outputs.version }} - uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5 + uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8 with: usesh: true copyback: false @@ -48,14 +49,14 @@ jobs: export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin time go build -o netbird client/main.go # check all component except management, since we do not support management server on freebsd - time go test -timeout 1m -failfast ./base62/... + time go test -tags privileged -timeout 1m -failfast ./base62/... # NOTE: without -p1 `client/internal/dns` will fail because of `listen udp4 :33100: bind: address already in use` - time go test -timeout 8m -failfast -v -p 1 ./client/... - time go test -timeout 1m -failfast ./dns/... - time go test -timeout 1m -failfast ./encryption/... - time go test -timeout 1m -failfast ./formatter/... - time go test -timeout 1m -failfast ./client/iface/... - time go test -timeout 1m -failfast ./route/... - time go test -timeout 1m -failfast ./sharedsock/... - time go test -timeout 1m -failfast ./util/... - time go test -timeout 1m -failfast ./version/... + time go test -tags privileged -timeout 8m -failfast -v -p 1 ./client/... + time go test -tags privileged -timeout 1m -failfast ./dns/... + time go test -tags privileged -timeout 1m -failfast ./encryption/... + time go test -tags privileged -timeout 1m -failfast ./formatter/... + time go test -tags privileged -timeout 1m -failfast ./client/iface/... + time go test -tags privileged -timeout 1m -failfast ./route/... + time go test -tags privileged -timeout 1m -failfast ./sharedsock/... + time go test -tags privileged -timeout 1m -failfast ./util/... + time go test -tags privileged -timeout 1m -failfast ./version/... diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index c17f83222..004b78b3e 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: concurrency: @@ -18,7 +19,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 +31,7 @@ jobs: - 'management/**' - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -41,7 +42,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 id: cache with: path: | @@ -53,7 +54,7 @@ jobs: - name: Install dependencies if: steps.cache.outputs.cache-hit != 'true' - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev - name: Install 32-bit libpcap if: steps.cache.outputs.cache-hit != 'true' @@ -119,12 +120,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -135,7 +136,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -145,7 +146,7 @@ jobs: ${{ runner.os }}-gotest-cache- - name: Install dependencies - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev gcc-multilib libpcap-dev - name: Install 32-bit libpcap if: matrix.arch == '386' @@ -158,29 +159,36 @@ jobs: run: git --no-pager diff --exit-code - name: Test - run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined) + # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, + # which fails to compile until the frontend has been built. The Wails UI + # has no Go-side unit tests, and its release pipeline runs `pnpm build` + # before goreleaser. + # `go list -e` lets the listing succeed even though the embed fails to + # resolve; the grep then drops the broken package by path. Without -e, + # go list aborts with empty stdout and `go test` falls back to the repo + # root, which has no Go files. + run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,CGO_ENABLED' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged) - 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 flags: unit,client - test_client_on_docker: name: "Client (Docker) / Unit" needs: [build-cache] runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -192,7 +200,7 @@ jobs: echo "modcache_dir=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 id: cache-restore with: path: | @@ -229,7 +237,7 @@ jobs: sh -c ' \ apk update; apk add --no-cache \ ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \ - go test -buildvcs=false -tags devcert -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server) + go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged) ' test_relay: @@ -246,12 +254,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -266,7 +274,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -290,7 +298,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 +314,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -325,7 +333,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -347,7 +355,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 +371,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -383,7 +391,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -407,7 +415,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 +432,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -440,7 +448,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -484,7 +492,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 +537,12 @@ jobs: prom/prometheus - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -545,7 +553,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -579,10 +587,11 @@ jobs: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - GIT_BRANCH=${{ github.ref_name }} \ go test -tags devcert -run=^$ -bench=. \ -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE,GIT_BRANCH,GITHUB_RUN_ID' \ -timeout 20m ./management/... ./shared/management/... $(go list ./management/... ./shared/management/... | grep -v -e /management/server/http) + env: + GIT_BRANCH: ${{ github.ref_name }} api_benchmark: name: "Management / Benchmark (API)" @@ -623,12 +632,12 @@ jobs: prom/prometheus - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -639,7 +648,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -673,12 +682,13 @@ jobs: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - GIT_BRANCH=${{ github.ref_name }} \ go test -tags=benchmark \ -run=^$ \ -bench=. \ -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE,GIT_BRANCH,GITHUB_RUN_ID' \ -timeout 20m ./management/server/http/... + env: + GIT_BRANCH: ${{ github.ref_name }} api_integration_test: name: "Management / Integration" @@ -692,12 +702,12 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false @@ -708,7 +718,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -734,7 +744,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..fb7b745d2 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: env: @@ -18,12 +19,12 @@ jobs: runs-on: windows-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 id: go with: go-version-file: "go.mod" @@ -35,7 +36,7 @@ jobs: echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV - name: Cache Go modules - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ${{ env.cache }} @@ -65,10 +66,17 @@ jobs: - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }} - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy - name: Generate test script + # Exclude client/ui: its main.go uses //go:embed all:frontend/dist, + # which fails to compile until the frontend has been built. The Wails UI + # has no Go-side unit tests, and its release pipeline runs `pnpm build` + # before goreleaser. + # `go list -e` lets the listing succeed even though the embed fails to + # resolve; the Where-Object pipeline then drops the broken package by + # path. Without -e, go list aborts with empty stdout. run: | - $packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } + $packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' } $goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe" - $cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" + $cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd - name: test diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 8f6d1ddb0..586e1235b 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -15,14 +15,22 @@ 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 uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2 with: - ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals - skip: go.mod,go.sum,**/proxy/web/** + ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals,flate,recordin,unparseable + # Non-English UI translations trip codespell on real foreign words + # (de: "Sie", "oder", "ist"). Only en/common.json is the source of + # truth that should be spell-checked. List each translated locale + # dir below and add new ones as languages are added under + # client/ui/i18n/locales/. Single-star globs are matched per path + # segment by codespell and behave the same across versions; the + # recursive "**" form did not take effect with the codespell shipped + # by this action. + skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/es/*,*/locales/fr/*,*/locales/hu/*,*/locales/it/*,*/locales/pt/*,*/locales/ru/*,*/locales/zh-CN/*,*/i18n/TRANSLATING.md golangci: strategy: fail-fast: false @@ -37,10 +45,10 @@ jobs: display_name: Linux name: ${{ matrix.display_name }} runs-on: ${{ matrix.os }} - timeout-minutes: 15 + 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,13 +56,22 @@ jobs: run: | ! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep . - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false - name: Install dependencies if: matrix.os == 'ubuntu-latest' - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev libpcap-dev + - name: Stub Wails frontend bundle + # client/ui/main.go has //go:embed all:frontend/dist. The + # directory is produced by `pnpm run build` and is gitignored, so + # lint-only runs (no frontend toolchain) need a placeholder file + # for the embed pattern to match. + shell: bash + run: | + mkdir -p client/ui/frontend/dist + touch client/ui/frontend/dist/.embed-placeholder - name: golangci-lint uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1 with: @@ -62,4 +79,4 @@ jobs: skip-cache: true skip-save-cache: true cache-invalidation-interval: 0 - args: --timeout=12m + args: --timeout=20m diff --git a/.github/workflows/install-script-test.yml b/.github/workflows/install-script-test.yml index aec9f6300..61709501c 100644 --- a/.github/workflows/install-script-test.yml +++ b/.github/workflows/install-script-test.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: paths: - "release_files/install.sh" @@ -22,7 +23,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..204576d28 100644 --- a/.github/workflows/mobile-build-validation.yml +++ b/.github/workflows/mobile-build-validation.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: concurrency: @@ -16,11 +17,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" - name: Setup Android SDK @@ -28,13 +29,13 @@ jobs: with: cmdline-tools-version: 8512546 - name: Setup Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 with: java-version: "11" distribution: "adopt" - name: NDK Cache id: ndk-cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: /usr/local/lib/android/sdk/ndk key: ndk-cache-23.1.7779620 @@ -42,8 +43,19 @@ jobs: run: /usr/local/lib/android/sdk/cmdline-tools/7.0/bin/sdkmanager --install "ndk;23.1.7779620" - name: install gomobile run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab + # `gomobile init` re-installs gobind from golang.org/x/mobile@latest + # regardless of the pin above (cmd/gomobile/init.go: "Make sure gobind is + # up to date"), so this step resolves a version nobody chose, on every run. + # + # setup-go sets GOTOOLCHAIN=local, so that install fails outright once + # x/mobile@latest declares a newer Go than go.mod does — which it did on + # 2026-08-21, breaking both jobs on every branch at once. GOTOOLCHAIN=auto + # lets this one install fetch the toolchain it asks for. Scoped to the + # step: the repo's own Go version, and every build below, is unaffected. - name: gomobile init run: gomobile init + env: + GOTOOLCHAIN: auto - name: build android netbird lib run: PATH=$PATH:$(go env GOPATH) gomobile bind -o $GITHUB_WORKSPACE/netbird.aar -javapkg=io.netbird.gomobile -ldflags="-checklinkname=0 -X golang.zx2c4.com/wireguard/ipc.socketDirectory=/data/data/io.netbird.client/cache/wireguard -X github.com/netbirdio/netbird/version.version=buildtest" $GITHUB_WORKSPACE/client/android env: @@ -54,17 +66,22 @@ jobs: runs-on: macos-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" - name: install gomobile run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab + # See the Android job: `gomobile init` re-installs gobind from + # golang.org/x/mobile@latest regardless of the pin above, and needs a + # toolchain it may pick newer than go.mod's. - name: gomobile init run: gomobile init + env: + GOTOOLCHAIN: auto - name: build iOS netbird lib run: PATH=$PATH:$(go env GOPATH) gomobile bind -target=ios -bundleid=io.netbird.framework -ldflags="-X github.com/netbirdio/netbird/version.version=buildtest" -o ./NetBirdSDK.xcframework ./client/ios/NetBirdSDK env: diff --git a/.github/workflows/no-new-replace.yml b/.github/workflows/no-new-replace.yml new file mode 100644 index 000000000..b906ce450 --- /dev/null +++ b/.github/workflows/no-new-replace.yml @@ -0,0 +1,78 @@ +name: No New Replace Directives + +on: + pull_request: + paths: + - "go.mod" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + cancel-in-progress: true + +jobs: + check-replace-directives: + name: check-replace-directives + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + + - name: Compare replace directives against the base branch + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + + # A replace directive only applies when this module is the main + # module. Anything importing netbird as a library, the embedded + # clients among them, resolves the replaced path upstream instead and + # fails to build against whatever the replacement provides. Requiring + # a fork under its own module path avoids that; a replace does not. + # + # go.mod is parsed rather than diffed so that reordering, comments and + # single-line versus block syntax do not register as changes. + # + # Versions are part of the key because a replace can be scoped to one + # version of a module. Keyed on paths alone, retargeting such a + # directive at a different version would read as unchanged. + list_replaces() { + go mod edit -json "$1" \ + | jq -r ' + def ref: .Path + (if (.Version // "") == "" then "" else " " + .Version end); + (.Replace // [])[] | "\(.Old | ref) => \(.New | ref)" + ' \ + | sort + } + + git show "${BASE_SHA}:go.mod" > /tmp/base-go.mod + list_replaces /tmp/base-go.mod > /tmp/base-replaces + list_replaces go.mod > /tmp/head-replaces + + added=$(comm -13 /tmp/base-replaces /tmp/head-replaces) + if [ -n "$added" ]; then + echo "::error::This PR adds a replace directive to go.mod:" + echo "$added" | sed 's/^/ /' + echo "" + echo "A replace directive applies only to the main module, so it does not" + echo "reach anything that imports netbird as a library. Require the module" + echo "under a path you control instead, as done for github.com/netbirdio/go-nat." + exit 1 + fi + + removed=$(comm -23 /tmp/base-replaces /tmp/head-replaces) + if [ -n "$removed" ]; then + echo "This PR removes replace directives:" + echo "$removed" | sed 's/^/ /' + fi + echo "No new replace directives." diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 67d65356c..24d81b50f 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -16,6 +16,8 @@ jobs: const allowedTags = [ 'management', 'client', + 'android', + 'ios', 'signal', 'proxy', 'relay', diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd3514d27..4d1945451 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,10 +6,11 @@ on: - "v*" branches: - main + - "release-*" pull_request: env: - SIGN_PIPE_VER: "v0.1.6" + SIGN_PIPE_VER: "v0.1.8" GORELEASER_VER: "v2.16.0" PRODUCT_NAME: "NetBird" COPYRIGHT: "NetBird GmbH" @@ -27,7 +28,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 @@ -64,7 +65,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 @@ -135,7 +136,7 @@ jobs: ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }} 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 @@ -166,12 +167,12 @@ jobs: fi - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false - name: Cache Go modules - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ~/go/pkg/mod @@ -186,9 +187,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 @@ -216,12 +217,12 @@ jobs: - name: Install goversioninfo run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e - name: Generate windows syso amd64 - run: goversioninfo -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_amd64.syso + run: goversioninfo -icon client/ui/build/windows/icon.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_amd64.syso - name: Generate windows syso arm64 - 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 + run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.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 }} @@ -254,15 +255,23 @@ jobs: id: tag_and_push_images if: | (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name == 'push' && github.ref == 'refs/heads/main') + (github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release-'))) run: | set -euo pipefail + # $GITHUB_REF / $GITHUB_EVENT_NAME are read from the runner + # environment rather than substituted into this script with the + # workflow expression syntax: branch names may legally contain + # $(…), and interpolating github.ref would execute it. resolve_tags() { - if [[ "${{ github.event_name }}" == "pull_request" ]]; then + if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then echo "pr-${{ github.event.pull_request.number }}" - else + elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then echo "main sha-$(git rev-parse --short HEAD)" + else + # Release branches get an immutable sha-* tag only — the floating + # "main" tag must never move from a release branch. + echo "sha-$(git rev-parse --short HEAD)" fi } @@ -293,8 +302,11 @@ jobs: ${{ steps.goreleaser.outputs.artifacts }} JSON + # dockers_v2 artifacts have no top-level goarch field, so match the + # per-platform -amd64 tag suffix instead; it works for both the old + # dockers and the new dockers_v2 image naming. mapfile -t src_images < <( - jq -r '.[] | select(.type == "Docker Image") | select(.goarch == "amd64") | .name | select(startswith("ghcr.io/"))' /tmp/goreleaser-artifacts.json + jq -r '.[] | select(.type == "Docker Image") | .name | select(startswith("ghcr.io/") and endswith("-amd64"))' /tmp/goreleaser-artifacts.json ) for src in "${src_images[@]}"; do @@ -347,7 +359,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 @@ -374,12 +386,12 @@ jobs: fi - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false - name: Cache Go modules - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ~/go/pkg/mod @@ -394,8 +406,18 @@ jobs: - name: check git status run: git --no-pager diff --exit-code + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Set up pnpm + uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + with: + version: 11 + - name: Install dependencies - run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64 + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev gcc-mingw-w64-x86-64 - name: Decode GPG signing key if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository @@ -414,13 +436,19 @@ jobs: echo "/tmp/llvm-mingw-20250709-ucrt-ubuntu-22.04-x86_64/bin" >> $GITHUB_PATH - name: Install goversioninfo run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e + - name: Install wails3 CLI + # Version derived from go.mod so the binding generator always matches + # the wails runtime the binary links against. + run: | + WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3) + go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION - name: Generate windows syso amd64 - run: goversioninfo -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_amd64.syso + run: goversioninfo -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -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_amd64.syso - name: Generate windows syso arm64 - 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 + run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -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 }} @@ -456,6 +484,132 @@ jobs: path: dist/ retention-days: 3 + release_ui_gtk3: + # Legacy GTK3/WebKit2GTK 4.1 UI build for distros without WebKitGTK 6.0 + # (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). Runs on ubuntu-22.04 so + # the binary links against the oldest supported glibc. + runs-on: ubuntu-22.04 + outputs: + release_ui_gtk3_artifact_url: ${{ steps.upload_release_ui_gtk3.outputs.artifact-url }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 # It is required for GoReleaser to work properly + persist-credentials: false + + - name: Parse semver string + id: semver_parser + uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 + + - 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@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: false + - name: Cache Go modules + # Restore-only from the release_ui cache written by trusted runs; the + # module cache is identical (same go.sum) and stale build-cache + # entries just miss. + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-ui-go-releaser-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-ui-go-releaser- + + - name: Install modules + run: go mod tidy + + - name: check git status + run: git --no-pager diff --exit-code + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22' + + - name: Set up pnpm + uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + with: + version: 11 + + - name: Install dependencies + run: sudo apt update && sudo apt install -y -q libgtk-3-dev libwebkit2gtk-4.1-dev + + - name: Decode GPG signing key + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + env: + GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }} + run: | + echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc + echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV + + - name: Install wails3 CLI + # Version derived from go.mod so the binding generator always matches + # the wails runtime the binary links against. + # -tags gtk3: the CLI links the wails runtime's cgo packages, and the + # default tags request gtk4/webkitgtk-6.0 pkg-config entries that do + # not exist on ubuntu-22.04. + run: | + WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3) + go install -tags gtk3 github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 + with: + version: ${{ env.GORELEASER_VER }} + args: release --config .goreleaser_ui_gtk3.yaml --clean ${{ env.flags }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} + UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} + GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }} + NFPM_NETBIRD_UI_RPM_GTK3_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 ' + dnf install -y -q rpm-sign curl >/dev/null 2>&1 + curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key + rpm --import /tmp/rpm-pub.key + echo "=== Verifying RPM signatures ===" + for rpm_file in /dist/*.rpm; do + [ -f "$rpm_file" ] || continue + echo "--- $(basename $rpm_file) ---" + rpm -K "$rpm_file" + done + ' + - name: Clean up GPG key + if: always() + run: rm -f /tmp/gpg-rpm-signing-key.asc + - name: upload non tags for debug purposes + id: upload_release_ui_gtk3 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: release-ui-gtk3 + path: dist/ + retention-days: 3 + release_ui_darwin: runs-on: macos-latest outputs: @@ -464,17 +618,17 @@ jobs: - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} run: echo "flags=--snapshot" >> $GITHUB_ENV - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # It is required for GoReleaser to work properly persist-credentials: false - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" cache: false - name: Cache Go modules - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: | ~/go/pkg/mod @@ -486,9 +640,23 @@ jobs: run: go mod tidy - name: check git status run: git --no-pager diff --exit-code + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Set up pnpm + uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + with: + version: 11 + - name: Install wails3 CLI + # Version derived from go.mod so the binding generator always matches + # the wails runtime the binary links against. + run: | + WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3) + go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION - 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 }} @@ -522,7 +690,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 @@ -534,13 +702,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 @@ -573,23 +741,6 @@ jobs: - name: Move wintun.dll into dist run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\ - - name: Download Mesa3D (amd64 only) - id: download-mesa3d - if: matrix.arch == 'amd64' - uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 - with: - url: https://pkgs.netbird.io/mesa3d/MesaForWindows-x64-20.1.8.7z - destination: ${{ env.downloadPath }}\mesa3d.7z - sha256: 71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9 - - - name: Extract Mesa3D driver (amd64 only) - if: matrix.arch == 'amd64' - run: 7z x -o"${{ env.downloadPath }}" "${{ env.downloadPath }}/mesa3d.7z" - - - name: Move opengl32.dll into dist (amd64 only) - if: matrix.arch == 'amd64' - run: mv ${{ env.downloadPath }}\opengl32.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\ - - name: Download EnVar plugin for NSIS uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 with: @@ -612,6 +763,28 @@ jobs: if: matrix.arch == 'amd64' run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z" + - name: Set up Go for wails3 CLI + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + cache: false + + - name: Install wails3 CLI + # Version derived from go.mod so the bootstrapper payload always + # matches the wails runtime the binary links against. + shell: bash + run: | + WAILS_VERSION=$(go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3) + go install github.com/wailsapp/wails/v3/cmd/wails3@$WAILS_VERSION + + - name: Stage WebView2 bootstrapper for installers + # Both client/installer.nsis and client/netbird.wxs reference + # client/MicrosoftEdgeWebview2Setup.exe. wails3 writes it there. + # The signing pipeline (netbirdio/sign-pipelines) does the same + # step for release builds; this mirrors it for PR sanity testing. + shell: bash + run: wails3 generate webview2bootstrapper -dir client + - name: Build NSIS installer shell: pwsh env: @@ -650,7 +823,7 @@ jobs: comment_release_artifacts: name: Comment release artifacts runs-on: ubuntu-latest - needs: [release, release_ui, release_ui_darwin] + needs: [release, release_ui, release_ui_gtk3, release_ui_darwin] if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} permissions: contents: read @@ -662,12 +835,14 @@ jobs: env: RELEASE_RESULT: ${{ needs.release.result }} RELEASE_UI_RESULT: ${{ needs.release_ui.result }} + RELEASE_UI_GTK3_RESULT: ${{ needs.release_ui_gtk3.result }} RELEASE_UI_DARWIN_RESULT: ${{ needs.release_ui_darwin.result }} RELEASE_ARTIFACT_URL: ${{ needs.release.outputs.release_artifact_url }} LINUX_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.linux_packages_artifact_url }} WINDOWS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.windows_packages_artifact_url }} MACOS_PACKAGES_ARTIFACT_URL: ${{ needs.release.outputs.macos_packages_artifact_url }} RELEASE_UI_ARTIFACT_URL: ${{ needs.release_ui.outputs.release_ui_artifact_url }} + RELEASE_UI_GTK3_ARTIFACT_URL: ${{ needs.release_ui_gtk3.outputs.release_ui_gtk3_artifact_url }} RELEASE_UI_DARWIN_ARTIFACT_URL: ${{ needs.release_ui_darwin.outputs.release_ui_darwin_artifact_url }} GHCR_IMAGES_MARKDOWN: ${{ needs.release.outputs.ghcr_images }} with: @@ -690,6 +865,7 @@ jobs: ['Windows packages', process.env.WINDOWS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT], ['macOS packages', process.env.MACOS_PACKAGES_ARTIFACT_URL, process.env.RELEASE_RESULT], ['UI artifacts', process.env.RELEASE_UI_ARTIFACT_URL, process.env.RELEASE_UI_RESULT], + ['UI GTK3 artifacts', process.env.RELEASE_UI_GTK3_ARTIFACT_URL, process.env.RELEASE_UI_GTK3_RESULT], ['UI macOS artifacts', process.env.RELEASE_UI_DARWIN_ARTIFACT_URL, process.env.RELEASE_UI_DARWIN_RESULT], ]; @@ -746,7 +922,7 @@ jobs: trigger_signer: runs-on: ubuntu-latest - needs: [release, release_ui, release_ui_darwin, test_windows_installer] + needs: [release, release_ui, release_ui_gtk3, release_ui_darwin, test_windows_installer] if: startsWith(github.ref, 'refs/tags/') steps: - name: Trigger binaries sign pipelines diff --git a/.github/workflows/sync-tag.yml b/.github/workflows/sync-tag.yml index d99f88b54..088e538d5 100644 --- a/.github/workflows/sync-tag.yml +++ b/.github/workflows/sync-tag.yml @@ -9,21 +9,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} cancel-in-progress: true -# Receiving workflows (cloud sync-tag, mobile bump-netbird) expect the short -# tag form (e.g. v0.30.0), not refs/tags/v0.30.0 — github.ref_name, not github.ref. +# The receiving bump-netbird workflows expect the short tag form +# (e.g. v0.30.0), not refs/tags/v0.30.0 — github.ref_name, not github.ref. jobs: - trigger_sync_tag: - runs-on: ubuntu-latest - steps: - - name: Trigger release tag sync - uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2 - with: - workflow: sync-tag.yml - ref: main - repo: ${{ secrets.UPSTREAM_REPO }} - token: ${{ secrets.NC_GITHUB_TOKEN }} - inputs: '{ "tag": "${{ github.ref_name }}" }' - trigger_android_bump: runs-on: ubuntu-latest if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index 9ad1f2f67..1313379ee 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: paths: - "infrastructure_files/**" @@ -68,17 +69,17 @@ jobs: run: sudo apt-get install -y curl - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" - name: Cache Go modules - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} @@ -207,7 +208,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 +217,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 +226,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 @@ -249,78 +250,44 @@ jobs: docker compose exec management ls -l /var/lib/netbird/ | grep -i GeoLite2-City_[0-9]*.mmdb docker compose exec management ls -l /var/lib/netbird/ | grep -i geonames_[0-9]*.db - test-getting-started-script: + test-legacy-getting-started-scripts: runs-on: ubuntu-latest steps: - - name: Install jq - 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 - - name: run script with Zitadel PostgreSQL - run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh - - - name: test Caddy file gen postgres - run: test -f Caddyfile - - - name: test docker-compose file gen postgres - run: test -f docker-compose.yml - - - name: test management.json file gen postgres - run: test -f management.json - - - name: test turnserver.conf file gen postgres + - name: Verify fresh-install session cookie key hardening run: | - set -x - test -f turnserver.conf - grep external-ip turnserver.conf + grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh + grep -Fxq ' sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"' infrastructure_files/getting-started.sh + grep -Fxq ' install -m 600 /dev/null config.yaml' infrastructure_files/getting-started.sh + grep -Fxq ' openssl rand -base64 32' infrastructure_files/getting-started-enterprise.sh + grep -Fxq ' NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)' infrastructure_files/getting-started-enterprise.sh + grep -Fxq ' sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"' infrastructure_files/getting-started-enterprise.sh - - name: test zitadel.env file gen postgres - run: test -f zitadel.env - - - name: test dashboard.env file gen postgres - run: test -f dashboard.env - - - name: test relay.env file gen postgres - run: test -f relay.env - - - name: test zdb.env file gen postgres - run: test -f zdb.env - - - name: Postgres run cleanup + - name: Verify Dex retirement notice run: | - docker compose down --volumes --rmi all - rm -rf docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json zdb.env + if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then + echo "Expected the retired Dex installer to fail" + exit 1 + fi + test ! -s stdout.txt + grep -Fq "Dex support is not deprecated." stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/local" stderr.txt + grep -Fq "removed in NetBird v0.80" stderr.txt - - name: run script with Zitadel CockroachDB - run: bash -x infrastructure_files/getting-started-with-zitadel.sh - env: - NETBIRD_DOMAIN: use-ip - ZITADEL_DATABASE: cockroach - - - name: test Caddy file gen CockroachDB - run: test -f Caddyfile - - - name: test docker-compose file gen CockroachDB - run: test -f docker-compose.yml - - - name: test management.json file gen CockroachDB - run: test -f management.json - - - name: test turnserver.conf file gen CockroachDB + - name: Verify Zitadel retirement notice run: | - set -x - test -f turnserver.conf - grep external-ip turnserver.conf - - - name: test zitadel.env file gen CockroachDB - run: test -f zitadel.env - - - name: test dashboard.env file gen CockroachDB - run: test -f dashboard.env - - - name: test relay.env file gen CockroachDB - run: test -f relay.env + if bash infrastructure_files/getting-started-with-zitadel.sh >stdout.txt 2>stderr.txt; then + echo "Expected the retired Zitadel installer to fail" + exit 1 + fi + test ! -s stdout.txt + grep -Fq "Zitadel support and existing Zitadel deployments are not deprecated." stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/zitadel" stderr.txt + grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-guide" stderr.txt + grep -Fq "removed in NetBird v0.80" stderr.txt diff --git a/.github/workflows/ui-translations.yml b/.github/workflows/ui-translations.yml new file mode 100644 index 000000000..7d3b12f2d --- /dev/null +++ b/.github/workflows/ui-translations.yml @@ -0,0 +1,42 @@ +name: UI Translations + +on: + pull_request: + paths: + - "client/ui/i18n/locales/**" + - "client/ui/i18n/check-translations.mjs" + - ".github/workflows/ui-translations.yml" + push: + branches: + - main + paths: + - "client/ui/i18n/locales/**" + - "client/ui/i18n/check-translations.mjs" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + cancel-in-progress: true + +jobs: + check-translations: + name: Check translation key parity + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + # English (en) is the source of truth for translation keys; every other + # locale declared in _index.json must carry the exact same key set. + - name: Check translation key parity + run: node client/ui/i18n/check-translations.mjs diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml index 318a127dd..5f21472e5 100644 --- a/.github/workflows/wasm-build-validation.yml +++ b/.github/workflows/wasm-build-validation.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release-*" pull_request: concurrency: @@ -19,15 +20,15 @@ jobs: GOARCH: wasm steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" - name: Install dependencies - run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev + run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev libgl1-mesa-dev xorg-dev libpcap-dev - name: Install golangci-lint uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1 with: @@ -44,11 +45,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: "go.mod" - name: Build Wasm client diff --git a/.gitignore b/.gitignore index 783fe77f3..305f3cb50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.claude .idea .run *.iml diff --git a/.golangci.yaml b/.golangci.yaml index 900af4ac0..e350b9de7 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -114,6 +114,16 @@ linters: - linters: - staticcheck text: "QF1012" + # client/ui/main.go uses //go:embed all:frontend/dist; the + # directory is populated by `pnpm build` in the release pipeline + # and missing at lint time, so the embed parses to "no matching + # files found" — surfaced by golangci-lint's typecheck pre-pass. + # Suppress just that one diagnostic; the rest of the package + # (services/, tray.go, grpc.go, ...) still gets linted normally. + - linters: + - typecheck + path: client/ui/main\.go + text: "pattern all:frontend/dist" paths: - third_party$ - builtin$ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c068f51d1..c5d260376 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -212,6 +212,7 @@ nfpms: description: Netbird client. homepage: https://netbird.io/ license: BSD-3-Clause + vendor: NetBird id: netbird_deb bindir: /usr/bin builds: @@ -226,6 +227,7 @@ nfpms: description: Netbird client. homepage: https://netbird.io/ license: BSD-3-Clause + vendor: NetBird id: netbird_rpm bindir: /usr/bin builds: @@ -271,8 +273,8 @@ dockers_v2: - netbirdio/netbird - ghcr.io/netbirdio/netbird tags: - - "v{{ .Version }}-rootless" - - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" + - "{{ .Version }}-rootless" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}" dockerfile: client/Dockerfile-rootless extra_files: - client/netbird-entrypoint.sh @@ -462,9 +464,20 @@ 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: + # The signing pipeline (netbirdio/sign-pipelines, dispatched by + # trigger_signer) marks the release latest once the Windows and macOS + # artifacts are signed. Without this override goreleaser marks it latest + # at publish time, while those artifacts are still unsigned. + make_latest: false + # Mark x.y.z-rc.* and other prerelease tags as prereleases on GitHub. + prerelease: auto 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 6f9b7c059..1c5bc41ac 100644 --- a/.goreleaser_ui.yaml +++ b/.goreleaser_ui.yaml @@ -2,6 +2,15 @@ version: 2 env: - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }} project_name: netbird-ui + +before: + hooks: + # Bindings are gitignored; regenerate before the frontend build so + # the @wailsio/runtime Vite plugin can resolve them (vite refuses to + # build without them). + - sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts' + - sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build' + builds: - id: netbird-ui dir: client/ui @@ -15,6 +24,8 @@ builds: 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 }}" + tags: + - production - id: netbird-ui-windows-amd64 dir: client/ui @@ -30,6 +41,8 @@ builds: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser - -H windowsgui mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production - id: netbird-ui-windows-arm64 dir: client/ui @@ -46,6 +59,8 @@ builds: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser - -H windowsgui mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - production archives: - id: linux-arch @@ -62,6 +77,8 @@ nfpms: - maintainer: Netbird description: Netbird client UI. homepage: https://netbird.io/ + license: BSD-3-Clause + vendor: NetBird id: netbird_ui_deb package_name: netbird-ui builds: @@ -71,16 +88,21 @@ nfpms: scripts: postinstall: "release_files/ui-post-install.sh" contents: - - src: client/ui/build/netbird.desktop - dst: /usr/share/applications/netbird.desktop - - src: client/ui/assets/netbird.png + - src: client/ui/build/linux/netbird.desktop + dst: /usr/share/applications/org.wails.netbird.desktop + - src: client/ui/build/appicon.png dst: /usr/share/pixmaps/netbird.png dependencies: - - netbird + - netbird (>= 0.75.0) + - libgtk-4-1 (>= 4.14) + - libwebkitgtk-6.0-4 + - xdg-utils - maintainer: Netbird description: Netbird client UI. homepage: https://netbird.io/ + license: BSD-3-Clause + vendor: NetBird id: netbird_ui_rpm package_name: netbird-ui builds: @@ -90,12 +112,16 @@ nfpms: scripts: postinstall: "release_files/ui-post-install.sh" contents: - - src: client/ui/build/netbird.desktop - dst: /usr/share/applications/netbird.desktop - - src: client/ui/assets/netbird.png + - src: client/ui/build/linux/netbird.desktop + dst: /usr/share/applications/org.wails.netbird.desktop + - src: client/ui/build/appicon.png dst: /usr/share/pixmaps/netbird.png dependencies: - - netbird + - netbird >= 0.75.0 + - (gtk4 >= 4.14 or libgtk-4-1 >= 4.14) + - (webkitgtk6.0 or libwebkitgtk-6_0-4) + - xdg-utils + rpm: signature: key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' @@ -118,3 +144,11 @@ uploads: target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }} username: dev@wiretrustee.com method: PUT + +release: + # Uploads into the release created by the main .goreleaser.yaml run. + # make_latest stays false everywhere: the signing pipeline + # (netbirdio/sign-pipelines) marks the release latest after the Windows + # and macOS artifacts are signed. + make_latest: false + prerelease: auto diff --git a/.goreleaser_ui_darwin.yaml b/.goreleaser_ui_darwin.yaml index 0a0082075..8ca0e8da6 100644 --- a/.goreleaser_ui_darwin.yaml +++ b/.goreleaser_ui_darwin.yaml @@ -1,6 +1,15 @@ version: 2 project_name: netbird-ui + +before: + hooks: + # Bindings are gitignored; regenerate before the frontend build so + # the @wailsio/runtime Vite plugin can resolve them (vite refuses to + # build without them). + - sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts' + - sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build' + builds: - id: netbird-ui-darwin dir: client/ui @@ -21,7 +30,7 @@ builds: - -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 }}" tags: - - load_wgnt_from_rsrc + - production universal_binaries: - id: netbird-ui-darwin @@ -34,3 +43,11 @@ checksum: name_template: "{{ .ProjectName }}_darwin_checksums.txt" changelog: disable: true + +release: + # Uploads into the release created by the main .goreleaser.yaml run. + # make_latest stays false everywhere: the signing pipeline + # (netbirdio/sign-pipelines) marks the release latest after the Windows + # and macOS artifacts are signed. + make_latest: false + prerelease: auto diff --git a/.goreleaser_ui_gtk3.yaml b/.goreleaser_ui_gtk3.yaml new file mode 100644 index 000000000..a9b2ca650 --- /dev/null +++ b/.goreleaser_ui_gtk3.yaml @@ -0,0 +1,144 @@ +version: 2 +env: + - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }} +project_name: netbird-ui + +before: + hooks: + # Bindings are gitignored; regenerate before the frontend build so + # the @wailsio/runtime Vite plugin can resolve them (vite refuses to + # build without them). + # -f '-tags gtk3': the generator type-checks client/ui, whose cgo imports + # would otherwise resolve gtk4/webkitgtk-6.0 pkg-config entries that do + # not exist on ubuntu-22.04. + - sh -c 'cd client/ui && wails3 generate bindings -clean=true -ts -f "-tags gtk3"' + - sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build' + +builds: + # Legacy GTK3 / WebKit2GTK 4.1 build for distros without WebKitGTK 6.0 + # (Ubuntu 22.04, Debian 12, RHEL 9, Fedora <=39). The gtk3 tag flips the + # Wails Linux backend to the GTK3 stack and swaps our GTK4-only XEmbed + # tray host for the pure-Go stub (client/ui/xembed_host_gtk3_linux.go). + # Must be built on the oldest supported glibc (ubuntu-22.04 runner). + - id: netbird-ui-gtk3 + dir: client/ui + binary: netbird-ui + env: + - CGO_ENABLED=1 + goos: + - linux + goarch: + - amd64 + 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 }}" + tags: + - production + - gtk3 + +archives: + - id: linux-gtk3-arch + name_template: "{{ .ProjectName }}-linux-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + builds: + - netbird-ui-gtk3 + +nfpms: + # Mutually-exclusive alternative to the GTK4 netbird-ui package -- both + # ship the same /usr/bin/netbird-ui from the shared stable/yum repos, so + # this one carries its own name and conflicts with the GTK4 package. + - maintainer: Netbird + description: Netbird client UI. + homepage: https://netbird.io/ + license: BSD-3-Clause + vendor: NetBird + id: netbird_ui_deb_gtk3 + package_name: netbird-ui-gtk3 + file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + builds: + - netbird-ui-gtk3 + formats: + - deb + scripts: + postinstall: "release_files/ui-post-install.sh" + contents: + - src: client/ui/build/linux/netbird.desktop + dst: /usr/share/applications/org.wails.netbird.desktop + - src: client/ui/build/appicon.png + dst: /usr/share/pixmaps/netbird.png + conflicts: + - netbird-ui + replaces: + - netbird-ui + dependencies: + - netbird (>= 0.75.0) + - libgtk-3-0 + - libwebkit2gtk-4.1-0 + - xdg-utils + + - maintainer: Netbird + description: Netbird client UI. + homepage: https://netbird.io/ + license: BSD-3-Clause + vendor: NetBird + id: netbird_ui_rpm_gtk3 + package_name: netbird-ui-gtk3 + file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + builds: + - netbird-ui-gtk3 + formats: + - rpm + scripts: + postinstall: "release_files/ui-post-install.sh" + contents: + - src: client/ui/build/linux/netbird.desktop + dst: /usr/share/applications/org.wails.netbird.desktop + - src: client/ui/build/appicon.png + dst: /usr/share/pixmaps/netbird.png + # No `replaces` here: nfpm maps it to rpm Obsoletes, which would make + # dnf swap installed GTK4 netbird-ui packages for this one on upgrade. + conflicts: + - netbird-ui + dependencies: + - netbird >= 0.75.0 + - (gtk3 or libgtk-3-0) + - (webkit2gtk4.1 or libwebkit2gtk-4_1-0) + - xdg-utils + + rpm: + signature: + key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' + +# The GTK4 UI job shares project_name, so the default checksum file name would +# collide with it on the shared GitHub release. +checksum: + name_template: "{{ .ProjectName }}_gtk3_checksums.txt" + +changelog: + disable: true + +uploads: + - name: debian + skip: "{{ .Env.SKIP_PUBLISH }}" + ids: + - netbird_ui_deb_gtk3 + mode: archive + target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package= + username: dev@wiretrustee.com + method: PUT + + - name: yum + skip: "{{ .Env.SKIP_PUBLISH }}" + ids: + - netbird_ui_rpm_gtk3 + mode: archive + target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }} + username: dev@wiretrustee.com + method: PUT + +release: + # Uploads into the release created by the main .goreleaser.yaml run. + # make_latest stays false everywhere: the signing pipeline + # (netbirdio/sign-pipelines) marks the release latest after the Windows + # and macOS artifacts are signed. + make_latest: false + prerelease: auto diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..5497acb15 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,783 @@ +# NetBird Agent Guidelines + +**NetBird** is an open source connectivity platform: a WireGuard®-based overlay +network with a control plane. The **agent** (`client/`) runs on user machines as +a privileged daemon and manages the WireGuard interface, routing, firewall, and +DNS. **Management** (`management/`) is the control plane and REST/gRPC API, +**Signal** (`signal/`) brokers peer handshakes, **Relay** (`relay/`) carries +traffic when a direct tunnel is impossible, and **Proxy** (`proxy/`) is the +identity-aware proxy behind Agent Network. + +This file applies to the whole repository, and is the single source of truth for +agent guidance here. `CLAUDE.md` is a one-line pointer to it — keep the guidance +in this file, not duplicated there. + +## Contents + +- [STOP and ask the user before](#stop-and-ask-the-user-before) +- [Quick reference](#quick-reference) +- [Structure](#structure) +- [Where to look](#where-to-look) +- [Security](#security) +- [Agent conventions](#agent-conventions) +- [Repo-wide principles](#repo-wide-principles) +- [Type safety](#type-safety) +- [Concurrency and lifecycle](#concurrency-and-lifecycle) +- [Error handling](#error-handling) +- [Comments](#comments) +- [Testing](#testing) +- [Pitfalls](#pitfalls) +- [Commits, PRs, releases](#commits-prs-releases) +- [After you push: CI and review bots](#after-you-push-ci-and-review-bots) +- [Discussion and support](#discussion-and-support) + +## STOP and ask the user before + +- **Opening a pull request for anything beyond a trivial fix, without an agreed + ticket.** Ask the user directly: *"Is there a discussion or issue for this + change?"* NetBird is discussion-first — community reports start in + [Discussions](https://github.com/netbirdio/netbird/discussions), DevRel + validates them, and only validated discussions become issues. A PR that + changes behavior with no linked issue may be closed on arrival. If there is no + ticket, offer to draft the discussion post **instead of** the PR, and wait for + the user's call. Only typos, broken links, documentation corrections, and + one-line fixes that already have an issue can skip this. +- **Designing in any high-risk area** (see + [CONTRIBUTING.md](CONTRIBUTING.md#high-risk-areas)): public API and OpenAPI + schema, gRPC protos, behavior existing deployments would notice after an + upgrade, peer connectivity (ICE, NAT traversal, relay selection, WireGuard® or + Rosenpass key handling), client system integration (routing, firewall, DNS, + interface), authentication and authorization, CLI or service flags, config + file format, daemon IPC, store schema and migrations, or a new feature. The + design gets agreed in the ticket before code is written. +- **Writing a store migration or changing a persisted model.** Migrations are + one-way in the field and both the GORM and pgx paths may need the change. +- **Hand-editing generated code.** `*.pb.go`, `*.gen.go`, and mocks are outputs. + Edit the source (`.proto`, `openapi.yml`) and rerun the matching + `generate.sh`. +- **Adding, removing, or bumping a dependency**, and never vendor a fork. +- **Weakening a security control** — authentication, authorization, certificate + verification, privilege dropping, or peer identity checks — even when it is + the fastest way to make a test pass. +- **Force-pushing to `main`**, force-pushing any branch that is already under + review, amending pushed commits, or bypassing hooks with `--no-verify`. + +## Quick reference + +```bash +# Build +go build ./... +cd client && CGO_ENABLED=0 go build . # agent +cd management && go build . # management service +cd signal && go build . # signal service + +# Verify (run before every push) +go fmt ./... +make lint # golangci-lint on files changed vs origin/main (also the pre-push hook) +make lint-all # full-repository lint, matches CI +make test-unit # host-safe unit tests, -tags devcert, no sudo +make test-privileged # privileged-tagged suite in a Docker container with NET_ADMIN +make setup-hooks # wire make lint into .githooks/pre-push + +# Narrow runs +go test ./client/internal/dns/... +go test -race -run TestPeerConn ./client/internal/peer/... +PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged + +# Code generation (never hand-edit the output) +./shared/management/http/api/generate.sh # REST types from openapi.yml +./shared/management/proto/generate.sh +./shared/signal/proto/generate.sh +./client/proto/generate.sh +./flow/proto/generate.sh + +# Run locally (lab only, never on a machine you rely on) +sudo ./client/netbird up --log-level debug --log-file console +sudo ./client/netbird down # teardown: restores routing, firewall, DNS +./signal/signal run --log-level debug --log-file console +./management/management management --log-level debug --log-file console --config ./management.json +``` + +`netbird up` needs root and rewrites the host's routing table, firewall rules, +DNS configuration, and WireGuard® interface. Run it only in a disposable test +environment (a VM, container, or throwaway host) that you can rebuild, never on +a workstation or server whose connectivity matters. Run `sudo netbird down` +before you stop working, before rebuilding the binary, and on every failure +path, so the host's networking state is restored instead of left half-applied. +See [Pitfalls](#pitfalls) for why cleanup on every exit path matters. + +## Structure + +```text +netbird/ +├── client/ NetBird agent +│ ├── cmd/ agent CLI +│ ├── internal/ agent business logic (engine, peer, dns, routemanager, ...) +│ ├── server/ daemon for background execution +│ ├── proto/ daemon gRPC protos +│ ├── iface/ WireGuard® interface management +│ ├── firewall/ nftables, iptables, pf, WFP, userspace backends +│ ├── ssh/ built-in SSH server and client +│ ├── ui/ desktop UI (Wails v3 + React) +│ ├── android/, ios/ mobile bindings +│ ├── wasm/ WebAssembly build +│ └── mdm/, system/ MDM policy, host information +├── management/ control plane +│ └── server/ account, peer, groups, networks, posture, permissions, +│ settings, store, http (REST), idp, integrations, migration +├── signal/ handshake broker (peer/, server/) +├── relay/ relay service (protocol/, server/, healthcheck/) +├── proxy/ identity-aware proxy (llm/, acme/, accesslog/, middleware/, tcp/, udp/) +├── agent-network/ Agent Network overview +├── shared/ imported by both agent and services +│ ├── management/ proto/, client/, http/api (OpenAPI + generated types) +│ ├── signal/ proto/, client/ +│ └── relay/, auth/, sshauth/, metrics/ +├── e2e/ end-to-end suites and harness +├── encryption/, dns/, route/, stun/, sharedsock/, util/, flow/ +├── infrastructure_files/ docker compose and getting-started templates +└── release_files/ files packaged into releases +``` + +## Where to look + +| Task | Location | +| --------------------------- | ------------------------------------------------------------ | +| REST API / OpenAPI | `shared/management/http/api/` + `management/server/http/` | +| Management gRPC protocol | `shared/management/proto/` | +| Signal protocol | `shared/signal/proto/` | +| Daemon IPC protocol | `client/proto/` | +| Peer connection and NAT | `client/internal/peer/` | +| Network map handling | `client/internal/engine.go`, `shared/management/networkmap/` | +| Routing | `client/internal/routemanager/`, `route/` | +| Firewall backends | `client/firewall/` | +| DNS | `client/internal/dns/`, `dns/` | +| WireGuard® interface | `client/iface/` | +| Persistence and migrations | `management/server/store/`, `management/server/migration/` | +| IdP integrations | `management/server/idp/` | +| Permissions model | `management/server/permissions/` | +| LLM routing / Agent Network | `proxy/internal/llm/`, `agent-network/` | +| End-to-end tests | `e2e/` | + +## Security + +### Never fail open + +When a security check — access control, an IP restriction, an auth decision — +hits an error such as an unparseable value, an unavailable lookup, or a state it +does not recognize, it must **deny**. Never skip the check or allow the request +through because the check itself failed, and make the `default` and unknown cases +of a security-related `switch` deny rather than fall through. + +### Daemon RPC input is untrusted + +The agent runs as root (LocalSystem on Windows), so a daemon RPC crosses a +privilege boundary: treat every field as untrusted input rather than as something +the UI or CLI validated on the way in. + +When you add or change an RPC, ask what the handler does with caller input while +running as root. If the answer touches a filesystem path, a URL or host, or a +privileged state change, it needs a gate **in the handler** — a check in the client +that normally calls it is not a check at all. + +- **A caller-supplied path the daemon opens.** Never `os.Open` it as root. + Constrain it, then open it *as the caller* with `ipcauth.OpenOwnedFile`, which + opens `O_NOFOLLOW`, requires a regular file, and refuses a file the caller does + not own — so a symlink or hardlink aimed at a root-only file is rejected. +- **A caller-supplied URL or host the daemon fetches.** Restrict the scheme and + allow only known hosts for unprivileged callers. Prefer a lexical host + allowlist plus TLS verification over "resolve the host, then reject private + IPs": the resolve-then-trust pattern has a DNS-rebinding race (public IP at + check time, attacker IP at connect time), while a name allowlist has no IP + check to race. Never accept `http://` where `https://` is expected. +- **A privileged state change** (SSH root login, management URL, deregistration) + gates on the caller identity from `ipcauth.CallerIdentity(ctx)`. + +Caller identity comes from the kernel — `SO_PEERCRED`, `LOCAL_PEERCRED`, or the +named-pipe client token — and never from an RPC field. When +`ipcauth.CallerIdentity` reports that it could not determine an identity, **deny**; +do not fall back to treating the caller as the transport peer. + +## Agent conventions + +### Three networking modes + +Where packets actually flow depends on the mode the agent is running in. The +three are not interchangeable, so establish which one a change applies to — and +what it should do in the other two — before you write it. + +- **kernel mode** (Linux only): in-kernel WireGuard®. The kernel handles both + peer-to-peer and routed traffic, and ACLs are iptables or nftables rules. The + client programs kernel facilities but never sees the traffic itself. +- **userspace mode** (wireguard-go with a TUN): wireguard-go runs in-process. The + kernel handles peer-to-peer traffic once it leaves the TUN, while routed traffic + — exit nodes and network routes — goes through the userspace forwarder, which + terminates the connection and re-establishes it over OS sockets. Used on + platforms without kernel WireGuard® or when the user opts out. +- **netstack mode**: wireguard-go in-process with no TUN and no kernel + networking. The forwarder does all routing by stitching userspace sockets, and + listeners such as the embedded SSH and DNS servers bind on a gVisor netstack. + Used where the process cannot create a TUN device, such as the embedded client + (`client/embed/`) and the WASM build. + +### The overlay interface is not "WireGuard" + +Do not put "WireGuard" in identifiers or comments unless the code is genuinely +coupled to WireGuard® specifically — a wireguard-go call, a handshake field, a +kernel WireGuard® netlink attribute. For the interface, the host, peers, or +traffic in general, say "the NetBird interface", "the interface", or "the overlay". +Most firewall, routing, and DNS code is transport-agnostic, so a WireGuard® +reference there is simply inaccurate and rots as the transports change. + +### IPv6 is a soft feature + +The IPv6 overlay is opt-in dual-stack, and capability can change at runtime. Treat +it as soft rather than a requirement: + +- Gate local v6 paths on the interface accessor (`wgIface.Address().HasIPv6()`), + not on raw state fields, and skip the v6 path when the host has no v6 rather + than returning an error. +- Treat an empty or unparseable peer v6 address as "no v6 for that peer" and skip + it, keeping the v4 path working. +- Never let a missing v6 break v4. Fail-closed is for security checks; a + capability mismatch skips the v6 work and carries on. + +### Environment variables + +Name the variable in a constant and parse booleans with `strconv.ParseBool` rather +than comparing strings inline, so an unexpected value is logged instead of +silently meaning false: + +```go +const EnvDisableFeature = "NB_DISABLE_FEATURE" + +func isDisabledByEnv() bool { + val := os.Getenv(EnvDisableFeature) + if val == "" { + return false + } + disabled, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s: %v", EnvDisableFeature, err) + return false + } + return disabled +} +``` + +### Validating against protocol specs + +When a change depends on what a protocol actually mandates, read the specification +text from the [IETF datatracker](https://datatracker.ietf.org/) rather than a +summary, and check that you have the current RFC — the widely cited one for a +protocol is often superseded. Cite the section, not just the document, so a +reviewer can jump straight to the rule. + +## Repo-wide principles + +1. **Run `go fmt` on every modified Go file.** Formatting is not optional. +2. **Zero unaddressed linter warnings.** Fix what `golangci-lint` reports on code + you touch, and delete imports, helpers, and parameters your refactor orphaned. + Exception: unused parameters in shared code may be consumed by builds outside + this repository — do not remove them, ask instead. +3. **Function comments are mandatory for exported functions**, written as full + sentences with a period, starting with the identifier name. +4. **Prefer private functions and constants.** Export only what a caller outside + the package genuinely needs. +5. **Early returns and guard clauses.** Handle errors and edge cases first + instead of nesting `if`/`else` chains. +6. **Split complex functions.** If a function trips a complexity warning, break + it into named helpers rather than silencing the warning. +7. **Avoid LLM-slop tells:** em dashes, hedging narration, restating the diff in + prose, trailing summaries. Defaults, not absolute bans. Applies to code, + comments, commit messages, and PR descriptions alike. +8. **Concurrency: do a two-pass race analysis after every change** that touches + shared state, including reads of existing maps and slices. Guard them with a + mutex (or an atomic or channel where that fits better), keep critical + sections short, and run `go test -race` on the touched packages. See + [Concurrency and lifecycle](#concurrency-and-lifecycle) for the failure modes + to check for. +9. **Cross-platform builds must keep working.** The agent targets Linux, macOS, + Windows, FreeBSD, Android, and iOS. When you add a platform-specific file, + add the counterpart or a build-tagged fallback for the others. +10. **Never hand-edit generated files.** Change the source and regenerate. +11. **Never log secrets** — private keys, setup keys, tokens, PAT values — and + keep peer IPs and hostnames out of logs above debug level. + +## Type safety + +**No bare primitives for domain concepts.** A `string` parameter for an account +ID next to a `string` parameter for a peer ID is two bugs waiting to happen, +because the compiler cannot catch the swap. Declare the type once and use it +throughout, converting only at the boundaries where data enters or leaves — +protobuf, gRPC, HTTP, an external library. + +```go +type ServiceID string +type AccountID string + +// Internal: typed all the way through +func (r *Router) RemoveRoute(host SNIHost, svcID ServiceID) { ... } + +// Proto boundary: convert once, on the way in and on the way out +svcID := ServiceID(mapping.GetId()) +req.ServiceId = string(svcID) +``` + +- **IP addresses are `netip.Addr`**, not `string` and not `net.IP`. Parse at the + boundary and pass the typed value inward. +- **Always `Unmap()`** after parsing an address, after converting from `net.IP`, + and after extracting one from `RemoteAddr()`. This normalizes a v4-mapped v6 + address (`::ffff:10.1.2.3`) to plain v4 so IPv4 rules match it. A stored or + compared mapped address silently fails to match those rules. +- **Ports are `uint16`** internally; use `int` only where a library forces it and + convert immediately. +- **Enums are a typed string with constants**, so the valid set is discoverable + and a typo fails to compile. +- **Map keys follow the same rule**, and must be a real type (`type ServiceID + string`) rather than an alias (`type serviceID = string`) — an alias silently + accepts bare strings. + +## Concurrency and lifecycle + +Beyond the mutex hygiene in the principles above, check for these failure +modes. + +- **Never read a struct field inside a goroutine** when another goroutine may nil + or reassign it. Pass the value as a parameter, or capture it into a local before + launching. This matters most when `Stop()` nils a field without waiting for the + goroutine to finish. + + ```go + go func(ifaceName string) { // good: passed in, cannot be nilled underneath + m.Start(ctx, ifaceName) + }(iface.Name()) + ``` + +- **Never wait on a channel while holding a lock the sender needs.** Copy what you + need out from under the lock, release it, then wait. + + ```go + func (m *Manager) Stop() { + m.mu.Lock() + cancel, done := m.cancel, m.done + m.mu.Unlock() + if cancel != nil { + cancel() + <-done + } + } + ``` + +- **`Stop`/`Close` must be idempotent** — guard on an already-stopped flag or a + nil cancel — and must release the state they guarded. Clear maps and caches; + a cancelled goroutine holding a live map still pins that memory. Note that a + nil map only panics on writes; reads and iteration behave like an empty map, + so where post-close use must be rejected, check the stopped flag explicitly. +- **Publish coupled state only after every fallible step succeeds.** When several + fields form an invariant, build them into locals and assign them to the receiver + at the end. Assigning as you go leaves the object half-initialized when a later + step fails, so a readiness predicate reports ready while a coupled field is nil. + If an earlier step already had an external side effect — a created chain, an + opened handle, an inserted rule — roll it back before returning the error. +- **Clean up what you own on constructor error paths.** Once a constructor has + started something, every later error path must undo it: cancel a goroutine and + wait for it to exit, stop a ticker, close a watcher. The object is never + returned, so its `Close` will never run. +- **A failed `Start` must undo everything it started.** When a component brings up + several subsystems in sequence — connection manager, watchers, routing, DNS, + flow, persisted state — a failure partway through has to tear down the ones + already running, not just close the handle the error came from. Put the + already-started guard *before* that teardown path, so a rejected second `Start` + cannot dismantle the one that is running. + +## Error handling + +Use single-assignment form when the error is only needed inside the `if`: + +```go +// Good +if err := someCall(); err != nil { + return fmt.Errorf("context: %w", err) +} + +// Bad - unnecessary split +err := someCall() +if err != nil { + return fmt.Errorf("context: %w", err) +} +``` + +Use multiple assignment when the value is needed after the block: + +```go +result, err := someCall() +if err != nil { + return fmt.Errorf("context: %w", err) +} +``` + +Add short, meaningful context, and **do not** start `fmt.Errorf` messages with +obvious words like "failed to" or "error": + +```go +// Good +return fmt.Errorf("parse remote address: %w", err) +return fmt.Errorf("listen on %s: %w", addr, err) + +// Bad +return fmt.Errorf("failed to parse remote address: %w", err) +return fmt.Errorf("error listening on %s: %w", addr, err) + +// "failed" is fine in log messages +log.Debugf("failed to parse remote address: %v", err) +``` + +Skip the wrapping when a function only extracts or delegates and the wrap would +add nothing: + +```go +func parseAddr(addr string) (string, int, error) { + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + return "", 0, err + } + // ... +} +``` + +Log the errors you choose not to act on: + +- `log.Debugf()` for errors that do not affect program flow but help debugging. +- `log.Tracef()` for very verbose errors that would otherwise spam logs. +- **Never ignore** errors from writes, network sends, or critical cleanup. +- Close errors may be ignored for read-only operations; log them at debug for + writes. + +**Do not log and return the same error.** It gets reported twice, from two places, +and the second reader cannot tell whether it happened once or twice. Return it and +let the caller decide. The exception is an API handler that has already written a +response. Internal helpers return errors rather than logging and swallowing them. + +**Never return a typed nil as an error.** A nil `*MyError` stored in an `error` +interface is not nil, so `err != nil` is true and callers take the failure path on +success. Return the error only where it is actually set: + +```go +if _, err := conn.Write(buf); err != nil { // good + return err +} +return nil +``` + +**Accumulate with `multierror` when an operation should continue past individual +failures** — teardown, cleanup, or setup where partial success is acceptable. +`client/errors.FormatErrorOrNil` returns nil for an empty accumulator, so callers +still see a plain nil on full success: + +```go +func (m *Manager) Cleanup() error { + var merr *multierror.Error + for _, r := range m.resources { + if err := r.Close(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("close %s: %w", r.Name, err)) + } + } + return nberrors.FormatErrorOrNil(merr) +} +``` + +| Scenario | Approach | Why | +| --------------------- | --------------------- | ----------------------------------------- | +| Cleanup / teardown | Accumulate | Clean up as much as possible | +| Setup with rollback | Abort on first error | Partial state is invalid; undo what stuck | +| Setup with partial OK | Accumulate | Degraded operation is still useful | + +## Comments + +Comment the **why**, never the **what**. Default to no comment, and add one only +when a hidden constraint or workaround would surprise a future reader. Never +reference the current task, PR, or your own changes in a comment. + +```go +// Bad - trailing comments explaining the obvious +defer localConn.Close() // Close the connection +if err != nil { // Check if error occurred + +// Good +defer localConn.Close() + +// Good - explains a non-obvious constraint +// Use incremental checksum update per RFC 1624 for performance. +checksum = updateChecksum(checksum, oldPort, newPort) +``` + +### Length budget + +Neither of these is linter-enforced, so they are conventions the surrounding code +mostly follows rather than hard limits: + +- **Around 90 characters per line.** Wrap the comment rather than running well past + it. +- **Roughly 250 characters per comment**, about three wrapped lines. Doc comments + on exported identifiers may exceed it when the API genuinely needs the + explanation; inline comments inside a function body rarely should. + +The budget is a smell detector, not a rule to game. Do not compress a needed +explanation into cryptic shorthand to fit — if a block of code needs more than +250 characters of prose, the code is doing too much. Fix the code: + +- **Extract a named function.** A well-named function replaces the comment: the + name says *what*, the body shows *how*, and the comment you no longer write + was the *what* anyway. Clean Code calls this "explain yourself in code". +- **Extract a named constant or predicate.** `if isExpiredSetupKey(key)` needs + no comment; `if key.ExpiresAt.Before(now) && !key.Revoked && key.UsageLimit > 0` + does. +- **Keep the surviving comment for the why** — the RFC, the kernel quirk, the + ordering constraint. That part is usually one or two lines. + +### Long switch and if/else chains + +A `switch` whose cases carry multi-line explanations is the usual place this +budget is breached, and the comment is a symptom. In order of preference: + +1. **Extract each case body into a named function.** The case becomes one line, + the name carries the meaning, and the switch reads as a table of contents. +2. **Replace the switch with a lookup table** — `map[Kind]handlerFunc` — when the + branches are uniform. Adding a case stops meaning editing a growing function. +3. **Replace conditional with polymorphism** when branches vary by type and the + same switch shape starts appearing in more than one place. Clean Code's rule + of thumb: tolerate a switch statement if it appears **once**, is buried in a + factory that returns an interface, and no other switch dispatches on the same + type. A second switch over the same enum is the signal to introduce the + interface. + +Do not restructure a switch purely to satisfy the budget when the cases are one +line each and self-evident — a flat, boring `switch` over an enum is fine and +needs no comments at all. + +Explanatory comments in tests are welcome — they document the scenario being set +up, and the 250-character budget does not apply to them. + +## Testing + +- **Unit tests** live beside the code as `_test.go`. `make test-unit` runs the + host-safe set with `-tags devcert` and no sudo. +- **Privileged tests** carry the `privileged` build tag and mutate host + networking. They run through `make test-privileged`, inside a Docker container + with `NET_ADMIN`. Never bypass that harness by running them directly on the + host. +- **End-to-end suites** live in `e2e/` with a shared harness. +- **Test real behavior, not API existence.** Assert on the observable end state + a consumer would see — bytes that arrived, the packet after translation, the + row after the write — not merely that a method exists or returns an error. +- **Avoid mocks for code we own.** Exercise the real store, manager, or + controller and assert what the caller actually receives. +- **`require` for setup and preconditions, `assert` for the conditions under + test.** Use `require` whenever a later line would panic or be meaningless + otherwise. +- **Message guidance:** optional for `NoError`/`Error`; always give context for + comparison, boolean, and collection assertions. +- **Reproduce a bug before fixing it.** Write the test, watch it fail *for the + reason you expect* — a test that fails for an unrelated reason proves nothing — + then apply the fix and confirm it passes. Add the thin surrounding cases while + you are there. +- **Use `t.Setenv`** rather than `os.Setenv` so the previous value is restored on + cleanup. To test the unset case, call `t.Setenv` first to register the restore, + then `os.Unsetenv`. +- **Prefer `t.Cleanup` over `defer`** in any test with parallel subtests: the + parent function returns, running its `defer`s, while parallel subtests are + still suspended. Sequential subtests finish inside `t.Run`, so `defer` is safe + there, but `t.Cleanup` works in both cases. +- **Explanatory comments in tests are welcome.** Describe the scenario being set + up; the comment budget below does not apply to them. + +```go +server, err := StartTestServer() +require.NoError(t, err, "Test server setup must succeed") +defer server.Close() + +result, err := client.DoOperation() +assert.NoError(t, err) +assert.Equal(t, expectedResult, result, "Result should match expected") +``` + +## Pitfalls + +- **The agent runs as root.** Anything touching routing, firewall, DNS, or the + interface can take a user's machine off the network. Prefer a reversible + change and make sure cleanup runs on every exit path. +- **Management has two account loaders** (GORM and pgx). Adding a relation to an + account often means updating both, or it silently comes back empty in + production. +- **`go test ./...` without `-tags devcert` skips tests** that need the + development certificate. Use `make test-unit`. +- **`make lint` only checks the diff against `origin/main`.** CI runs + `make lint-all`; run it too before pushing a large change. +- **Protos are consumed by released clients.** An old agent must keep working + against a new Management, so fields are added, never renumbered or removed. +- **Windows requires the wintun driver**, and the daemon serves a named pipe + (`npipe://netbird`) rather than loopback TCP. Loopback TCP carries no caller + identity, so privileged operations are refused over it. + +## Commits, PRs, releases + +- **PR titles must start with a bracketed tag.** Before you propose a title, + **read [`.github/workflows/pr-title-check.yml`](.github/workflows/pr-title-check.yml) + and take the allowed tags from the `allowedTags` array in that file.** It is + the only source of truth, it changes as components are added, and the check + runs on every title edit — a tag that is not in that array is a red build. Do + not rely on a list memorized from anywhere else, including this file. + + ```text + [client] Authorize daemon IPC callers by their local identity + [management,client] Add MDM policy support + ``` + + Multiple tags are comma-separated inside one pair of brackets. Match the tag + to the component you actually changed, not to the one you read the most. + +- **Use the repository's PR template.** Fill in + [`.github/pull_request_template.md`](.github/pull_request_template.md) rather + than replacing it with your own summary: describe the change, link the issue, + tick the checklist honestly (including "ran locally" and "single purpose"), + and complete the documentation section. Do not tick a box you have not + verified, and do not delete rows that do not apply — the docs gate in CI reads + that section and fails when it is missing. + +- **Keep the PR description short.** Under 1000 words on top of the template's + own text, and usually far less — a few paragraphs. Reviewers read the diff; + the description exists to explain what the diff cannot say for itself. This is + well below what an agent will produce by default, so cut before you post. + +- **Body: why before what.** Lead with the problem and the reason for this + approach, then the shape of the change. No bullet list of files changed, no + per-function walkthrough, no restating the diff in prose, no trailing summary + section, no self-congratulatory closing line. + +- **No `Co-Authored-By` or tool-attribution trailers in the PR description**, + and none in commits either. Contributors own their contributions. Whatever + tooling produced the diff, the person opening the PR is its author: they have + read every line, they can explain why it works, they can answer review + questions without going back to a model, and they are accountable for the + consequences of merging it. Do not add a trailer, footer, or description line + that spreads that ownership onto a tool. + +- **Commit subjects follow the same `[scope] Subject` convention.** Keep the + subject short, and use the body for why before what. No bullet lists of files + changed. + +- **Push review fixes as separate commits.** The PR is squashed on merge, so + there is no reason to rewrite history mid-review; many small commits make the + re-review readable. + +- **Do not force-push a branch that is under review.** A force-push detaches + existing review comments from the lines they were written against, destroys + the "changes since your last review" diff a reviewer relies on, and discards + the CI history that showed which commit broke what. Add commits instead — + including for fixups and reverts. Force-push only when there is no + alternative: a rebase to clear a genuine conflict, or removing a secret or a + large binary that was committed by mistake. When you must, ask the user first, + then say so in a PR comment so reviewers know their anchors moved. Never + force-push `main`, and never force-push a branch you do not own. + +- **One PR, one purpose.** Split refactors out of fixes and fixes out of + features. + +- **Keep the PR small.** Size is the single strongest predictor of how long a PR + waits. Aim for **under ~400 changed lines across under ~20 files**; past + roughly **1000 lines or 50 files** a community PR is likely to be sent back to + be split, or left unreviewed until it is. Large PRs from outside the core team + may be blocked outright when the size was never agreed in the ticket — + reviewing a sprawling change against a privileged networking daemon is a + security risk in itself, not just a time cost. + + Judge the size by hand-written code: exclude generated output, `go.sum`, + vendored files, and test fixtures from the estimate, but do not use their + presence to argue a 3000-line PR is small. + + When a change genuinely cannot be small — a protocol migration, a + cross-component rename — agree the split in the ticket **before** writing + code, and land it as a sequence of PRs that each build, test, and make sense + on their own. Propose that split to the user rather than opening one large PR + and hoping. + + Prefer GitHub's stacked pull requests for such a sequence, rather than + hand-managing base branches: open each PR against the branch below it instead of + `main`, so every PR's diff shows only its own change. Merging a layer retargets + the PRs above it, and branch protections and required checks on the base branch + still apply to each one. + +- **User-facing changes need a docs PR** in + [netbirdio/docs](https://github.com/netbirdio/docs), linked from the PR + description. + +## After you push: CI and review bots + +Opening the PR is not the end of the task. Watch the run, read what the bots +say, and drive the PR to green before you report the work as done. + +```bash +gh pr checks --watch # all checks, live +gh run view --log-failed # only the failing steps +gh pr view --comments # bot and human review comments +``` + +**Never report a change as finished while checks are pending or red**, and never +describe a red PR as passing. If you ran out of turn before CI finished, say +which checks were still running. + +### The checks + +- **Go tests** — `golang-test-{linux,darwin,windows,freebsd}.yml`, sharded per + component. A failure in a component you did not touch is usually a real + interaction, not noise; read the log before assuming flake. +- **golangci-lint** — `golangci-lint.yml` runs the full repository, while + `make lint` only checks your diff. A clean local lint does not guarantee green + CI on a large change. +- **PR Title Check** — `pr-title-check.yml`, see above. +- **Codecov** — uploaded from the Linux test workflow with per-component flags + (`unit,client`, `unit,management`, `unit,relay`, `unit,proxy`, `unit,signal`, + `integration,management`). Coverage on new code should not go backwards. Add + tests for the paths you introduced; do not adjust thresholds or exclude files + to clear the report. +- **CodeRabbit** — configured in [`.coderabbit.yaml`](.coderabbit.yaml): `chill` + profile, auto-review on every non-draft PR, TypeScript/JavaScript/SVG paths + filtered out. Chat auto-reply is on, so `@coderabbitai` in a comment reaches + it. +- **SonarCloud** — project `netbirdio_netbird`, quality gate on new code (bugs, + vulnerabilities, code smells, duplication, coverage). +- **Snyk** — dependency and code scanning. + +Sonar and Snyk report as GitHub App checks rather than workflows in this +repository, so their detail lives on the PR check, not in the Actions logs. + +### Handling bot findings + +- **Read every comment and act on it.** Either fix it, or reply with the reason + it does not apply. Do not bulk-resolve threads to clear the count, and do not + silently ignore a finding because the check is advisory. +- **Bots are frequently wrong here.** NetBird has privileged, platform-specific, + and concurrency-heavy code that static analysis reads poorly. A confident + CodeRabbit or Sonar comment can still be nonsense. Verify the claim against + the code before you change anything — never edit correct code just to silence + a bot. +- **Security findings get the opposite default.** For a Snyk or Sonar + vulnerability, or a CodeRabbit comment about authentication, authorization, + certificate verification, or key handling, assume it is real until you have + disproved it. Surface it to the user rather than dismissing it yourself. +- **A new vulnerable dependency is a stop.** Bumping or replacing dependencies + needs the user's decision, as above. +- **Never change a workflow, threshold, lint exclusion, or bot config to make a + check pass.** If a check is genuinely wrong, say so and let the user decide. +- **Do not paper over flakes with blind re-runs.** Identify the failure first. If + it is a known flake, name it; if you cannot tell, report it as unresolved + rather than re-running until it goes green. + +## Discussion and support + +- Discussions: +- Slack: +- Docs: +- Security: — never in public +- Contribution process: [CONTRIBUTING.md](CONTRIBUTING.md) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..764f406be --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See [AGENTS.md](AGENTS.md) for the agent guidelines in this repository. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd1c087bb..9dea37ec8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to NetBird -Thanks for your interest in contributing to NetBird. +Thanks for your interest in contributing to NetBird. There are many ways that you can contribute: - Reporting issues @@ -10,12 +10,99 @@ There are many ways that you can contribute: If you haven't already, join our slack workspace [here](https://docs.netbird.io/slack-url), we would love to discuss topics that need community contribution and enhancements to existing features. +## Ticket first, PR second + +**Open a ticket and wait for feedback before you open a pull request.** Every PR +that changes behavior must link to an issue the NetBird team has agreed on. A PR +that arrives without one may be closed and redirected to a discussion, no matter +how good the code is. + +Issues in this repository are maintainer-curated work items, so the flow starts +in [Discussions](https://github.com/netbirdio/netbird/discussions): + +1. **Open a discussion.** Use + [Issue Triage](https://github.com/netbirdio/netbird/discussions/new?category=issue-triage) + for a bug, regression, or unexpected behavior, and + [Ideas & Feature Requests](https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests) + for a feature, enhancement, or integration idea. Setup and usage questions + belong in + [Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support). + Never report a security vulnerability in public — follow the + [security policy](https://github.com/netbirdio/netbird/security/policy) + instead. +2. **Wait for feedback.** DevRel validates and reproduces the report, and a + maintainer confirms the direction. We may ask for more detail or propose a + different approach. Validated discussions become issues. +3. **Then write the code**, following the approach agreed in the issue, and open + the PR linking that issue. + +Trivial fixes — a typo, a broken link, a documentation correction, or a one-line +fix that already has an issue — can go straight to a PR. Everything else starts +with a ticket. When in doubt, ask in the discussion or on +[Slack](https://docs.netbird.io/slack-url); an hour of conversation up front +regularly saves a week of rework. + +### High-risk areas + +These always need the design discussed and agreed in the issue **before** you +write code: + +- **Public API** — REST / management API, OpenAPI schema, dashboard-facing contracts +- **gRPC protocols** — management, signal, relay, and client daemon protos +- **Functionality behavior** — anything existing deployments would experience differently after an upgrade +- **Peer connectivity** — ICE and NAT traversal, relay selection, WireGuard® and Rosenpass key handling +- **Client system integration** — routing, firewall, DNS, and interface management +- **Authentication and authorization** — IdP integration, tokens, permissions, cryptography +- **CLI / service flags**, configuration file format, and daemon IPC +- **Store and database schema** — models and migrations +- **New features** + +These surfaces are NetBird's contract with operators, self-hosters, and +downstream integrators, and changes to them have compatibility, security, and +release-planning implications. Agreeing on the direction early lets the PR +review focus on implementation rather than design. + +Typical bug fixes, internal refactors, documentation updates, and tests do not +need a design discussion, but should still be tied to an issue so the work is +visible and nobody duplicates it. + +### Using AI coding agents + +We have no policy for or against using an AI agent to write NetBird code. That +choice is yours, and we are not going to interrogate anyone about their tools. + +What we do have is a lot of incoming contributions that were plainly drafted with +one, and enough experience reviewing them to see the same avoidable problems +again and again: no ticket behind the change, a diff far too large to review, a +description longer than the code it describes, an approach that was never going +to be accepted, and an author who cannot answer questions about their own PR. +None of that is caused by the tooling — it is what happens when a tool is pointed +at a repository whose expectations it has never been told. + +So rather than a rule, there is a guide. [AGENTS.md](AGENTS.md) restates the +expectations from this document in the form agents read automatically +(`CLAUDE.md` points to it), so pointing your tool at the repository is usually +enough. Among other things it tells the agent to ask you for the +discussion or issue before drafting a PR, to keep the change small and +single-purpose, to run the tests locally, to use this repository's PR template +and title tags, and to write a description a reviewer can get through. + +The guardrails are the point, and they are the same ones we apply to everyone: an +agreed ticket, a change you have actually run, a diff small enough to review with +care, and an author who can explain it. Whatever wrote the diff, you are its +author — you own every line you submit and the consequences of opening a PR with it. + +We may assess whether a contribution is maintainable and whether its merged code +aligns with our security standards and design expectations. + ## Contents - [Contributing to NetBird](#contributing-to-netbird) + - [Ticket first, PR second](#ticket-first-pr-second) + - [High-risk areas](#high-risk-areas) + - [Using AI coding agents](#using-ai-coding-agents) - [Contents](#contents) - [Code of conduct](#code-of-conduct) - - [Discuss changes with the NetBird team first](#discuss-changes-with-the-netbird-team-first) - [Directory structure](#directory-structure) - [Development setup](#development-setup) - [Requirements](#requirements) @@ -24,6 +111,8 @@ If you haven't already, join our slack workspace [here](https://docs.netbird.io/ - [Build and start](#build-and-start) - [Test suite](#test-suite) - [Checklist before submitting a PR](#checklist-before-submitting-a-pr) + - [When we close a PR](#when-we-close-a-pr) + - [Translations](#translations) - [Other project repositories](#other-project-repositories) - [Contributor License Agreement](#contributor-license-agreement) @@ -34,42 +123,66 @@ Conduct which can be found in the file [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to community@netbird.io. -## Discuss changes with the NetBird team first - -Changes to the **public API**, **gRPC protocols**, **functionality behavior**, **CLI / service flags**, or **new features** should be discussed with the NetBird team before you start the work. These surfaces are part of NetBird's contract with operators, self-hosters, and downstream integrators, and changes to them have compatibility, security, and release-planning implications that benefit from an early conversation. - -Open an issue or reach out on [Slack](https://docs.netbird.io/slack-url) to talk through what you have in mind. We'll help shape the change, flag any constraints we know about, and confirm the direction so the PR review can focus on implementation rather than design. - -Typical bug fixes, internal refactors, documentation updates, and tests do not need pre-discussion — open the PR directly. - ## Directory structure -The NetBird project monorepo is organized to maintain most of its individual dependencies code within their directories, except for a few auxiliary or shared packages. +The NetBird project monorepo keeps most of each component's code within its own +directory, except for a few auxiliary or shared packages. Protocol definitions +and the client-side service clients live under [/shared](/shared), because both +the agent and the services import them. -The most important directories are: +**Agent** -- [/.github](/.github) - Github actions workflow files and issue templates - [/client](/client) - NetBird agent code -- [/client/cmd](/client/cmd) - NetBird agent cli code +- [/client/cmd](/client/cmd) - NetBird agent CLI code - [/client/internal](/client/internal) - NetBird agent business logic code -- [/client/proto](/client/proto) - NetBird agent daemon GRPC proto files - [/client/server](/client/server) - NetBird agent daemon code for background execution -- [/client/ui](/client/ui) - NetBird agent UI code -- [/encryption](/encryption) - Contain main encryption code for agent communication -- [/iface](/iface) - Wireguard® interface code -- [/infrastructure_files](/infrastructure_files) - Getting started files containing docker and template scripts +- [/client/proto](/client/proto) - NetBird agent daemon gRPC proto files +- [/client/iface](/client/iface) - WireGuard® interface code +- [/client/firewall](/client/firewall) - Platform firewall backends (nftables, iptables, pf, WFP, userspace) +- [/client/ssh](/client/ssh) - Built-in SSH server and client +- [/client/ui](/client/ui) - NetBird agent UI code (Wails v3 + React) +- [/client/android](/client/android), [/client/ios](/client/ios) - Mobile platform bindings +- [/client/wasm](/client/wasm) - WebAssembly build of the agent +- [/client/mdm](/client/mdm) - MDM-delivered policy handling +- [/client/system](/client/system) - Host and system information collection + +**Control plane services** + - [/management](/management) - Management service code -- [/management/client](/management/client) - Management service client code which is imported by the agent code -- [/management/proto](/management/proto) - Management service GRPC proto files - [/management/server](/management/server) - Management service server code - [/management/server/http](/management/server/http) - Management service REST API code +- [/management/server/store](/management/server/store) - Persistence layer and migrations - [/management/server/idp](/management/server/idp) - Management service IDP management code -- [/release_files](/release_files) - Files that goes into release packages +- [/management/server/peer](/management/server/peer), [/management/server/groups](/management/server/groups), [/management/server/networks](/management/server/networks), [/management/server/posture](/management/server/posture), [/management/server/permissions](/management/server/permissions) - Core domain packages - [/signal](/signal) - Signal service code -- [/signal/client](/signal/client) - Signal service client code which is imported by the agent code - [/signal/peer](/signal/peer) - Signal service peer message logic -- [/signal/proto](/signal/proto) - Signal service GRPC proto files - [/signal/server](/signal/server) - Signal service server code +- [/relay](/relay) - Relay service code +- [/relay/protocol](/relay/protocol) - Relay wire protocol +- [/proxy](/proxy) - Identity-aware proxy used by Agent Network (LLM routing, ACME, access logs) +- [/agent-network](/agent-network) - Agent Network overview and documentation +- [/upload-server](/upload-server) - Debug bundle upload service + +**Shared code** + +- [/shared/management/proto](/shared/management/proto) - Management service gRPC proto files +- [/shared/management/client](/shared/management/client) - Management service client code which is imported by the agent code +- [/shared/management/http/api](/shared/management/http/api) - OpenAPI specification and generated REST API types +- [/shared/signal/proto](/shared/signal/proto) - Signal service gRPC proto files +- [/shared/signal/client](/shared/signal/client) - Signal service client code which is imported by the agent code +- [/shared/relay](/shared/relay) - Relay client and shared relay types +- [/shared/auth](/shared/auth), [/shared/sshauth](/shared/sshauth) - Shared authentication primitives +- [/encryption](/encryption) - Contain main encryption code for agent communication +- [/dns](/dns), [/route](/route), [/stun](/stun), [/sharedsock](/sharedsock), [/util](/util) - Shared networking and utility primitives +- [/flow](/flow) - Flow event protocol shared by the agent and Management + +**Build, test, and packaging** + +- [/.github](/.github) - Github actions workflow files, issue templates, and the pull request template +- [/e2e](/e2e) - End-to-end test suites and harness +- [/infrastructure_files](/infrastructure_files) - Getting started files containing docker and template scripts +- [/release_files](/release_files) - Files that goes into release packages +- [/tools](/tools) - Development and maintenance tooling ## Development setup @@ -79,13 +192,21 @@ dependencies are installed. Here is a short guide on how that can be done. ### Requirements -#### Go 1.21 +#### Go 1.25 Follow the installation guide from https://go.dev/ -#### UI client - Fyne toolkit +#### UI client - Wails v3 + React -We use the fyne toolkit in our UI client. You can follow its requirement guide to have all its dependencies installed: https://developer.fyne.io/started/#prerequisites +The desktop UI client (`client/ui`) is built with [Wails v3](https://v3.wails.io/) and a React frontend rendered in a WebView. To build it you need: + +- Go ≥ 1.25 +- Node ≥ 20 and **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`) +- The `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest` +- The `task` runner: `go install github.com/go-task/task/v3/cmd/task@latest` +- Linux only: `libwebkitgtk-6.0-dev`, `libgtk-4-dev`, `libsoup-3.0-dev` + +All UI build, dev-loop, and cross-compile commands are described in the [UI client](#ui-client) section below. #### gRPC You can follow the instructions from the quickstarter guide https://grpc.io/docs/languages/go/quickstart/#prerequisites and then run the `generate.sh` files located in each `proto` directory to generate changes. @@ -214,6 +335,49 @@ To start NetBird the client in the foreground: sudo ./client up --log-level debug --log-file console ``` > On Windows use a powershell with administrator privileges + +#### UI client + +The desktop UI lives in `client/ui` and is built with Wails v3 (see [Requirements](#ui-client---wails-v3--react)). All commands run from `client/ui`. + +Live-reload development (Vite + Go binary + `*.go` watcher): + +``` +cd client/ui +task dev +``` + +Pass daemon flags after `--`, pointing the UI at the socket the daemon serves: + +``` +task dev -- --daemon-addr=unix:///var/run/netbird.sock # Linux, macOS +task dev -- --daemon-addr=npipe://netbird # Windows +``` + +On Windows the daemon serves a named pipe (`npipe://netbird`). Which path that +ends up being depends on what the daemon may create: as a service or elevated it +serves `\\.\pipe\ProtectedPrefix\Administrators\netbird`, which no unprivileged +process can take from it, and otherwise it falls back to `\\.\pipe\netbird`. +Clients try both and check who owns the pipe before using the plain one. Avoid +`tcp://127.0.0.1:41731`: loopback TCP carries no caller identity, so the daemon +refuses the operations that require an administrator and you will not exercise +those paths. + +Production build (frontend assets embedded into the binary, output in `client/ui/bin/`): + +``` +cd client/ui +task build +``` + +Cross-compile the Windows binary from Linux (requires the mingw-w64 toolchain, e.g. `sudo apt install gcc-mingw-w64-x86-64`): + +``` +CGO_ENABLED=1 task windows:build +``` + +> macOS cross-compile from Linux is not supported (signing and notarization need a real Mac). + #### Signal service To start NetBird's signal, execute: @@ -251,10 +415,10 @@ Create dist directory mkdir -p dist/netbird_windows_amd64 ``` -UI client +UI client (built with Wails v3 — see the [UI client](#ui-client) section above) ```shell -CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -o netbird-ui.exe -ldflags "-s -w -H windowsgui" ./client/ui -mv netbird-ui.exe ./dist/netbird_windows_amd64/ +(cd client/ui && CGO_ENABLED=1 task windows:build) +mv client/ui/bin/netbird-ui.exe ./dist/netbird_windows_amd64/ ``` Client @@ -283,25 +447,183 @@ The installer `netbird-installer.exe` will be created in root directory. ### Test suite -The tests can be started via: +The host-safe unit tests run as a normal user and leave host networking +untouched: ``` -cd netbird -go test -exec sudo ./... +make test-unit ``` + +Tests that need root and mutate host networking (firewall, routing, interface +management) carry the `privileged` build tag and run inside a +`--privileged --cap-add=NET_ADMIN` Docker container: + +``` +make test-privileged +``` + +Narrow a privileged run with environment variables: + +``` +PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged +``` + +Single packages can be run directly, adding `-race` when the change touches +shared state: + +``` +go test -race ./client/internal/dns/... +``` + > On Windows use a powershell with administrator privileges -> Non-GTK environments will need the `libayatana-appindicator3-dev` (debian/ubuntu) package installed - ## Checklist before submitting a PR -As a critical network service and open-source project, we must enforce a few things before submitting the pull-requests: + +As a critical network service and open source project, we must enforce a few +things before submitting a pull request. The +[pull request template](/.github/pull_request_template.md) mirrors this list — +fill it in rather than deleting it. + +### Link the issue + +The PR description must link the agreed issue (or the validated discussion it +came from). See [Ticket first, PR second](#ticket-first-pr-second). + +### Run it locally + +**If you can't run it, you can't submit it.** Build the affected components and +exercise the change on a real setup — see [Build and start](#build-and-start). +"CI will tell me" is not acceptable for a VPN agent that runs as root on other +people's machines. + +### Green CI, and answer the bots + +We do not start reviewing while CI is red. Get the pipeline green first — a +failing build, lint, or test means the PR is not ready for review. + +Alongside the test workflows, your PR is reviewed by CodeRabbit and scanned by +SonarCloud, Snyk, and Codecov. Read what they report and either fix it or reply +with why it does not apply; please do not resolve the threads without a +response. They are not always right — this codebase has privileged, +platform-specific, and concurrency-heavy paths that static analysis reads poorly +— so push back when a finding is wrong rather than changing correct code to +silence it. Security and dependency findings are the exception: treat those as +real until shown otherwise. Do not edit workflows, thresholds, or scanner +configuration to make a check pass. + +### One PR, one purpose + +Bug fix, refactor, feature: separate PRs. Mixed PRs are slow to review, hard to +revert, and may be closed with a request to split them. + +### Keep it small + +Size is the strongest predictor of how long a PR waits for review. Aim for under +roughly 400 changed lines across under 20 files. Past about 1000 lines or 50 +files, expect to be asked to split the change — and large PRs from outside the +core team may be blocked until the scope has been agreed in a ticket. This is +not only about reviewer time: NetBird's agent runs as root on other people's +machines, and a sprawling diff cannot be reviewed with the care that deserves. + +Measure by hand-written code, excluding generated output, `go.sum`, and +fixtures. If a change genuinely cannot be small — a protocol migration, a +cross-component rename — agree the split in the issue before you start, and land +it as a series of PRs that each build and make sense on their own. + +### Avoid force-pushing during review + +Once a PR is open, push new commits instead of rewriting history. A force-push +detaches existing review comments from their lines, throws away the +"changes since your last review" diff, and loses the CI history that showed +which commit broke what. Since we squash on merge, there is nothing to gain from +a tidy branch history. + +Force-pushing is sometimes unavoidable — rebasing to clear a real conflict, or +removing a secret or large binary committed by mistake. When that happens, leave +a comment on the PR so reviewers know their anchors moved. + +### Quality checks + +Run these from the repository root before pushing: + +```shell +go fmt ./... +make lint # golangci-lint on files changed against origin/main +make lint-all # full-repository lint, matches CI +make test-unit # host-safe unit tests +``` + +`make setup-hooks` wires `make lint` into a pre-push hook so the fast lint runs +automatically. If your change touches privileged paths (firewall, routing, +interface management), also run `make test-privileged`, which executes the +`privileged`-tagged suite inside a Docker container with `NET_ADMIN`. + +### Code standards + - Keep functions as simple as possible, with a single purpose - Use private functions and constants where possible - Comment on any new public functions - Add unit tests for any new public function +- Comment the **why**, not the **what** — explain non-obvious decisions, invariants, and constraints, not the line below +- Keep comments within 90 characters per line and roughly 250 characters per comment; when a block needs more explanation than that, extract a named function instead of writing a longer comment (see [AGENTS.md](AGENTS.md#length-budget)) + +### PR title and commits + +PR titles must start with a bracketed tag, enforced by +[pr-title-check.yml](/.github/workflows/pr-title-check.yml): + +```text +[client] Authorize daemon IPC callers by their local identity +[management,client] Add MDM policy support +``` + +Use a comma-separated list inside a single pair of brackets when a change spans +components. The `allowedTags` array in +[pr-title-check.yml](/.github/workflows/pr-title-check.yml) is the source of +truth — at the time of writing it accepts `management`, `client`, `signal`, +`proxy`, `relay`, `misc`, `infrastructure`, `self-hosted`, and `doc`. + +Commit subjects follow the same convention — keep them short and put the +reasoning in the body, why before what, with no bullet list of files changed. + +Keep the PR description itself under 1000 words on top of the template text. +Reviewers read the diff; the description explains what the diff cannot. > When pushing fixes to the PR comments, please push as separate commits; we will squash the PR before merging, so there is no need to squash it before pushing it, and we are more than okay with 10-100 commits in a single PR. This helps review the fixes to the requested changes. +### Documentation + +User-facing changes need a matching PR in +[netbirdio/docs](https://github.com/netbirdio/docs); link it in the PR +description, or state why documentation is not needed. + +## When we close a PR + +We would rather redirect early than let a PR sit. We may close one if: + +- It changes behavior with no linked issue, or the approach was never agreed with a maintainer +- The change was clearly never run or tested locally +- CI has been red without a response +- It mixes unrelated purposes, or the purpose is not clear +- It is far too large to review and the scope was never agreed in a ticket +- The author cannot answer questions about their own change — including PRs that read as unreviewed model output, where review turns into a relay between the maintainer and an LLM. Tooling is fine; unreviewed output is not, you are responsible for the code you sign your name to +- There has been no activity for 14 days after we requested changes + +A closed PR is not a rejected idea. Take it back to the +[discussion](https://github.com/netbirdio/netbird/discussions), settle the +approach, and reopen the work from there. + +## Translations + +Desktop UI translations are not contributed through pull requests. Translate on +[Crowdin](https://crowdin.com/project/netbird) instead: no ticket needed, just +join the project and pick your language. Crowdin syncs with this repository and +opens the service PRs itself, so hand-edited locale files would conflict with +the next sync. Style, terminology, and review guidance live in +[client/ui/i18n/TRANSLATING.md](client/ui/i18n/TRANSLATING.md). To request a +language the project does not offer yet, ask on the Crowdin project page or in +a [discussion](https://github.com/netbirdio/netbird/discussions). + ## Other project repositories NetBird project is composed of 3 main repositories: diff --git a/Makefile b/Makefile index 5d52b94fa..0a4fad2f2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: lint lint-all lint-install setup-hooks +.PHONY: lint lint-all lint-install setup-hooks test-unit test-privileged GOLANGCI_LINT := $(shell pwd)/bin/golangci-lint # Install golangci-lint locally if needed @@ -25,3 +25,15 @@ setup-hooks: @git config core.hooksPath .githooks @chmod +x .githooks/pre-push @echo "✅ Git hooks configured! Pre-push will now run 'make lint'" + +# Host-safe unit tests: excludes the privileged-tagged tests (root / system-mutating). +# Runs as a normal user with no sudo and leaves host networking untouched. +test-unit: + @go test -tags devcert -timeout 10m ./... + +# Privileged suite: runs the `privileged`-tagged tests inside a --privileged +# --cap-add=NET_ADMIN container via the ory/dockertest harness. Requires Docker. +# Narrow the run with env vars, e.g.: +# PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged +test-privileged: + @go test -tags 'devcert privileged' -timeout 30m -run TestRunPrivilegedSuiteInDocker -v ./client/testutil/privileged/... diff --git a/README.md b/README.md index cc27e2d28..3bcb4a035 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,15 @@

- 🚀 We are hiring! Join us at careers.netbird.io + 🚀 We are hiring! Join us at https://netbird.io/careers

+> ### 🤖 NetBird Agent Network (Beta) +> Identity-aware access control for AI agents — keyless access to LLM APIs and private +> resources over the encrypted NetBird tunnel. See [`agent-network/`](agent-network/) or +> read the docs at **[netbird.ai](https://netbird.ai)**. + **NetBird combines a configuration-free peer-to-peer private network and a centralized access control system in a single platform, making it easy to create secure private networks for your organization or home.** **Connect.** NetBird creates a WireGuard-based overlay network that automatically connects your machines over an encrypted tunnel, leaving behind the hassle of opening ports, complex firewall rules, VPN gateways, and so forth. @@ -125,7 +130,7 @@ In November 2022, NetBird joined the [StartUpSecure program](https://www.forschu ![CISPA_Logo_BLACK_EN_RZ_RGB (1)](https://user-images.githubusercontent.com/700848/203091324-c6d311a0-22b5-4b05-a288-91cbc6cdcc46.png) ### Acknowledgements -We build on open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing). +We build on open source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing). ### Legal This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/. diff --git a/SECURITY.md b/SECURITY.md index 745c66e61..cbcc975ba 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,12 +1,70 @@ # Security Policy -NetBird's goal is to provide a secure network. If you find a vulnerability or bug, please report it by opening an issue [here](https://github.com/netbirdio/netbird/issues/new?assignees=&labels=&template=bug-issue-report.md&title=) or by contacting us by email. - -There has yet to be an official bug bounty program for the NetBird project. - -## Supported Versions -- We currently support only the latest version +NetBird's goal is to provide a secure network. The client runs as a privileged service on every machine it is installed on, +so we take reports about it seriously and we publish what we fix. ## Reporting a Vulnerability -Please report security issues to `security@netbird.io` +**Please do not open a public issue for a security vulnerability.** Public issues are visible to everyone, including before +a fix is available. + +Report security issues one of these two ways: + +- **GitHub private vulnerability reporting** — [open a private report](https://github.com/netbirdio/netbird/security/advisories/new) + on this repository. This is the preferred route: it keeps the discussion, the draft advisory, and the credit in one place. +- **Email** — `security@netbird.io`. + +If the finding affects NetBird Cloud or our hosted infrastructure rather than the open source code, email us rather than +filing a repository report. + +### What to include + +A report is easier to act on when it contains: + +- The affected component (client, management, signal, relay, dashboard) and the version or commit you tested +- The platform and configuration, where relevant — operating system, self-hosted or NetBird Cloud, container or host install +- What an attacker needs before they can exploit it: network position, an account, local access, a specific privilege level +- Steps to reproduce, and a proof of concept if you have one +- The impact you believe it has + +Partial reports are still welcome. If you are unsure whether something is a security issue, send it to `security@netbird.io` +and let us make that call. + +## What to expect from us + +- **We acknowledge your report** and tell you whether we can reproduce it. +- **We work with you on severity and scope.** If we assess it differently than you do, we will explain why rather than + silently downgrade it. +- **We fix and release**, then publish a [GitHub Security Advisory](https://github.com/netbirdio/netbird/security/advisories) + naming the affected version range and the patched version. +- **We credit reporters who want to be credited.** Tell us the name or handle you would like used, or that you would rather + stay anonymous. +- **We keep you in the loop** until the advisory is published. + +We ask that you give us a reasonable opportunity to ship a fix before disclosing the issue publicly, and that you avoid +accessing, modifying, or exfiltrating data belonging to other people while testing. Testing against your own installation +or your own account is always fine. + +## Supported Versions + +We support the latest release. Security fixes ship in the next version rather than as backports to older releases, so +upgrading to the current release is how you get them. + +Release notifications are available by watching [releases](https://github.com/netbirdio/netbird/releases). + +## Published advisories + +Every vulnerability we fix is published as a GitHub Security Advisory on the +[advisories page](https://github.com/netbirdio/netbird/security/advisories), including the affected version range, the +patched version, and the reporter's credit. Advisories for the Go module are also distributed through the Go vulnerability +database, so `govulncheck` will report them against your dependencies. + +## Bug bounty + +There is no official bug bounty program for the NetBird project. We credit reporters in advisories, and we are grateful for +the work, but we cannot currently offer payment for reports. + +## Non-security bugs + +For bugs that are not security issues, please use the +[issue tracker](https://github.com/netbirdio/netbird/discussions/new/choose). diff --git a/agent-network/README.md b/agent-network/README.md new file mode 100644 index 000000000..5211fe8f9 --- /dev/null +++ b/agent-network/README.md @@ -0,0 +1,102 @@ +# NetBird Agent Network + +Agent Network is NetBird's access control layer for AI agents and the people who run them. +It gives every agent a real identity, tied to an identity provider (IdP), and governs what it can reach: LLM APIs and +AI gateways it can call, and the internal resources it can access. Traffic flows only over the encrypted NetBird tunnel, +scoped by policy, with no API keys or other credentials to leak. It also gives you control over cost and token usage. + +Because every LLM request passes through an +identity-aware proxy, you can: + +- **Set spending and rate limits** per agent, per user, or per team — with hard caps + that stop requests once a budget is reached. +- **Restrict models and providers** so agents can only call approved (and cost-appropriate) + endpoints, keeping expensive models off-limits unless explicitly allowed. +- **Attribute usage** by tracking token consumption and cost per identity, group, or cost center so every + request is tied back to the agent and person responsible. +- **Reuse your existing AI gateway** — point the proxy at a gateway you already run, + keeping its routing and config in place while it adds identity on top, so you skip + API key distribution. + +https://github.com/user-attachments/assets/44d18286-d8ab-49f8-a457-98ccd66f3268 + +> **Beta.** Agent Network is in beta, but it's stable and already running in +> production environments. It's fully open source and can be self-hosted on your own +> infrastructure, with no vendor lock-in and no data leaving your environment. + +## How it works + +Say you have a simple use case: your Engineering or IT team needs access to Claude Code or Codex, and you want visibility into usage plus the ability to enforce budgets. +How can you do that without creating a dedicated API key for every team? + +With Agent Network you get a private endpoint inside your network, for example: https://mirror.netbird.ai +Teams configure their agents to point to that endpoint instead of using individual API keys directly. + +This endpoint is only reachable when users are connected to your NetBird network and authenticated through your IdP. Otherwise, it is not accessible from the public internet. +You can then use this private endpoint to configure your AI agents, whether that is Claude Code, Codex, or another tool. + +## Quickstart + +Full step-by-step setup: +**https://docs.netbird.io/agent-network/quickstart** + +## Client settings that don't follow the endpoint + +Most of an agent's traffic follows the base URL you hand it, but a few +client-side checks call their vendor directly and never reach the proxy. On a +network that blocks direct egress they fail even though inference works, so +they are worth setting once when you roll the endpoint out. + +For Claude Code: + +- **Fast mode** checks availability against `api.anthropic.com` rather than the + configured base URL. Set `CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK=1` when the + agent authenticates with `ANTHROPIC_AUTH_TOKEN` alone (the usual shape when + the proxy injects the real provider key) or when a TLS-inspecting proxy + answers the check itself. Set + `CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS=1` when the network refuses the + connection outright. Fast mode is an Anthropic-API feature, so it is + unavailable on a Bedrock- or Vertex-backed endpoint whatever you set. +- **Model discovery** is off by default. Set + `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` for the picker to list the + models your policies authorise; the proxy filters the response to that set. + The client gives discovery a three-second budget and treats any redirect as + a failure, so the endpoint must serve `/v1/models` directly. +- **The WebFetch domain safety check** also calls `api.anthropic.com` directly + and is unaffected by the variables above. + +Allowing direct egress to `api.anthropic.com` covers the network cases but not +the credential one, where the check reaches Anthropic and is rejected because +the agent presents a proxy-issued key. + +## Architecture + +Agent Network is built on two existing NetBird capabilities: + +- **Overlay network** — the encrypted WireGuard mesh between peers. +- **Reverse proxy** — a NetBird peer that terminates LLM requests, establishes the + caller's identity, evaluates policies/limits/guardrails, injects the upstream provider + key server-side, forwards to the API or gateway, and records usage. + +LLM traffic is routed through the proxy's identity-aware pipeline, while internal +resources (databases, internal APIs, self-hosted models) are reached directly over +peer-to-peer WireGuard tunnels, governed by the same identities and access policies. + +image + + +## Where the code lives + +There is no separate "agent-network" service — it reuses the reverse-proxy and management +components: + +- [`proxy/`](../proxy) — the NetBird reverse proxy that serves the agent network endpoint + and runs the per-request middleware pipeline. +- [`management/internals/modules/reverseproxy/`](../management/internals/modules/reverseproxy) + — the management-side control plane: providers, policies, guardrails, limits, routing, + and usage/access logs. + +## Documentation + +Full documentation, architecture, and quickstart: +**https://docs.netbird.io/agent-network** diff --git a/client/android/client.go b/client/android/client.go index 99ccdf393..7eea83dc0 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "slices" + "strings" "sync" "time" @@ -14,6 +15,7 @@ import ( log "github.com/sirupsen/logrus" + nbAnonymize "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/debug" @@ -24,6 +26,8 @@ import ( "github.com/netbirdio/netbird/client/internal/routemanager" "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/netbirdio/netbird/client/net" + "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netsweep" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" @@ -31,10 +35,12 @@ import ( types "github.com/netbirdio/netbird/upload-server/types" ) -// ConnectionListener export internal Listener for mobile -type ConnectionListener interface { - peer.Listener -} +// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted +// anonymizeLevel values for DebugBundle. +const ( + AnonymizeLevelDefault = nbAnonymize.LevelDefaultString + AnonymizeLevelStrict = nbAnonymize.LevelStrictString +) // TunAdapter export internal TunAdapter for mobile type TunAdapter interface { @@ -56,6 +62,12 @@ type DnsReadyListener interface { dns.ReadyListener } +// TunSettings is a snapshot of the settings the TUN device is rebuilt with +type TunSettings struct { + Routes string + SearchDomains string +} + func init() { formatter.SetLogcatFormatter(log.StandardLogger()) } @@ -70,18 +82,46 @@ type Client struct { deviceName string uiVersion string networkChangeListener listener.NetworkChangeListener + // netState outlives engine restarts: it mirrors the OS connectivity, not + // the engine lifecycle. Run and RunWithoutLogin inject it into each new + // ConnectClient, which distributes it to every reconnection loop. + netState *netstate.State + + // sweeper also outlives engine restarts; NotifyNetworkChange sweeps it. + sweeper *netsweep.Sweeper stateMu sync.RWMutex connectClient *internal.ConnectClient config *profilemanager.Config cacheDir string + // Identifies the running profile for the SSO login hint; see profile_state.go. + cfgPath string + + stateChangeMu sync.Mutex + stateChangeSubID string + eventSub *peer.EventSubscription + // Closed to stop the watch goroutines from delivering buffered items to a + // listener that has been removed or replaced. See stopStateChangeWatchLocked. + stateChangeDone chan struct{} + + // Latched "the server wants an interactive login": survives the engine + // restarts that replace the run loop's context state. See Client.Status. + // Guarded by loginRequiredMu together with loginCleared, which counts + // clears so a stale observation cannot re-latch over one. + loginRequiredMu sync.Mutex + loginRequired bool + loginCleared uint64 + + extendMu sync.Mutex + extendCancel context.CancelFunc } -func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) { +func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cfgPath string, cc *internal.ConnectClient) { c.stateMu.Lock() defer c.stateMu.Unlock() c.config = cfg c.cacheDir = cacheDir + c.cfgPath = cfgPath c.connectClient = cc } @@ -91,6 +131,16 @@ func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.Conn return c.config, c.cacheDir, c.connectClient } +// authSnapshot returns the config together with the path it was loaded from, in +// one lock: the path identifies the profile whose account email backs the login +// hint, so reading it separately could pair one profile's config with another's +// hint when a profile switch lands in between. +func (c *Client) authSnapshot() (*profilemanager.Config, string, *internal.ConnectClient) { + c.stateMu.RLock() + defer c.stateMu.RUnlock() + return c.config, c.cfgPath, c.connectClient +} + func (c *Client) getConnectClient() *internal.ConnectClient { c.stateMu.RLock() defer c.stateMu.RUnlock() @@ -102,6 +152,7 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd execWorkaround(androidSDKVersion) net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket) + system.SetIFaceDiscover(iFaceDiscover) return &Client{ deviceName: deviceName, uiVersion: uiVersion, @@ -110,6 +161,8 @@ func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAd recorder: peer.NewRecorder(""), ctxCancelLock: &sync.Mutex{}, networkChangeListener: networkChangeListener, + netState: netstate.New(), + sweeper: netsweep.New(), } } @@ -143,16 +196,22 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid defer c.ctxCancel() c.ctxCancelLock.Unlock() - auth := NewAuthWithConfig(ctx, cfg) + auth := NewAuthWithConfig(ctx, cfg, cfgFile) err = auth.login(urlOpener, isAndroidTV) if err != nil { return err } - // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) - connectClient := internal.NewConnectClient(ctx, cfg, c.recorder) - c.setState(cfg, cacheDir, connectClient) + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, + internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) + c.setState(cfg, cacheDir, cfgFile, connectClient) + // This path runs the interactive SSO flow, so reaching here means the peer + // is authenticated again — release the latch Status() reports from. Clear + // only once the fresh connect client is installed: until then Status() + // still reads the previous run's context state, which holds the NeedsLogin + // that prompted this login, and would re-latch what was just cleared. + c.clearLoginRequired() return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } @@ -186,8 +245,9 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) - connectClient := internal.NewConnectClient(ctx, cfg, c.recorder) - c.setState(cfg, cacheDir, connectClient) + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, + internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) + c.setState(cfg, cacheDir, cfgFile, connectClient) return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } @@ -216,9 +276,47 @@ func (c *Client) RenewTun(fd int) error { return e.RenewTun(fd) } +func (c *Client) GetTunSettings() (*TunSettings, error) { + cc := c.getConnectClient() + if cc == nil { + return nil, fmt.Errorf("engine not running") + } + + e := cc.Engine() + if e == nil { + return nil, fmt.Errorf("engine not initialized") + } + + routes, searchDomains := e.TunSettings() + return &TunSettings{ + Routes: strings.Join(routes, ";"), + SearchDomains: strings.Join(searchDomains, ";"), + }, nil +} + +// SetNetworkAvailable feeds OS-reported network availability into the client. +// While unavailable, the internal reconnect loops suspend their attempts and +// the connection listener reports NoNetwork instead of Connecting; when +// availability returns, the loops resume immediately with a fresh backoff. +func (c *Client) SetNetworkAvailable(available bool) { + c.netState.Set(available) + c.recorder.SetNetworkAvailable(available) +} + +// NotifyNetworkChange marks the management, signal and relay connections +// stale after the OS switched networks and schedules a sweep that cuts +// whatever has not redialed on the new network by then. The engine and the +// TUN device stay untouched. +func (c *Client) NotifyNetworkChange() { + c.sweeper.MarkNetworkChange() + log.Infof("network change: connections marked stale") +} + // DebugBundle generates a debug bundle, uploads it, and returns the upload key. -// It works both with and without a running engine. -func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) { +// It works both with and without a running engine. anonymizeLevel is "default" +// or "strict"; strict also anonymizes internal IP ranges, peer names, and +// WireGuard public keys, and implies anonymize. +func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) { cfg, cacheDir, cc := c.stateSnapshot() // If the engine hasn't been started, load config from disk @@ -237,6 +335,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin InternalConfig: cfg, StatusRecorder: c.recorder, TempDir: cacheDir, + StatePath: platformFiles.StateFilePath(), } if cc != nil { @@ -247,6 +346,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin deps.SyncResponse = resp if e := cc.Engine(); e != nil { + deps.RefreshStatus = func() { + e.RunHealthProbes(context.Background(), true) + } if cm := e.GetClientMetrics(); cm != nil { deps.ClientMetrics = cm } @@ -257,6 +359,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin deps, debug.BundleConfig{ Anonymize: anonymize, + AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel), IncludeSystemInfo: true, }, ) @@ -274,7 +377,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path) + key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false) if err != nil { return "", fmt.Errorf("upload debug bundle: %w", err) } @@ -296,6 +399,13 @@ func (c *Client) SetInfoLogLevel() { // PeersList return with the list of the PeerInfos func (c *Client) PeersList() *PeerInfoArray { + // The recorder only caches transfer counters and handshake times; nothing + // refreshes them on its own, so without this they read as zero. The desktop + // daemon does the same before serving a full peer status. + if err := c.recorder.RefreshWireGuardStats(); err != nil { + log.Debugf("failed to refresh WireGuard stats: %v", err) + } + fullStatus := c.recorder.GetFullStatus() peerInfos := make([]PeerInfo, len(fullStatus.Peers)) @@ -306,6 +416,20 @@ func (c *Client) PeersList() *PeerInfoArray { FQDN: p.FQDN, ConnStatus: int(p.ConnStatus), Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())}, + + PubKey: p.PubKey, + Latency: formatDuration(p.Latency), + LatencyMs: p.Latency.Milliseconds(), + BytesRx: p.BytesRx, + BytesTx: p.BytesTx, + ConnStatusUpdate: formatTime(p.ConnStatusUpdate), + Relayed: p.Relayed, + RosenpassEnabled: p.RosenpassEnabled, + LastWireguardHandshake: formatTime(p.LastWireguardHandshake), + LocalIceCandidateType: p.LocalIceCandidateType, + RemoteIceCandidateType: p.RemoteIceCandidateType, + LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint, + RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint, } peerInfos[n] = pi } @@ -428,7 +552,11 @@ func (c *Client) OnUpdatedHostDNS(list *DNSList) error { // SetConnectionListener set the network connection listener func (c *Client) SetConnectionListener(listener ConnectionListener) { - c.recorder.SetConnectionListener(listener) + if listener == nil { + c.recorder.RemoveConnectionListener() + return + } + c.recorder.SetConnectionListener(connectionListenerAdapter{listener}) } // RemoveConnectionListener remove connection listener @@ -436,10 +564,6 @@ func (c *Client) RemoveConnectionListener() { c.recorder.RemoveConnectionListener() } -func (c *Client) toggleRoute(command routeCommand) error { - return command.toggleRoute() -} - func (c *Client) getRouteManager() (routemanager.Manager, error) { client := c.getConnectClient() if client == nil { @@ -459,22 +583,22 @@ func (c *Client) getRouteManager() (routemanager.Manager, error) { return manager, nil } -func (c *Client) SelectRoute(route string) error { +func (c *Client) SelectRoute(id string) error { manager, err := c.getRouteManager() if err != nil { return err } - return c.toggleRoute(selectRouteCommand{route: route, manager: manager}) + return manager.SelectRoutes([]route.NetID{route.NetID(id)}, true) } -func (c *Client) DeselectRoute(route string) error { +func (c *Client) DeselectRoute(id string) error { manager, err := c.getRouteManager() if err != nil { return err } - return c.toggleRoute(deselectRouteCommand{route: route, manager: manager}) + return manager.DeselectRoutes([]route.NetID{route.NetID(id)}) } // getNetworkDomainsFromRoute extracts domains from a route and enriches each domain @@ -509,3 +633,28 @@ func exportEnvList(list *EnvList) { } } } + +// formatDuration renders a duration for display, trimming the fractional part +// to two digits so latencies read as "12.34ms" rather than "12.345678ms". +func formatDuration(d time.Duration) string { + ds := d.String() + dotIndex := strings.Index(ds, ".") + if dotIndex == -1 { + return ds + } + + endIndex := min(dotIndex+3, len(ds)) + + // Skip the remaining digits so only the unit suffix is appended back. + unitStart := endIndex + for unitStart < len(ds) && ds[unitStart] >= '0' && ds[unitStart] <= '9' { + unitStart++ + } + return ds[:endIndex] + ds[unitStart:] +} + +// formatTime renders a timestamp in UTC using a fixed layout. The zero time is +// passed through as-is so the UI can recognise it and show "never" instead. +func formatTime(t time.Time) string { + return t.UTC().Format("2006-01-02 15:04:05") +} diff --git a/client/android/connection_listener.go b/client/android/connection_listener.go new file mode 100644 index 000000000..77c47574b --- /dev/null +++ b/client/android/connection_listener.go @@ -0,0 +1,41 @@ +//go:build android + +package android + +import ( + "github.com/netbirdio/netbird/client/internal/peer" +) + +// Client state values delivered via ConnectionListener.OnStateChanged, +// re-exported as basic constants so gomobile emits them into the generated +// Java bindings. They mirror peer.ClientState*: append-only, never reorder. +const ( + ClientStateDisconnected = int(peer.ClientStateDisconnected) + ClientStateConnected = int(peer.ClientStateConnected) + ClientStateConnecting = int(peer.ClientStateConnecting) + ClientStateDisconnecting = int(peer.ClientStateDisconnecting) + ClientStateNoNetwork = int(peer.ClientStateNoNetwork) +) + +// ConnectionListener export internal Listener for mobile. It mirrors +// peer.Listener with OnStateChanged taking a plain int (one of the +// ClientState* constants), because gomobile cannot bind named types. +type ConnectionListener interface { + OnStateChanged(state int) + OnConnected() + OnDisconnected() + OnConnecting() + OnDisconnecting() + OnAddressChanged(string, string) + OnPeersListChanged(int) +} + +// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to +// peer.Listener, converting the typed state to the int the binding carries. +type connectionListenerAdapter struct { + ConnectionListener +} + +func (a connectionListenerAdapter) OnStateChanged(state peer.ClientState) { + a.ConnectionListener.OnStateChanged(int(state)) +} diff --git a/client/android/env_list.go b/client/android/env_list.go index a0a4d7040..d0e0a1e78 100644 --- a/client/android/env_list.go +++ b/client/android/env_list.go @@ -10,7 +10,7 @@ var ( EnvKeyNBForceRelay = peer.EnvKeyNBForceRelay // EnvKeyNBLazyConn Exported for Android java client to configure lazy connection - EnvKeyNBLazyConn = lazyconn.EnvEnableLazyConn + EnvKeyNBLazyConn = lazyconn.EnvLazyConn // EnvKeyNBInactivityThreshold Exported for Android java client to configure connection inactivity threshold EnvKeyNBInactivityThreshold = lazyconn.EnvInactivityThreshold diff --git a/client/android/login.go b/client/android/login.go index a9422cdbf..3742e01a5 100644 --- a/client/android/login.go +++ b/client/android/login.go @@ -4,8 +4,11 @@ import ( "context" "fmt" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mobile" "github.com/netbirdio/netbird/client/system" ) @@ -36,12 +39,20 @@ type Auth struct { } // NewAuth instantiate Auth struct and validate the management URL +// +// The configuration at cfgPath is reused when one is already there, and only created when it is +// not. Building a fresh in-memory config unconditionally gives the client a new WireGuard key on +// every call: the peer registers under that key, the key is written out, and any peer registered by +// an earlier call is orphaned on the server. It also breaks a client that enrols and then runs from +// the persisted config, because the identity it registered is not the one it runs with — the +// management stream rejects it with "no peer auth method provided". func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { inputCfg := profilemanager.ConfigInput{ + ConfigPath: cfgPath, ManagementURL: mgmURL, } - cfg, err := profilemanager.CreateInMemoryConfig(inputCfg) + cfg, err := profilemanager.UpdateOrCreateConfig(inputCfg) if err != nil { return nil, err } @@ -53,11 +64,14 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { }, nil } -// NewAuthWithConfig instantiate Auth based on existing config -func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config) *Auth { +// NewAuthWithConfig instantiate Auth based on existing config. cfgPath is the +// file the config was loaded from; it identifies the profile whose account email +// backs the login_hint. +func NewAuthWithConfig(ctx context.Context, config *profilemanager.Config, cfgPath string) *Auth { return &Auth{ - ctx: ctx, - config: config, + ctx: ctx, + config: config, + cfgPath: cfgPath, } } @@ -150,12 +164,14 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error { } jwtToken := "" + email := "" if needsLogin { tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV) if err != nil { return fmt.Errorf("interactive sso login failed: %v", err) } jwtToken = tokenInfo.GetTokenToUse() + email = tokenInfo.Email } err, _ = authClient.Login(a.ctx, "", jwtToken) @@ -163,27 +179,62 @@ func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error { return fmt.Errorf("login failed: %v", err) } + // Stored after Login, not before: a rejected token must not leave a hint + // pointing at an account that cannot be used. + if email != "" && a.cfgPath != "" { + if err := mobile.WriteProfileEmail(a.cfgPath, email); err != nil { + log.Warnf("failed to store profile account email: %v", err) + } + } + go urlOpener.OnLoginSuccess() return nil } func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) { - oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV) + oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV, profileLoginHint(a.cfgPath)) if err != nil { return nil, fmt.Errorf("failed to get OAuth flow: %v", err) } - flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO()) + return runOAuthFlow(a.ctx, oAuthFlow, urlOpener, nil) +} + +// profileLoginHint returns the stored account email for the profile at cfgPath. +// An empty hint is deliberate, not a fallback: a fresh profile leaves the +// choice to the IdP. Switching accounts is done by switching or removing +// profiles, not by logging out — logout keeps the email. +func profileLoginHint(cfgPath string) string { + if cfgPath == "" { + return "" + } + return mobile.ReadProfileEmail(cfgPath) +} + +// runOAuthFlow drives an already acquired OAuth flow to a token: requests the +// flow info, presents the verification URL through the opener and waits for +// the browser round-trip. Open is called synchronously — it is what marks the +// surface as opened on the client side, and a fast token's OnLoginSuccess is +// a no-op until it has, so the dismissal would be dropped rather than +// delayed. Openers must therefore not block: they post their UI work and +// return. onWaiting, when set, runs after the URL is shown, right before the +// blocking wait. +func runOAuthFlow(ctx context.Context, flow auth.OAuthFlow, urlOpener URLOpener, onWaiting func()) (*auth.TokenInfo, error) { + flowInfo, err := flow.RequestAuthInfo(ctx) if err != nil { - return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err) + return nil, fmt.Errorf("request auth info: %w", err) } - go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode) + urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode) - tokenInfo, err := oAuthFlow.WaitToken(a.ctx, flowInfo) + if onWaiting != nil { + onWaiting() + } + + tokenInfo, err := flow.WaitToken(ctx, flowInfo) if err != nil { - return nil, fmt.Errorf("waiting for browser login failed: %v", err) + return nil, fmt.Errorf("wait for token: %w", err) } return &tokenInfo, nil diff --git a/client/android/login_test.go b/client/android/login_test.go new file mode 100644 index 000000000..b04790f6b --- /dev/null +++ b/client/android/login_test.go @@ -0,0 +1,51 @@ +package android + +import ( + "path/filepath" + "testing" +) + +// NewAuth must reuse the configuration already at cfgPath rather than building a fresh one. +// +// Creating a new in-memory config on every call gives the client a new WireGuard private key each +// time. The peer registers under that key and the key is written out, so a peer registered by an +// earlier call is orphaned on the server — a client that enrols twice leaves two entries and owns +// neither. It also breaks enrol-then-run: RunWithoutLogin reloads the configuration from disk, so +// the identity that registered is not the identity that runs, and the management stream rejects it +// with "no peer auth method provided, please use a setup key or interactive SSO login". +func TestNewAuth_ReusesPersistedIdentity(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.json") + + first, err := NewAuth(cfgPath, "https://api.example.com:443") + if err != nil { + t.Fatalf("first NewAuth: %v", err) + } + if first.config.PrivateKey == "" { + t.Fatal("first NewAuth produced no private key") + } + + second, err := NewAuth(cfgPath, "https://api.example.com:443") + if err != nil { + t.Fatalf("second NewAuth: %v", err) + } + + if second.config.PrivateKey != first.config.PrivateKey { + t.Errorf("private key changed between calls: a second enrolment would orphan the peer registered by the first") + } +} + +// A missing configuration is still created, so a first enrolment works unchanged. +func TestNewAuth_CreatesConfigWhenAbsent(t *testing.T) { + cfgPath := filepath.Join(t.TempDir(), "config.json") + + auth, err := NewAuth(cfgPath, "https://api.example.com:443") + if err != nil { + t.Fatalf("NewAuth: %v", err) + } + if auth.config == nil || auth.config.PrivateKey == "" { + t.Fatal("NewAuth did not create a usable configuration") + } + if auth.cfgPath != cfgPath { + t.Errorf("cfgPath = %q, want %q", auth.cfgPath, cfgPath) + } +} diff --git a/client/android/peer_notifier.go b/client/android/peer_notifier.go index c2595e574..f525055bb 100644 --- a/client/android/peer_notifier.go +++ b/client/android/peer_notifier.go @@ -12,12 +12,30 @@ const ( ) // PeerInfo describe information about the peers. It designed for the UI usage +// +// The fields below ConnStatus back the peer detail screen. Durations and times +// are pre-formatted into strings so the UI does not have to know Go's layouts; +// Latency is additionally exposed as LatencyMs for colour coding. type PeerInfo struct { IP string IPv6 string FQDN string ConnStatus int Routes PeerRoutes + + PubKey string + Latency string + LatencyMs int64 + BytesRx int64 + BytesTx int64 + ConnStatusUpdate string + Relayed bool + RosenpassEnabled bool + LastWireguardHandshake string + LocalIceCandidateType string + RemoteIceCandidateType string + LocalIceCandidateEndpoint string + RemoteIceCandidateEndpoint string } func (p *PeerInfo) GetPeerRoutes() *PeerRoutes { diff --git a/client/android/profile_prefs.go b/client/android/profile_prefs.go new file mode 100644 index 000000000..a761ebbcf --- /dev/null +++ b/client/android/profile_prefs.go @@ -0,0 +1,37 @@ +//go:build android + +package android + +import ( + "fmt" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +type prefsStore interface { + Get(namespace string, v any) (bool, error) + Put(namespace string, v any) error +} + +type profilePrefs struct { + prefs *profilemanager.Prefs +} + +func newProfilePrefs(configDir, profileID string) (*profilePrefs, error) { + if configDir == "" || profileID == "" { + return nil, fmt.Errorf("profile prefs require a config dir and profile ID") + } + prefs, err := NewProfileManager(configDir).impl.ProfilePrefs(profileID) + if err != nil { + return nil, err + } + return &profilePrefs{prefs: prefs}, nil +} + +func (p *profilePrefs) Get(namespace string, v any) (bool, error) { + return p.prefs.Get(namespace, v) +} + +func (p *profilePrefs) Put(namespace string, v any) error { + return p.prefs.Put(namespace, v) +} diff --git a/client/android/route_command.go b/client/android/route_command.go deleted file mode 100644 index 5e7357335..000000000 --- a/client/android/route_command.go +++ /dev/null @@ -1,70 +0,0 @@ -//go:build android - -package android - -import ( - "fmt" - - log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" - - "github.com/netbirdio/netbird/client/internal/routemanager" - "github.com/netbirdio/netbird/route" -) - -func executeRouteToggle(id string, manager routemanager.Manager, - operationName string, - routeOperation func(routes []route.NetID, allRoutes []route.NetID) error) error { - netID := route.NetID(id) - routes := []route.NetID{netID} - - routesMap := manager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - - log.Debugf("%s with ids: %v", operationName, routes) - - if err := routeOperation(routes, maps.Keys(routesMap)); err != nil { - log.Debugf("error when %s: %s", operationName, err) - return fmt.Errorf("error %s: %w", operationName, err) - } - - manager.TriggerSelection(manager.GetClientRoutes()) - - return nil -} - -type routeCommand interface { - toggleRoute() error -} - -type selectRouteCommand struct { - route string - manager routemanager.Manager -} - -func (s selectRouteCommand) toggleRoute() error { - routeSelector := s.manager.GetRouteSelector() - if routeSelector == nil { - return fmt.Errorf("no route selector available") - } - - routeOperation := func(routes []route.NetID, allRoutes []route.NetID) error { - return routeSelector.SelectRoutes(routes, true, allRoutes) - } - - return executeRouteToggle(s.route, s.manager, "selecting route", routeOperation) -} - -type deselectRouteCommand struct { - route string - manager routemanager.Manager -} - -func (d deselectRouteCommand) toggleRoute() error { - routeSelector := d.manager.GetRouteSelector() - if routeSelector == nil { - return fmt.Errorf("no route selector available") - } - - return executeRouteToggle(d.route, d.manager, "deselecting route", routeSelector.DeselectRoutes) -} diff --git a/client/android/session.go b/client/android/session.go new file mode 100644 index 000000000..d5da09c93 --- /dev/null +++ b/client/android/session.go @@ -0,0 +1,312 @@ +//go:build android + +package android + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/auth" + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + "github.com/netbirdio/netbird/client/internal/peer" + cProto "github.com/netbirdio/netbird/client/proto" +) + +// StateChangeListener receives client state notifications. +// +// OnStateChanged is a payload-free wake-up whenever the state snapshot +// changed: connection state, the run-loop status label (e.g. NeedsLogin) or +// the session deadline. It mirrors the daemon's SubscribeStatus stream +// trigger — on each signal the consumer pulls the fresh values via +// Status() / SessionExpiresAtUnix(). +// +// OnSessionExpiring forwards the engine's session-expiry warnings, fired at +// sessionwatch.WarningLead before the deadline and again at FinalWarningLead +// (finalWarning true). The second one is suppressed when the user dismissed +// the first via DismissSessionWarning. The daemon turns the same events into +// its tray notification. +type StateChangeListener interface { + OnStateChanged() + OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool) +} + +// Status returns the connect run-loop's status label — the same value the +// desktop daemon serves in StatusResponse.Status. "NeedsLogin" means the +// management server rejected the peer and an interactive login is required. +// +// The label is latched: the run loop keeps its status in a per-run context +// state, which a restart replaces with a fresh Idle one, so an engine restart +// (network change, always-on) would otherwise erase the fact that the peer +// still needs to log in. Only a successful interactive login or extend clears +// it — see clearLoginRequired. +func (c *Client) Status() string { + latched, generation := c.loginRequiredState() + if latched { + return string(internal.StatusNeedsLogin) + } + cc := c.getConnectClient() + if cc == nil { + return string(internal.StatusIdle) + } + status := cc.Status() + if status == internal.StatusNeedsLogin { + c.latchLoginRequired(generation) + } + return string(status) +} + +func (c *Client) loginRequiredState() (bool, uint64) { + c.loginRequiredMu.Lock() + defer c.loginRequiredMu.Unlock() + return c.loginRequired, c.loginCleared +} + +// latchLoginRequired records a NeedsLogin observation, unless a clear landed +// while the caller was reading the run loop's status: cc.Status() is read +// outside the lock, so a login or extend completing in that window would +// otherwise be undone by this stale observation, stranding the UI on +// "login required" over a healthy session. +func (c *Client) latchLoginRequired(observedGeneration uint64) { + c.loginRequiredMu.Lock() + defer c.loginRequiredMu.Unlock() + if c.loginCleared != observedGeneration { + return + } + c.loginRequired = true +} + +// clearLoginRequired releases the latch after a successful interactive login +// or session extend, and invalidates any observation already in flight. +func (c *Client) clearLoginRequired() { + c.loginRequiredMu.Lock() + defer c.loginRequiredMu.Unlock() + c.loginRequired = false + c.loginCleared++ +} + +// SessionExpiresAtUnix returns the SSO session deadline as unix seconds, or 0 +// when no deadline is known (not SSO-registered, expiry disabled, or the +// engine has not received one yet). A past value means the session expired. +// Mirror of StatusResponse.sessionExpiresAt on the desktop daemon. +func (c *Client) SessionExpiresAtUnix() int64 { + deadline := c.recorder.GetSessionExpiresAt() + if deadline.IsZero() { + return 0 + } + return deadline.Unix() +} + +// SetStateChangeListener registers the state notification listener. +// Replaces any previously registered listener; remove it with +// RemoveStateChangeListener. +func (c *Client) SetStateChangeListener(listener StateChangeListener) { + c.stateChangeMu.Lock() + defer c.stateChangeMu.Unlock() + c.stopStateChangeWatchLocked() + if listener == nil { + return + } + + // Both subscriptions are buffered (one pending tick, ten pending events), + // so unsubscribing is not enough to stop callbacks: the loops would drain + // what is already queued and deliver it to a listener the caller has + // already removed or replaced. Gate every callback on this registration's + // own signal, which is closed before unsubscribing. + done := make(chan struct{}) + c.stateChangeDone = done + + id, ch := c.recorder.SubscribeToStateChanges() + c.stateChangeSubID = id + // The channel is closed by UnsubscribeFromStateChanges, which ends the + // goroutine. Ticks are coalesced (buffer of one), so a burst of changes + // wakes the listener once. + go func() { + for range ch { + select { + case <-done: + return + default: + } + listener.OnStateChanged() + } + }() + + c.eventSub = c.recorder.SubscribeToEvents() + go watchSessionWarnings(c.eventSub, listener, done) +} + +// RemoveStateChangeListener unregisters the state notification listener. +func (c *Client) RemoveStateChangeListener() { + c.stateChangeMu.Lock() + defer c.stateChangeMu.Unlock() + c.stopStateChangeWatchLocked() +} + +// DismissSessionWarning records the user's "Dismiss" on the first expiry +// warning and suppresses the final one for the current deadline. A refreshed +// deadline re-arms both. No-op while the engine is not running. +func (c *Client) DismissSessionWarning() { + cc := c.getConnectClient() + if cc == nil { + return + } + engine := cc.Engine() + if engine == nil { + return + } + engine.DismissSessionWarning() +} + +// ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and +// asks the management server to extend the session deadline. The tunnel is +// untouched: no resync, no reconnect. Async; the result arrives on the +// listener. Mirror of the daemon's RequestExtendAuthSession / +// WaitExtendAuthSession RPC pair, with URLOpener playing the "UI opens the +// browser" role. +// +// Only one flow may be in flight: the PKCE step binds a fixed loopback port, +// so a second concurrent flow would fail on that bind. Call +// CancelExtendAuthSession when the user abandons the browser. +func (c *Client) ExtendAuthSession(urlOpener URLOpener, isAndroidTV bool, resultListener ErrListener) { + ctx, err := c.beginExtend() + if err != nil { + resultListener.OnError(err) + return + } + + go func() { + defer c.endExtend() + if err := c.extendAuthSession(ctx, urlOpener, isAndroidTV); err != nil { + resultListener.OnError(err) + return + } + resultListener.OnSuccess() + }() +} + +// CancelExtendAuthSession aborts an in-flight ExtendAuthSession. The tunnel is +// left alone — unlike the login flow, which cancels the whole client context +// by stopping the engine. Without this the abandoned PKCE wait keeps its +// loopback port for the full flow timeout and blocks every later attempt. +// No-op when no flow is running. +func (c *Client) CancelExtendAuthSession() { + c.extendMu.Lock() + defer c.extendMu.Unlock() + if c.extendCancel != nil { + c.extendCancel() + } +} + +func (c *Client) stopStateChangeWatchLocked() { + // Signal first, unsubscribe second: closing the channels only stops new + // items, and the loops would still hand whatever is buffered to a listener + // that is no longer registered. + if c.stateChangeDone != nil { + close(c.stateChangeDone) + c.stateChangeDone = nil + } + if c.stateChangeSubID != "" { + c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID) + c.stateChangeSubID = "" + } + if c.eventSub != nil { + // Closes the channel, which ends watchSessionWarnings. + c.recorder.UnsubscribeFromEvents(c.eventSub) + c.eventSub = nil + } +} + +// watchSessionWarnings forwards the engine's session-expiry warnings to the +// listener. The event stream also carries unrelated traffic — network-map +// updates on every sync, DNS and route errors — so everything but an +// AUTHENTICATION event carrying the session-warning marker is dropped. Exits +// when the subscription is closed by UnsubscribeFromEvents, or earlier when +// done is closed — the stream buffers up to ten events, and a deregistered +// listener must not receive the ones already queued. +func watchSessionWarnings(sub *peer.EventSubscription, listener StateChangeListener, done <-chan struct{}) { + for ev := range sub.Events() { + select { + case <-done: + return + default: + } + if ev.GetCategory() != cProto.SystemEvent_AUTHENTICATION { + continue + } + meta := ev.GetMetadata() + if meta[sessionwatch.MetaSessionWarning] != "true" { + // Other AUTHENTICATION events exist (e.g. a deadline rejected as + // out of range); they carry no warning marker. + continue + } + deadline, err := sessionwatch.ParseExpiresAt(meta[sessionwatch.MetaSessionExpiresAt]) + if err != nil { + log.Warnf("session warning event with unparsable deadline: %v", err) + continue + } + lead, err := sessionwatch.ParseLeadMinutes(meta[sessionwatch.MetaSessionLeadMinutes]) + if err != nil { + // Informational only — the deadline above is what drives the UI. + lead = 0 + } + listener.OnSessionExpiring(deadline.Unix(), int64(lead), + meta[sessionwatch.MetaSessionFinal] == "true") + } +} + +func (c *Client) beginExtend() (context.Context, error) { + c.extendMu.Lock() + defer c.extendMu.Unlock() + if c.extendCancel != nil { + return nil, fmt.Errorf("session extend already in progress") + } + ctx, cancel := context.WithCancel(context.Background()) + c.extendCancel = cancel + return ctx, nil +} + +func (c *Client) endExtend() { + c.extendMu.Lock() + defer c.extendMu.Unlock() + if c.extendCancel != nil { + c.extendCancel() + c.extendCancel = nil + } +} + +func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error { + cfg, cfgPath, cc := c.authSnapshot() + if cfg == nil || cc == nil { + return fmt.Errorf("engine is not running") + } + engine := cc.Engine() + if engine == nil { + return fmt.Errorf("engine is not initialized") + } + + authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg) + if err != nil { + return fmt.Errorf("failed to create auth client: %v", err) + } + defer authClient.Close() + + // Passing the config path makes the flow pick up the login_hint: an extend + // renews the session of the account already signed in, so it must not stop to + // offer a choice. + a := NewAuthWithConfig(ctx, cfg, cfgPath) + tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV) + if err != nil { + return fmt.Errorf("interactive sso login failed: %v", err) + } + + if _, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()); err != nil { + return err + } + c.clearLoginRequired() + + go urlOpener.OnLoginSuccess() + return nil +} diff --git a/client/android/ssh_client.go b/client/android/ssh_client.go new file mode 100644 index 000000000..2822b6539 --- /dev/null +++ b/client/android/ssh_client.go @@ -0,0 +1,649 @@ +//go:build android + +package android + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + gossh "golang.org/x/crypto/ssh" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/auth" + "github.com/netbirdio/netbird/client/internal/profilemanager" + nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/client/ssh/detection" +) + +const ( + sshDialTimeout = 30 * time.Second + sshDetectionTimeout = 5 * time.Second +) + +// PasswordRequiredMarker tells Java to prompt for a password and retry. It is +// a string because gomobile flattens errors to their message, so a sentinel +// value would not survive the binding. +const PasswordRequiredMarker = "netbird-ssh-password-required" + +// HostKeyUnknownMarker tells Java to show the fingerprint and, on confirmation, +// retry with TrustHostKey set. The presented fingerprint is appended after the +// marker so the prompt can display it and the retry can guard against a key +// that changed between the two connects. Only regular (non-NetBird) servers +// reach this: NetBird peers verify against the registry. +const HostKeyUnknownMarker = "netbird-ssh-hostkey-unknown" + +var ( + errPasswordRequired = errors.New(PasswordRequiredMarker) + errClientClosed = errors.New("ssh client closed") +) + +// errHostKeyUnknown carries the presented fingerprint so Connect can build the +// marker message the Java side parses. +type errHostKeyUnknown struct { + fingerprint string +} + +func (e *errHostKeyUnknown) Error() string { + return HostKeyUnknownMarker + ":" + e.fingerprint +} + +// SSHTerminalListener receives SSH session events. It is implemented in Java. +// +// All callbacks are invoked from goroutines and may run concurrently with each +// other; the implementation must be safe to call from any thread. +type SSHTerminalListener interface { + OnConnected() + OnData(data []byte) + OnClose(reason string) + OnError(message string) +} + +// SSHClient is a NetBird-aware SSH client exposed to Java via gomobile. +// +// It dials through the running NetBird tunnel and runs a standard SSH session +// on top with PTY enabled. Host-key verification uses the NetBird-provided +// peer SSH host keys, identical to the desktop client. +type SSHClient struct { + nb *Client + mu sync.Mutex + listener SSHTerminalListener + urlOpener URLOpener + + sshClient *gossh.Client + session *gossh.Session + stdin io.WriteCloser + closed bool + + // gen identifies the current connection attempt. Connect and Close bump it, + // so an in-flight dial or a reader left over from a previous connection + // finds itself stale and stays silent instead of publishing OnConnected or + // OnClose for a connection the caller already abandoned. + gen uint64 + dialCancel context.CancelFunc + + // knownHostsConfigDir and knownHostsProfile locate the TOFU store for + // regular SSH servers in the profile's preferences. Java supplies them, + // since an overlay IP is a different host under a different profile. Empty + // until set: without them a regular server cannot be verified and Connect + // refuses one. + knownHostsConfigDir string + knownHostsProfile string + // trustHostKey carries the fingerprint the user confirmed on a previous + // attempt, so the retry accepts exactly that key and persists it. + trustHostKey string +} + +// NewSSHClient creates a new SSH client bound to the running NetBird Client. +func NewSSHClient(c *Client) *SSHClient { + return &SSHClient{nb: c} +} + +// SetListener registers the Java listener. Must be called before Connect to +// receive any events. +func (s *SSHClient) SetListener(l SSHTerminalListener) { + s.mu.Lock() + s.listener = l + s.mu.Unlock() +} + +// SetURLOpener registers the Java URL opener used to display the device-code +// authorization page in a Custom Tabs window when the target peer requires +// JWT authentication. Must be set before Connect to be effective. +func (s *SSHClient) SetURLOpener(opener URLOpener) { + s.mu.Lock() + s.urlOpener = opener + s.mu.Unlock() +} + +// SetKnownHostsStore points the TOFU host-key store at a profile's preferences. +// Must be set before connecting to a regular SSH server; without it such a +// server cannot be verified and Connect refuses one. +func (s *SSHClient) SetKnownHostsStore(configDir, profileID string) { + s.mu.Lock() + s.knownHostsConfigDir = configDir + s.knownHostsProfile = profileID + s.mu.Unlock() +} + +// TrustHostKey records the fingerprint the user confirmed for a regular server, +// so the next Connect accepts that exact key and adds it to the known-hosts +// store. Passing a fingerprint that no longer matches makes the connect fail +// rather than trust a key that changed since the prompt. +func (s *SSHClient) TrustHostKey(fingerprint string) { + s.mu.Lock() + s.trustHostKey = fingerprint + s.mu.Unlock() +} + +// Connect dials the SSH server through the NetBird tunnel and performs the +// SSH handshake. It auto-detects the server type via SSH banner inspection +// and selects the appropriate authentication path: +// +// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code +// flow, opens the verification URL through the registered URLOpener, and +// uses the resulting token as the SSH password. Host-key verification +// uses the NetBird peer registry. +// - NetBird-SSH server without JWT: authenticates with the NetBird SSH +// private key. Host-key verification uses the NetBird peer registry. +// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key +// first (so a user-installed NetBird public key works), then falls back +// to the supplied password if non-empty. Host-key verification is +// trust-on-first-use against the per-profile known-hosts store. +// +// The password parameter is only consulted for regular SSH servers. +func (s *SSHClient) Connect(host string, port int, user, password string) error { + if port < 1 || port > 65535 { + return fmt.Errorf("invalid port: %d", port) + } + + cfg, cfgPath, cc := s.nb.authSnapshot() + if cc == nil { + return errors.New("netbird client not running") + } + if cfg == nil { + return errors.New("netbird config not loaded") + } + engine := cc.Engine() + if engine == nil { + return errors.New("netbird engine not available") + } + + s.mu.Lock() + s.gen++ + gen := s.gen + s.mu.Unlock() + + serverType := detectServerType(host, port) + log.Debugf("SSH server type: %s", serverType) + + authMethods, hostKeyCallback, err := s.buildAuth(cfg, cfgPath, engine, serverType, password) + if err != nil { + return err + } + + clientConfig := &gossh.ClientConfig{ + User: user, + Auth: authMethods, + HostKeyCallback: hostKeyCallback, + Timeout: sshDialTimeout, + } + err = s.dialAndHandshake(gen, host, port, clientConfig) + + // An unknown host key is a prompt, not a failure: return the marker intact + // (rootCause would unwrap it) so Java can show the fingerprint and retry. + var unknownHost *errHostKeyUnknown + if errors.As(err, &unknownHost) { + return errors.New(unknownHost.Error()) + } + + // A regular server may still accept a password, so let the caller ask for + // one instead of failing. NetBird servers never use a password, so a + // failure there is genuine. + if err != nil && serverType != detection.ServerTypeNetBirdJWT && + serverType != detection.ServerTypeNetBirdNoJWT && isAuthFailure(err) && + passwordCouldHelp(err, password != "") { + return errPasswordRequired + } + if err != nil { + return rootCause(err) + } + return nil +} + +// StartSession requests a PTY and starts an interactive shell. Output from +// the session is forwarded to the listener via OnData. +func (s *SSHClient) StartSession(cols, rows int) error { + err := s.startSession(cols, rows) + if err != nil { + log.Infof("SSH: start session failed: %v", err) + return rootCause(err) + } + return nil +} + +// Write sends data to the SSH session stdin. +func (s *SSHClient) Write(data []byte) error { + s.mu.Lock() + stdin := s.stdin + s.mu.Unlock() + if stdin == nil { + return errors.New("ssh session not started") + } + if _, err := stdin.Write(data); err != nil { + return fmt.Errorf("write stdin: %w", err) + } + return nil +} + +// Resize updates the PTY window size. +func (s *SSHClient) Resize(cols, rows int) error { + s.mu.Lock() + session := s.session + s.mu.Unlock() + if session == nil { + return errors.New("ssh session not started") + } + return session.WindowChange(rows, cols) +} + +// Reset makes a closed client usable for another Connect: Close leaves the +// one-shot guard set, and clearing it lets the same client back a reconnect. +func (s *SSHClient) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + s.closed = false +} + +// Close terminates the SSH session and underlying connection. Safe to call +// multiple times. +func (s *SSHClient) Close() error { + s.mu.Lock() + s.gen++ + if s.dialCancel != nil { + s.dialCancel() + s.dialCancel = nil + } + sshClient := s.sshClient + session := s.session + stdin := s.stdin + s.sshClient = nil + s.session = nil + s.stdin = nil + notify := !s.closed + s.closed = true + listener := s.listener + s.mu.Unlock() + + if stdin != nil { + if err := stdin.Close(); err != nil { + log.Debugf("ssh: stdin close: %v", err) + } + } + if session != nil { + if err := session.Close(); err != nil && !errors.Is(err, io.EOF) { + log.Debugf("ssh: session close: %v", err) + } + } + var firstErr error + if sshClient != nil { + if err := sshClient.Close(); err != nil { + firstErr = err + } + } + if notify && listener != nil { + listener.OnClose("closed by client") + } + return firstErr +} + +func (s *SSHClient) startSession(cols, rows int) error { + log.Debugf("SSH: starting session %dx%d", cols, rows) + s.mu.Lock() + sshClient := s.sshClient + gen := s.gen + s.mu.Unlock() + + if sshClient == nil { + return errors.New("ssh client not connected") + } + + pty, err := nbssh.StartPTYSession(sshClient, cols, rows) + if err != nil { + return err + } + + s.mu.Lock() + if gen != s.gen { + s.mu.Unlock() + closeQuiet(pty.Session, "stale session") + return errClientClosed + } + s.session = pty.Session + s.stdin = pty.Stdin + s.mu.Unlock() + + readerDone := make(chan string, 2) + go func() { readerDone <- s.readLoop(pty.Stdout, "stdout") }() + go func() { readerDone <- s.readLoop(pty.Stderr, "stderr") }() + go func() { + reason := <-readerDone + if second := <-readerDone; reason == "" { + reason = second + } + s.notifyClose(gen, reason) + }() + log.Debug("SSH: session started, shell running") + return nil +} + +func (s *SSHClient) buildAuth(cfg *profilemanager.Config, cfgPath string, engine *internal.Engine, + serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) { + + switch serverType { + case detection.ServerTypeNetBirdJWT: + token, err := s.requestJWTToken(cfg, cfgPath) + if err != nil { + return nil, nil, fmt.Errorf("jwt: %w", err) + } + auths := []gossh.AuthMethod{gossh.Password(token)} + return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil + + case detection.ServerTypeNetBirdNoJWT: + if cfg.SSHKey == "" { + return nil, nil, errors.New("no NetBird SSH key available") + } + signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)) + if err != nil { + return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err) + } + auths := []gossh.AuthMethod{gossh.PublicKeys(signer)} + return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil + + case detection.ServerTypeRegular: + var auths []gossh.AuthMethod + if cfg.SSHKey != "" { + if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil { + auths = append(auths, gossh.PublicKeys(signer)) + } else { + log.Debugf("ssh: parse netbird key for regular auth: %v", err) + } + } + if password != "" { + pw := password + auths = append(auths, gossh.Password(pw)) + auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) { + answers := make([]string, len(questions)) + for i := range questions { + answers[i] = pw + } + return answers, nil + })) + } + if len(auths) == 0 { + // Nothing to offer at all: ask for a password rather than failing, + // so the caller can retry once the user supplies one. + return nil, nil, errPasswordRequired + } + callback, err := s.tofuHostKeyCallback() + if err != nil { + return nil, nil, err + } + return auths, callback, nil + + default: + return nil, nil, fmt.Errorf("unsupported SSH server type: %v", serverType) + } +} + +// tofuHostKeyCallback verifies a regular server's host key against the +// per-profile known-hosts store. An unknown host returns errHostKeyUnknown so +// Java can show the fingerprint and, once confirmed, retry with the key +// trusted; a changed key is rejected outright, as OpenSSH does. When the user +// has confirmed a fingerprint, the callback accepts exactly that key and +// appends it to the store. +func (s *SSHClient) tofuHostKeyCallback() (gossh.HostKeyCallback, error) { + s.mu.Lock() + configDir := s.knownHostsConfigDir + profileID := s.knownHostsProfile + trusted := s.trustHostKey + s.mu.Unlock() + + if configDir == "" || profileID == "" { + return nil, errors.New("no known-hosts store configured for regular SSH") + } + + store, err := openKnownHostsStore(configDir, profileID) + if err != nil { + return nil, fmt.Errorf("load known-hosts store: %w", err) + } + + return func(hostname string, remote net.Addr, key gossh.PublicKey) error { + verdict, err := store.verify(hostname, remote, key) + if err != nil { + return err + } + if verdict == hostKeyMatched { + return nil + } + if verdict == hostKeyChanged { + return fmt.Errorf("SSH host key changed for %s (possible attack)", hostname) + } + + fingerprint := gossh.FingerprintSHA256(key) + if trusted == "" { + return &errHostKeyUnknown{fingerprint: fingerprint} + } + if trusted != fingerprint { + return fmt.Errorf("SSH host key changed since it was confirmed for %s", hostname) + } + if err := store.append(hostname, remote, key); err != nil { + return fmt.Errorf("persist trusted host key: %w", err) + } + // The confirmation is spent: now that the key is stored, a later + // reconnect must verify against the file, not re-accept this fingerprint. + s.mu.Lock() + s.trustHostKey = "" + s.mu.Unlock() + return nil + }, nil +} + +func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config, cfgPath string) (string, error) { + s.mu.Lock() + urlOpener := s.urlOpener + s.mu.Unlock() + if urlOpener == nil { + return "", errors.New("URL opener not configured for JWT auth") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profileLoginHint(cfgPath)) + if err != nil { + return "", fmt.Errorf("create oauth flow: %w", err) + } + + // The status callback covers the browser round-trip, which would + // otherwise leave the terminal blank. + tokenInfo, err := runOAuthFlow(ctx, flow, urlOpener, func() { + s.notifyStatus("Waiting for browser authentication...") + }) + if err != nil { + return "", err + } + + token := tokenInfo.GetTokenToUse() + if token == "" { + return "", errors.New("empty token returned by IdP") + } + + // Tells the client the browser round-trip is over so it can dismiss the + // surface it opened, the same way the login and session-extend flows do. + // Without it the Custom Tab stays in front of the terminal even though the + // token has already been collected. + urlOpener.OnLoginSuccess() + + return token, nil +} + +func (s *SSHClient) dialAndHandshake(gen uint64, host string, port int, clientConfig *gossh.ClientConfig) error { + addr := net.JoinHostPort(host, strconv.Itoa(port)) + ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout) + defer cancel() + + s.mu.Lock() + if gen != s.gen { + s.mu.Unlock() + return errClientClosed + } + s.dialCancel = cancel + s.mu.Unlock() + + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("dial %s: %w", addr, err) + } + + client, err := nbssh.Handshake(ctx, conn, addr, clientConfig) + if err != nil { + return err + } + + s.mu.Lock() + if gen != s.gen { + s.mu.Unlock() + closeQuiet(client, "stale ssh client") + return errClientClosed + } + s.sshClient = client + listener := s.listener + s.mu.Unlock() + + if listener != nil { + listener.OnConnected() + } + return nil +} + +func (s *SSHClient) readLoop(r io.Reader, name string) string { + buf := make([]byte, 4096) + for { + n, err := r.Read(buf) + if n > 0 { + s.mu.Lock() + listener := s.listener + s.mu.Unlock() + if listener != nil { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + listener.OnData(chunk) + } + } + if err != nil { + // EOF is a normal shell exit, so report it without a reason. + if errors.Is(err, io.EOF) { + return "" + } + log.Debugf("ssh %s read: %v", name, err) + return rootCause(err).Error() + } + } +} + +// notifyStatus writes a progress line to the terminal through the normal +// output path, so long steps are visible while nothing else is arriving. +func (s *SSHClient) notifyStatus(text string) { + s.mu.Lock() + listener := s.listener + s.mu.Unlock() + if listener != nil { + listener.OnData([]byte("\r\n\x1b[33m" + text + "\x1b[0m\r\n")) + } +} + +func (s *SSHClient) notifyClose(gen uint64, reason string) { + s.mu.Lock() + if gen != s.gen || s.closed { + s.mu.Unlock() + return + } + s.closed = true + listener := s.listener + s.mu.Unlock() + if listener != nil { + listener.OnClose(reason) + } +} + +func closeQuiet(c io.Closer, label string) { + if c == nil { + return + } + if err := c.Close(); err != nil && !errors.Is(err, io.EOF) { + log.Debugf("ssh: close %s: %v", label, err) + } +} + +func detectServerType(host string, port int) detection.ServerType { + ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout) + defer cancel() + + dialer := &net.Dialer{} + serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port) + if err != nil { + log.Debugf("ssh: server detection failed: %v (assuming regular SSH)", err) + return detection.ServerTypeRegular + } + return serverType +} + +// rootCause returns the innermost error of a %w chain, so the terminal shows +// "i/o timeout" rather than every layer that added context on the way up. +func rootCause(err error) error { + for { + // A joined error has no single root, so keep it as-is. + if _, ok := err.(interface{ Unwrap() []error }); ok { + return err + } + next := errors.Unwrap(err) + if next == nil { + return err + } + err = next + } +} + +// isAuthFailure distinguishes credential rejection from dial, timeout and +// host-key errors, which retrying with a password would not fix. +func isAuthFailure(err error) bool { + if errors.Is(err, errPasswordRequired) { + return true + } + var partial *gossh.PartialSuccessError + if errors.As(err, &partial) { + return true + } + return strings.Contains(err.Error(), "unable to authenticate") +} + +// passwordCouldHelp reports whether prompting for a password again can change +// the outcome. gossh lists a method under "attempted methods" only when the +// server offered it, so a supplied password that was never attempted means the +// server does not accept passwords and the real error should surface instead. +func passwordCouldHelp(err error, passwordOffered bool) bool { + if !passwordOffered { + return true + } + msg := err.Error() + return strings.Contains(msg, "password") || strings.Contains(msg, "keyboard-interactive") +} diff --git a/client/android/ssh_known_hosts.go b/client/android/ssh_known_hosts.go new file mode 100644 index 000000000..eea90fd32 --- /dev/null +++ b/client/android/ssh_known_hosts.go @@ -0,0 +1,168 @@ +//go:build android + +package android + +import ( + "bytes" + "net" + "strconv" + "strings" + "sync" + + gossh "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +const knownHostsNamespace = "ssh" + +const ( + hostKeyUnknown hostKeyVerdict = iota + hostKeyMatched + hostKeyChanged +) + +var knownHostsMu sync.Mutex + +type hostKeyVerdict uint8 + +type knownHostsSection struct { + KnownHosts []string `json:"knownHosts"` +} + +type knownHostsStore struct { + prefs prefsStore +} + +// RemoveKnownHost deletes every known-hosts entry for host:port from the +// profile's store, so a host trusted for a session that is being deleted does +// not linger. Java calls this only once no session targets that host, so a +// shared host stays trusted. A missing entry is not an error: the goal state +// is "absent". +func RemoveKnownHost(configDir, profileID, host string, port int) error { + store, err := openKnownHostsStore(configDir, profileID) + if err != nil { + return err + } + return store.removeHost(host, port) +} + +func openKnownHostsStore(configDir, profileID string) (*knownHostsStore, error) { + prefs, err := newProfilePrefs(configDir, profileID) + if err != nil { + return nil, err + } + return &knownHostsStore{prefs: prefs}, nil +} + +func (st *knownHostsStore) verify(hostname string, remote net.Addr, key gossh.PublicKey) (hostKeyVerdict, error) { + lines, err := st.lines() + if err != nil { + return hostKeyUnknown, err + } + targets := knownHostsTargets(hostname, remote) + + verdict := hostKeyUnknown + for _, line := range lines { + pubKey, ok := knownHostsLineKey(line, targets) + if !ok { + continue + } + if pubKey.Type() == key.Type() && bytes.Equal(pubKey.Marshal(), key.Marshal()) { + return hostKeyMatched, nil + } + verdict = hostKeyChanged + } + return verdict, nil +} + +func (st *knownHostsStore) append(hostname string, remote net.Addr, key gossh.PublicKey) error { + line := knownhosts.Line(knownHostsTargets(hostname, remote), key) + + knownHostsMu.Lock() + defer knownHostsMu.Unlock() + + lines, err := st.lines() + if err != nil { + return err + } + return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: append(lines, line)}) +} + +func (st *knownHostsStore) removeHost(host string, port int) error { + target := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port))) + + knownHostsMu.Lock() + defer knownHostsMu.Unlock() + + lines, err := st.lines() + if err != nil { + return err + } + kept := make([]string, 0, len(lines)) + for _, line := range lines { + if knownHostsLineMatches(line, target) { + continue + } + kept = append(kept, line) + } + if len(kept) == len(lines) { + return nil + } + return st.prefs.Put(knownHostsNamespace, knownHostsSection{KnownHosts: kept}) +} + +func (st *knownHostsStore) lines() ([]string, error) { + var section knownHostsSection + if _, err := st.prefs.Get(knownHostsNamespace, §ion); err != nil { + return nil, err + } + return section.KnownHosts, nil +} + +func knownHostsTargets(hostname string, remote net.Addr) []string { + targets := []string{knownhosts.Normalize(hostname)} + if remote != nil { + if normalized := knownhosts.Normalize(remote.String()); normalized != targets[0] { + targets = append(targets, normalized) + } + } + return targets +} + +func knownHostsLineKey(line string, targets []string) (gossh.PublicKey, bool) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + return nil, false + } + _, hosts, pubKey, _, _, err := gossh.ParseKnownHosts([]byte(trimmed)) + if err != nil { + return nil, false + } + for _, host := range hosts { + for _, target := range targets { + if host == target { + return pubKey, true + } + } + } + return nil, false +} + +// knownHostsLineMatches reports whether a known-hosts line's address list +// contains the normalized target. Comment and blank lines never match. +func knownHostsLineMatches(line, target string) bool { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + return false + } + fields := strings.Fields(trimmed) + if len(fields) == 0 { + return false + } + for _, addr := range strings.Split(fields[0], ",") { + if addr == target { + return true + } + } + return false +} diff --git a/client/android/ssh_sessions.go b/client/android/ssh_sessions.go new file mode 100644 index 000000000..44b5464e9 --- /dev/null +++ b/client/android/ssh_sessions.go @@ -0,0 +1,104 @@ +//go:build android + +package android + +const ( + sshSessionsNamespace = "ssh-sessions" + maxStoredSSHSessions = 50 +) + +type sshSessionRecord struct { + ID string `json:"id"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` +} + +type sshSessionsSection struct { + Sessions []sshSessionRecord `json:"sessions"` +} + +// SSHSessionEntry is one stored SSH session, without any credential. +type SSHSessionEntry struct { + ID string + Host string + Port int + User string +} + +// SSHSessionArray wraps stored SSH sessions for gomobile compatibility. +type SSHSessionArray struct { + items []*SSHSessionEntry +} + +// NewSSHSessionArray creates an empty session array to fill via Add. +func NewSSHSessionArray() *SSHSessionArray { + return &SSHSessionArray{} +} + +// Add appends a session entry, oldest first. +func (a *SSHSessionArray) Add(id, host string, port int, user string) { + a.items = append(a.items, &SSHSessionEntry{ID: id, Host: host, Port: port, User: user}) +} + +// Length returns the number of entries. +func (a *SSHSessionArray) Length() int { + return len(a.items) +} + +// Get returns the entry at index i, or nil when out of range. +func (a *SSHSessionArray) Get(i int) *SSHSessionEntry { + if i < 0 || i >= len(a.items) { + return nil + } + return a.items[i] +} + +// SSHSessionStore reads and writes a profile's stored SSH sessions. +type SSHSessionStore struct { + prefs prefsStore +} + +// NewSSHSessionStore opens the session store of the given profile. +func NewSSHSessionStore(configDir, profileID string) (*SSHSessionStore, error) { + prefs, err := newProfilePrefs(configDir, profileID) + if err != nil { + return nil, err + } + return &SSHSessionStore{prefs: prefs}, nil +} + +// Load returns the stored sessions, oldest first. +func (s *SSHSessionStore) Load() (*SSHSessionArray, error) { + var section sshSessionsSection + if _, err := s.prefs.Get(sshSessionsNamespace, §ion); err != nil { + return nil, err + } + + out := NewSSHSessionArray() + for _, record := range section.Sessions { + if record.ID == "" || record.Host == "" { + continue + } + out.Add(record.ID, record.Host, record.Port, record.User) + } + return out, nil +} + +// Save replaces the stored sessions, keeping only the newest entries when the +// list exceeds the storage cap. +func (s *SSHSessionStore) Save(sessions *SSHSessionArray) error { + var items []*SSHSessionEntry + if sessions != nil { + items = sessions.items + } + if len(items) > maxStoredSSHSessions { + items = items[len(items)-maxStoredSSHSessions:] + } + + records := make([]sshSessionRecord, 0, len(items)) + for _, item := range items { + records = append(records, sshSessionRecord{ID: item.ID, Host: item.Host, Port: item.Port, User: item.User}) + } + return s.prefs.Put(sshSessionsNamespace, sshSessionsSection{Sessions: records}) +} diff --git a/client/anonymize/anonymize.go b/client/anonymize/anonymize.go index c140cef89..c5d43ed55 100644 --- a/client/anonymize/anonymize.go +++ b/client/anonymize/anonymize.go @@ -2,6 +2,7 @@ package anonymize import ( "crypto/rand" + "encoding/base64" "fmt" "math/big" "net" @@ -15,13 +16,88 @@ import ( const anonTLD = ".domain" +// Level selects how much the anonymizer redacts. Levels are ordered: a higher +// level redacts strictly more. On the wire (protos, flags) levels travel as +// their string form. +type Level int + +const ( + // LevelDefault anonymizes public IP addresses, IPv6 ULA, domains, and MAC + // addresses. Internal IPv4 ranges (RFC 1918, CGNAT, link-local) are + // preserved so support can reason about the real topology. + LevelDefault Level = iota + // LevelStrict additionally anonymizes internal IP ranges, peer names, and + // WireGuard public keys. + LevelStrict +) + +// LevelDefaultString and LevelStrictString are the wire forms of the levels, +// for boundaries that pass levels as strings (flags, protos, mobile bindings). +const ( + LevelDefaultString = "default" + LevelStrictString = "strict" +) + +// ParseLevel maps s to a Level. Empty means LevelDefault; anything +// unrecognized maps to LevelStrict so an unknown request never yields less +// anonymization than intended. +func ParseLevel(s string) Level { + switch strings.ToLower(s) { + case "", LevelDefaultString: + return LevelDefault + default: + return LevelStrict + } +} + +// String returns the wire form of the level: "default" or "strict". +func (l Level) String() string { + if l >= LevelStrict { + return LevelStrictString + } + return LevelDefaultString +} + +// protectedDomains are NetBird-operated suffixes that stay recognizable in an +// anonymized bundle. At LevelStrict the labels in front of them (the peer +// name) are still replaced, except under netbird.io, which only hosts +// NetBird infrastructure (api, signal, flow), never peer names. +var protectedDomains = []string{"netbird.io", "netbird.selfhosted", "netbird.cloud", "netbird.stage"} + +const infraDomain = "netbird.io" + +var ( + macColonRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b`) + macDashRegex = regexp.MustCompile(`\b[0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5}\b`) + wgKeyRegex = regexp.MustCompile(`\b[A-Za-z0-9+/]{43}=`) +) + type Anonymizer struct { ipAnonymizer map[netip.Addr]netip.Addr domainAnonymizer map[string]string - currentAnonIPv4 netip.Addr - currentAnonIPv6 netip.Addr - startAnonIPv4 netip.Addr - startAnonIPv6 netip.Addr + // domainOrder caches the keys of domainAnonymizer sorted longest-first + // for AnonymizeString; it is rebuilt when the map gains entries. + domainOrder []string + labelAnonymizer map[string]string + labelAnonymized map[string]struct{} + labelCounter uint32 + macAnonymizer map[string]string + macCounter uint32 + wgKeyAnonymizer map[string]string + wgKeyAnonymized map[string]struct{} + currentAnonIPv4 netip.Addr + currentAnonIPv6 netip.Addr + startAnonIPv4 netip.Addr + startAnonIPv6 netip.Addr + + // LevelStrict also anonymizes internal ranges (RFC 1918, CGNAT, + // link-local), replacing them from the dedicated internal pools below so + // a reader can still tell an internal address from a public one. + level Level + currentAnonInternalIPv4 netip.Addr + currentAnonInternalIPv6 netip.Addr + startAnonInternalIPv4 netip.Addr + startAnonInternalIPv6 netip.Addr domainKeyRegex *regexp.Regexp } @@ -32,25 +108,50 @@ func DefaultAddresses() (netip.Addr, netip.Addr) { return netip.AddrFrom4([4]byte{198, 51, 100, 0}), netip.MustParseAddr("2001:db8:ffff::") } +// InternalAddresses returns the pool starts used in strict mode for internal +// ranges. Both are reserved ranges that cannot collide with real addressing: +// 198.18.0.0 (RFC 2544 benchmarking), 2001:db8:1:: (RFC 3849 documentation). +func InternalAddresses() (netip.Addr, netip.Addr) { + return netip.AddrFrom4([4]byte{198, 18, 0, 0}), netip.MustParseAddr("2001:db8:1::") +} + func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer { + internalIPv4, internalIPv6 := InternalAddresses() return &Anonymizer{ ipAnonymizer: map[netip.Addr]netip.Addr{}, domainAnonymizer: map[string]string{}, + labelAnonymizer: map[string]string{}, + labelAnonymized: map[string]struct{}{}, + macAnonymizer: map[string]string{}, + wgKeyAnonymizer: map[string]string{}, + wgKeyAnonymized: map[string]struct{}{}, currentAnonIPv4: startIPv4, currentAnonIPv6: startIPv6, startAnonIPv4: startIPv4, startAnonIPv6: startIPv6, + level: LevelDefault, + currentAnonInternalIPv4: internalIPv4, + currentAnonInternalIPv6: internalIPv6, + startAnonInternalIPv4: internalIPv4, + startAnonInternalIPv6: internalIPv6, + domainKeyRegex: regexp.MustCompile(`\bdomain=([^\s,:"]+)`), } } +// SetLevel selects the anonymization level. The zero value of a new +// Anonymizer is LevelDefault. +func (a *Anonymizer) SetLevel(level Level) { + a.level = level +} + func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr { + // Normalize 4-in-6 addresses so ::ffff:192.168.1.1 classifies and maps + // like 192.168.1.1. + ip = ip.Unmap() + if ip.IsLoopback() || - ip.IsLinkLocalUnicast() || - ip.IsLinkLocalMulticast() || - ip.IsInterfaceLocalMulticast() || - (ip.Is4() && ip.IsPrivate()) || ip.IsUnspecified() || ip.IsMulticast() || isWellKnown(ip) || @@ -59,18 +160,100 @@ func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr { return ip } + if isInternal(ip) && a.level < LevelStrict { + return ip + } + if _, ok := a.ipAnonymizer[ip]; !ok { - if ip.Is4() { - a.ipAnonymizer[ip] = a.currentAnonIPv4 - a.currentAnonIPv4 = a.currentAnonIPv4.Next() - } else { - a.ipAnonymizer[ip] = a.currentAnonIPv6 - a.currentAnonIPv6 = a.currentAnonIPv6.Next() - } + a.ipAnonymizer[ip] = a.nextAnonIP(ip) } return a.ipAnonymizer[ip] } +func (a *Anonymizer) nextAnonIP(ip netip.Addr) netip.Addr { + // At the strict level, internal addresses (including IPv6 ULA, matched + // by IsPrivate) come from the internal pools so they remain recognizable + // as internal without disclosing the real values. + if a.level >= LevelStrict && (isInternal(ip) || ip.IsPrivate()) { + if ip.Is4() { + anon := a.currentAnonInternalIPv4 + a.currentAnonInternalIPv4 = a.currentAnonInternalIPv4.Next() + return anon + } + anon := a.currentAnonInternalIPv6 + a.currentAnonInternalIPv6 = a.currentAnonInternalIPv6.Next() + return anon + } + + if ip.Is4() { + anon := a.currentAnonIPv4 + a.currentAnonIPv4 = a.currentAnonIPv4.Next() + return anon + } + anon := a.currentAnonIPv6 + a.currentAnonIPv6 = a.currentAnonIPv6.Next() + return anon +} + +// AnonymizeMAC replaces a MAC address with a consistent placeholder from the +// locally administered range starting at 02:00:00:00:00:01, at every +// anonymization level. Broadcast, multicast, all-zero, and already assigned +// placeholder addresses are preserved. The colon and dash spellings of the +// same address share one placeholder; the output keeps the input's separator. +func (a *Anonymizer) AnonymizeMAC(mac string) string { + hw, err := net.ParseMAC(mac) + if err != nil || len(hw) != 6 { + return mac + } + + if isWellKnownMAC(hw) || a.isAnonymizedMAC(hw) { + return mac + } + + key := hw.String() + anon, ok := a.macAnonymizer[key] + if !ok { + a.macCounter++ + anon = fmt.Sprintf("02:00:00:%02x:%02x:%02x", byte(a.macCounter>>16), byte(a.macCounter>>8), byte(a.macCounter)) + a.macAnonymizer[key] = anon + } + + if strings.Contains(mac, "-") { + anon = strings.ReplaceAll(anon, ":", "-") + } + return anon +} + +// isAnonymizedMAC reports whether hw is a placeholder this anonymizer already +// handed out, so a second pass over anonymized output leaves it unchanged. +func (a *Anonymizer) isAnonymizedMAC(hw net.HardwareAddr) bool { + if hw[0] != 0x02 || hw[1] != 0 || hw[2] != 0 { + return false + } + value := uint32(hw[3])<<16 | uint32(hw[4])<<8 | uint32(hw[5]) + return value <= a.macCounter +} + +// AnonymizeWGKey replaces a WireGuard public key with a consistent random +// placeholder of the same shape. Keys are only anonymized at LevelStrict; +// placeholders already handed out pass through unchanged. +func (a *Anonymizer) AnonymizeWGKey(key string) string { + if a.level < LevelStrict || !looksLikeWGKey(key) { + return key + } + if _, ok := a.wgKeyAnonymized[key]; ok { + return key + } + + anon, ok := a.wgKeyAnonymizer[key] + if !ok { + anon = generateAnonymousKey() + a.wgKeyAnonymizer[key] = anon + a.wgKeyAnonymized[anon] = struct{}{} + } + return anon +} + func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr { // Convert IP to netip.Addr ip, ok := netip.AddrFromSlice(addr.IP) @@ -89,12 +272,12 @@ func (a *Anonymizer) AnonymizeUDPAddr(addr net.UDPAddr) net.UDPAddr { // isInAnonymizedRange checks if an IP is within the range of already assigned anonymized IPs func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool { - if ip.Is4() && ip.Compare(a.startAnonIPv4) >= 0 && ip.Compare(a.currentAnonIPv4) <= 0 { - return true - } else if !ip.Is4() && ip.Compare(a.startAnonIPv6) >= 0 && ip.Compare(a.currentAnonIPv6) <= 0 { - return true + if ip.Is4() { + return inPoolRange(ip, a.startAnonIPv4, a.currentAnonIPv4) || + inPoolRange(ip, a.startAnonInternalIPv4, a.currentAnonInternalIPv4) } - return false + return inPoolRange(ip, a.startAnonIPv6, a.currentAnonIPv6) || + inPoolRange(ip, a.startAnonInternalIPv6, a.currentAnonInternalIPv6) } func (a *Anonymizer) AnonymizeIPString(ip string) string { @@ -118,14 +301,23 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string { baseDomain = domain[:len(domain)-1] } - if strings.HasSuffix(baseDomain, "netbird.io") || - strings.HasSuffix(baseDomain, "netbird.selfhosted") || - strings.HasSuffix(baseDomain, "netbird.cloud") || - strings.HasSuffix(baseDomain, "netbird.stage") || - strings.HasSuffix(baseDomain, anonTLD) { + if strings.HasSuffix(baseDomain, anonTLD) { return domain } + // A reverse zone names an address prefix, so it follows the address rules, + // which also keeps its digit labels intact. + if zone, ok := a.anonymizeReverseZone(baseDomain); ok { + return withTrailingDot(zone, hasDot) + } + + if suffix := protectedSuffix(baseDomain); suffix != "" { + if a.level < LevelStrict || baseDomain == suffix || suffix == infraDomain { + return domain + } + return withTrailingDot(a.anonymizePeerName(baseDomain, suffix), hasDot) + } + parts := strings.Split(baseDomain, ".") if len(parts) < 2 { return domain @@ -141,12 +333,53 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string { } result := strings.Replace(baseDomain, baseForLookup, anonymized, 1) - if hasDot { - result += "." + if a.level >= LevelStrict && len(parts) > 2 { + prefix := strings.TrimSuffix(baseDomain, "."+baseForLookup) + result = a.anonymizeLabels(prefix, "host") + "." + anonymized + // The full mapping feeds AnonymizeString so seeded FQDNs are caught + // in log lines as a whole, labels included. + a.domainAnonymizer[baseDomain] = result + } + return withTrailingDot(result, hasDot) +} + +// anonymizePeerName replaces the labels in front of a protected suffix with +// numbered peer placeholders, keeping the suffix, and records the full +// mapping for string replacement in logs. The numbering keeps a peer +// recognizable across the whole bundle without disclosing its name. +func (a *Anonymizer) anonymizePeerName(baseDomain, suffix string) string { + prefix := strings.TrimSuffix(baseDomain, "."+suffix) + result := a.anonymizeLabels(prefix, "peer") + "." + suffix + if result != baseDomain { + a.domainAnonymizer[baseDomain] = result } return result } +// anonymizeLabels replaces each dot-separated label with a consistent +// numbered placeholder ("-"). Wildcard labels and +// placeholders already handed out pass through unchanged. +func (a *Anonymizer) anonymizeLabels(prefix, placeholder string) string { + labels := strings.Split(prefix, ".") + for i, label := range labels { + if label == "*" { + continue + } + if _, ok := a.labelAnonymized[label]; ok { + continue + } + anon, ok := a.labelAnonymizer[label] + if !ok { + a.labelCounter++ + anon = fmt.Sprintf("%s-%d", placeholder, a.labelCounter) + a.labelAnonymizer[label] = anon + a.labelAnonymized[anon] = struct{}{} + } + labels[i] = anon + } + return strings.Join(labels, ".") +} + func (a *Anonymizer) AnonymizeURI(uri string) string { u, err := url.Parse(uri) if err != nil { @@ -178,17 +411,75 @@ func (a *Anonymizer) AnonymizeString(str string) string { ipv4Regex := regexp.MustCompile(`\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b`) ipv6Regex := regexp.MustCompile(`\b([0-9a-fA-F:]+:+[0-9a-fA-F]{0,4})(?:%[0-9a-zA-Z]+)?(?:\/[0-9]{1,3})?(?::[0-9]{1,5})?\b`) + // Reverse zones go first and are then held out of the passes below: their + // labels are digits, which the address patterns would otherwise consume. + str, restoreZones := a.replaceReverseZones(str) + str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString) str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString) - for domain, anonDomain := range a.domainAnonymizer { - str = strings.ReplaceAll(str, domain, anonDomain) + for _, domain := range a.sortedDomains() { + str = strings.ReplaceAll(str, domain, a.domainAnonymizer[domain]) } str = a.AnonymizeSchemeURI(str) str = a.AnonymizeDNSLogLine(str) - return str + // MAC handling runs after the IP passes so preserved IPv6 addresses are + // already out of the way; the separator guard skips matches embedded in a + // longer colon- or dash-separated sequence (such as an IPv6 tail). + str = a.anonymizeMACsInString(str, macColonRegex, ':') + str = a.anonymizeMACsInString(str, macDashRegex, '-') + + if a.level >= LevelStrict { + str = wgKeyRegex.ReplaceAllStringFunc(str, a.AnonymizeWGKey) + } + + return restoreZones(str) +} + +// sortedDomains returns the domain mappings longest-first, so a full-FQDN +// mapping (strict level) is applied before the base-domain mapping it +// contains. The order is rebuilt only when domainAnonymizer has grown. +func (a *Anonymizer) sortedDomains() []string { + if len(a.domainOrder) == len(a.domainAnonymizer) { + return a.domainOrder + } + + a.domainOrder = a.domainOrder[:0] + for domain := range a.domainAnonymizer { + a.domainOrder = append(a.domainOrder, domain) + } + slices.SortFunc(a.domainOrder, func(x, y string) int { + if d := len(y) - len(x); d != 0 { + return d + } + return strings.Compare(x, y) + }) + return a.domainOrder +} + +// anonymizeMACsInString replaces MAC addresses matched by re, skipping +// matches that directly adjoin another sep so a six-group run inside a longer +// separated sequence is left alone. +func (a *Anonymizer) anonymizeMACsInString(str string, re *regexp.Regexp, sep byte) string { + matches := re.FindAllStringIndex(str, -1) + if len(matches) == 0 { + return str + } + + var b strings.Builder + last := 0 + for _, m := range matches { + if (m[0] > 0 && str[m[0]-1] == sep) || (m[1] < len(str) && str[m[1]] == sep) { + continue + } + b.WriteString(str[last:m[0]]) + b.WriteString(a.AnonymizeMAC(str[m[0]:m[1]])) + last = m[1] + } + b.WriteString(str[last:]) + return b.String() } // AnonymizeSchemeURI finds and anonymizes URIs with ws, wss, rel, rels, stun, stuns, turn, and turns schemes. @@ -239,10 +530,79 @@ func isWellKnown(addr netip.Addr) bool { "128.0.0.0", "8000::", // 2nd split subnet for default routes } - if slices.Contains(wellKnown, addr.String()) { + return slices.Contains(wellKnown, addr.String()) +} + +// isInternal reports whether ip identifies a host only within the local +// network: IPv4 private (RFC 1918), CGNAT (RFC 6598), and link-local (v4 and +// v6). These are preserved at the default level so support can reason about +// the real topology, and replaced from the internal pools at the strict +// level. IPv6 ULA is deliberately not internal: its random global ID uniquely +// fingerprints the network, so it is anonymized at every level. +func isInternal(ip netip.Addr) bool { + return (ip.Is4() && ip.IsPrivate()) || + ip.IsLinkLocalUnicast() || + isCGNAT(ip) +} + +func inPoolRange(ip, start, current netip.Addr) bool { + return ip.Compare(start) >= 0 && ip.Compare(current) <= 0 +} + +// isWellKnownMAC reports whether hw carries no stable host identity: all-zero +// or a group address (broadcast and multicast). +func isWellKnownMAC(hw net.HardwareAddr) bool { + if hw[0]&1 == 1 { return true } + for _, b := range hw { + if b != 0 { + return false + } + } + return true +} +// looksLikeWGKey reports whether s has the shape of a WireGuard key: +// 44 base64 characters decoding to 32 bytes. +func looksLikeWGKey(s string) bool { + if len(s) != 44 || s[43] != '=' { + return false + } + decoded, err := base64.StdEncoding.DecodeString(s) + return err == nil && len(decoded) == 32 +} + +func generateAnonymousKey() string { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return strings.Repeat("A", 43) + "=" + } + return base64.StdEncoding.EncodeToString(buf) +} + +// protectedSuffix returns the protected NetBird suffix baseDomain ends with, +// or empty. The match is label-anchored so an unrelated domain that merely +// ends in the same characters is not preserved. +func protectedSuffix(baseDomain string) string { + for _, d := range protectedDomains { + if baseDomain == d || strings.HasSuffix(baseDomain, "."+d) { + return d + } + } + return "" +} + +func withTrailingDot(domain string, hasDot bool) string { + if hasDot { + return domain + "." + } + return domain +} + +// isCGNAT reports whether addr is in 100.64.0.0/10 (RFC 6598), the range +// NetBird assigns overlay peer addresses from. +func isCGNAT(addr netip.Addr) bool { cgnatRangeStart := netip.AddrFrom4([4]byte{100, 64, 0, 0}) cgnatRange := netip.PrefixFrom(cgnatRangeStart, 10) diff --git a/client/anonymize/anonymize_test.go b/client/anonymize/anonymize_test.go index 852315fa1..7c3c7bcf8 100644 --- a/client/anonymize/anonymize_test.go +++ b/client/anonymize/anonymize_test.go @@ -1,8 +1,11 @@ package anonymize_test import ( + "bytes" + "encoding/base64" "net/netip" "regexp" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -44,6 +47,301 @@ func TestAnonymizeIP(t *testing.T) { } } +func TestParseLevel(t *testing.T) { + tests := []struct { + input string + expect anonymize.Level + }{ + {"", anonymize.LevelDefault}, + {"default", anonymize.LevelDefault}, + {"DEFAULT", anonymize.LevelDefault}, + {"strict", anonymize.LevelStrict}, + {"STRICT", anonymize.LevelStrict}, + // Unknown values must never yield less anonymization than requested. + {"garbage", anonymize.LevelStrict}, + } + + for _, tc := range tests { + t.Run("input="+tc.input, func(t *testing.T) { + assert.Equal(t, tc.expect, anonymize.ParseLevel(tc.input), "parsed level should match") + }) + } +} + +func TestAnonymizeIP_DefaultLevelInternalRanges(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + + tests := []struct { + name string + ip string + expect string + }{ + {"RFC1918 10/8", "10.1.2.3", "10.1.2.3"}, + {"RFC1918 172.16/12", "172.16.5.5", "172.16.5.5"}, + {"RFC1918 192.168/16", "192.168.1.1", "192.168.1.1"}, + {"CGNAT", "100.64.0.5", "100.64.0.5"}, + {"IPv4 link-local", "169.254.1.1", "169.254.1.1"}, + {"IPv6 link-local", "fe80::1", "fe80::1"}, + // ULA is anonymized even at the default level: its random global ID + // uniquely fingerprints the network, unlike shared RFC 1918 space. + {"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:ffff::"}, + // 4-in-6 addresses classify like their unmapped IPv4 form. + {"4-in-6 RFC1918", "::ffff:192.168.1.1", "192.168.1.1"}, + {"4-in-6 CGNAT", "::ffff:100.64.0.5", "100.64.0.5"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip)) + assert.Equal(t, tc.expect, result.String(), "default level should preserve internal ranges except ULA") + }) + } +} + +func TestAnonymizeIP_StrictLevel(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(anonymize.LevelStrict) + + // Order matters: internal pool addresses are assigned sequentially. + tests := []struct { + name string + ip string + expect string + }{ + {"RFC1918 192.168/16", "192.168.1.1", "198.18.0.0"}, + {"Second RFC1918", "192.168.1.2", "198.18.0.1"}, + {"Repeated RFC1918", "192.168.1.1", "198.18.0.0"}, + {"RFC1918 10/8", "10.1.2.3", "198.18.0.2"}, + {"RFC1918 172.16/12", "172.16.5.5", "198.18.0.3"}, + {"CGNAT", "100.64.0.5", "198.18.0.4"}, + {"IPv4 link-local", "169.254.1.1", "198.18.0.5"}, + {"Public IPv4 uses public pool", "1.2.3.4", "198.51.100.0"}, + {"IPv6 link-local", "fe80::1", "2001:db8:1::"}, + {"IPv6 ULA", "fd12:3456:789a::1", "2001:db8:1::1"}, + {"Public IPv6 uses public pool", "2607:f8b0:4005:805::200e", "2001:db8:ffff::"}, + {"Loopback IPv4", "127.0.0.1", "127.0.0.1"}, + {"Loopback IPv6", "::1", "::1"}, + {"Unspecified", "0.0.0.0", "0.0.0.0"}, + {"Multicast", "224.0.0.251", "224.0.0.251"}, + {"Well known resolver", "8.8.8.8", "8.8.8.8"}, + {"Well known split marker", "128.0.0.0", "128.0.0.0"}, + {"In internal pool range", "198.18.0.3", "198.18.0.3"}, + {"In public pool range", "198.51.100.0", "198.51.100.0"}, + {"4-in-6 repeated RFC1918", "::ffff:192.168.1.1", "198.18.0.0"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := anonymizer.AnonymizeIP(netip.MustParseAddr(tc.ip)) + assert.Equal(t, tc.expect, result.String(), "strict level should replace internal ranges from the internal pools") + }) + } +} + +func TestAnonymizeString_StrictInternalIPs(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(anonymize.LevelStrict) + + input := "route 10.20.30.0/24 via 192.168.1.1 dev eth0 src 100.64.0.7" + firstPass := anonymizer.AnonymizeString(input) + secondPass := anonymizer.AnonymizeString(firstPass) + + assert.NotContains(t, firstPass, "10.20.30.0", "private network address should be anonymized") + assert.NotContains(t, firstPass, "192.168.1.1", "private gateway should be anonymized") + assert.NotContains(t, firstPass, "100.64.0.7", "CGNAT address should be anonymized") + assert.Contains(t, firstPass, "/24", "prefix length should be preserved") + assert.Equal(t, firstPass, secondPass, "second pass should not further anonymize the string") +} + +func TestAnonymizeMAC(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + + first := anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f") + assert.Equal(t, "02:00:00:00:00:01", first, "first MAC should get the first placeholder") + assert.Equal(t, first, anonymizer.AnonymizeMAC("aa:bb:cc:dd:ee:0f"), "repeated MAC should map to the same placeholder") + assert.Equal(t, first, anonymizer.AnonymizeMAC("AA:BB:CC:DD:EE:0F"), "case should not affect the mapping") + assert.Equal(t, "02-00-00-00-00-01", anonymizer.AnonymizeMAC("AA-BB-CC-DD-EE-0F"), "dash form should keep its separator but share the mapping") + + second := anonymizer.AnonymizeMAC("10:22:33:44:55:66") + assert.Equal(t, "02:00:00:00:00:02", second, "second distinct MAC should get the next placeholder") + + tests := []struct { + name string + mac string + }{ + {"Broadcast", "ff:ff:ff:ff:ff:ff"}, + {"IPv4 multicast", "01:00:5e:00:00:fb"}, + {"IPv6 multicast", "33:33:00:00:00:01"}, + {"All zero", "00:00:00:00:00:00"}, + {"Assigned placeholder", "02:00:00:00:00:01"}, + {"Invalid", "not-a-mac"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.mac, anonymizer.AnonymizeMAC(tc.mac), "should be preserved") + }) + } +} + +func TestAnonymizeString_MACAddresses(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + + tests := []struct { + name string + input string + expect string + }{ + { + name: "nftables ether rule", + input: "ether saddr aa:bb:cc:dd:ee:ff drop", + expect: "ether saddr 02:00:00:00:00:01 drop", + }, + { + name: "Windows dash form", + input: "Physical Address : AA-BB-CC-DD-EE-FF", + expect: "Physical Address : 02-00-00-00-00-01", + }, + { + name: "IPv6 address tail is not treated as MAC", + input: "addr fe80:0:11:22:33:44:55:66 scope link", + expect: "addr fe80:0:11:22:33:44:55:66 scope link", + }, + { + name: "broadcast MAC preserved", + input: "dst ff:ff:ff:ff:ff:ff type ARP", + expect: "dst ff:ff:ff:ff:ff:ff type ARP", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := anonymizer.AnonymizeString(tc.input) + assert.Equal(t, tc.expect, result, "MAC addresses should be anonymized at every level") + assert.Equal(t, result, anonymizer.AnonymizeString(result), "second pass should not change the result") + }) + } +} + +func TestAnonymizeWGKey(t *testing.T) { + key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32)) + + t.Run("default level preserves keys", func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + assert.Equal(t, key, anonymizer.AnonymizeWGKey(key), "default level should not touch WireGuard keys") + }) + + t.Run("strict level replaces keys", func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(anonymize.LevelStrict) + + anon := anonymizer.AnonymizeWGKey(key) + assert.NotEqual(t, key, anon, "strict level should replace the key") + assert.Regexp(t, `^[A-Za-z0-9+/]{43}=$`, anon, "placeholder should keep the WireGuard key shape") + assert.Equal(t, anon, anonymizer.AnonymizeWGKey(key), "repeated key should map to the same placeholder") + assert.Equal(t, anon, anonymizer.AnonymizeWGKey(anon), "an assigned placeholder should pass through unchanged") + + assert.Equal(t, "not-a-key", anonymizer.AnonymizeWGKey("not-a-key"), "non-key values should be preserved") + }) +} + +func TestAnonymizeString_WGKeys(t *testing.T) { + key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x42}, 32)) + input := "peer " + key + " handshake completed" + + t.Run("default level preserves keys", func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + assert.Equal(t, input, anonymizer.AnonymizeString(input), "default level should not touch WireGuard keys in strings") + }) + + t.Run("strict level replaces keys", func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(anonymize.LevelStrict) + + firstPass := anonymizer.AnonymizeString(input) + assert.NotContains(t, firstPass, key, "the key should not survive strict anonymization") + assert.Equal(t, anonymizer.AnonymizeWGKey(key), extractKey(t, firstPass), "string replacement should be consistent with AnonymizeWGKey") + assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result") + }) +} + +func extractKey(t *testing.T, logLine string) string { + t.Helper() + fields := strings.Fields(logLine) + require.Len(t, fields, 4, "log line should keep its structure") + return fields[1] +} + +func TestAnonymizeDomain_StrictLevel(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(anonymize.LevelStrict) + + t.Run("netbird peer name", func(t *testing.T) { + result := anonymizer.AnonymizeDomain("my-laptop.netbird.cloud") + assert.Regexp(t, `^peer-\d+\.netbird\.cloud$`, result, "peer name should be anonymized, suffix kept") + assert.NotContains(t, result, "my-laptop", "the peer name should not survive") + assert.Equal(t, result, anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"), "repeated domain should map consistently") + assert.Equal(t, result, anonymizer.AnonymizeDomain(result), "an anonymized domain should pass through unchanged") + }) + + t.Run("bare netbird domain", func(t *testing.T) { + assert.Equal(t, "netbird.cloud", anonymizer.AnonymizeDomain("netbird.cloud"), "the bare protected suffix should be preserved") + }) + + t.Run("netbird infrastructure preserved", func(t *testing.T) { + assert.Equal(t, "api.netbird.io", anonymizer.AnonymizeDomain("api.netbird.io"), + "netbird.io hosts infrastructure, not peer names, and should stay readable") + }) + + t.Run("leading labels of other domains", func(t *testing.T) { + result := anonymizer.AnonymizeDomain("host1.corp.example.com") + assert.Regexp(t, `^host-\d+\.host-\d+\.anon-[a-zA-Z0-9]+\.domain$`, result, "every label should be anonymized") + for _, label := range []string{"host1", "corp", "example"} { + assert.NotContains(t, result, label, "no original label should survive") + } + assert.Equal(t, result, anonymizer.AnonymizeDomain("host1.corp.example.com"), "repeated domain should map consistently") + }) + + t.Run("same label maps consistently across domains", func(t *testing.T) { + first := anonymizer.AnonymizeDomain("shared.one.com") + second := anonymizer.AnonymizeDomain("shared.two.com") + assert.Equal(t, strings.Split(first, ".")[0], strings.Split(second, ".")[0], "the shared host label should get one placeholder") + }) + + t.Run("wildcard label preserved", func(t *testing.T) { + result := anonymizer.AnonymizeDomain("*.example.com") + assert.Regexp(t, `^\*\.anon-[a-zA-Z0-9]+\.domain$`, result, "the wildcard label should stay a wildcard") + }) +} + +func TestAnonymizeDomain_DefaultLevelKeepsPeerNames(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + + assert.Equal(t, "my-laptop.netbird.cloud", anonymizer.AnonymizeDomain("my-laptop.netbird.cloud"), + "default level should preserve netbird FQDNs including the peer name") + assert.Regexp(t, `^sub\.anon-[a-zA-Z0-9]+\.domain$`, anonymizer.AnonymizeDomain("sub.example.com"), + "default level should keep subdomain labels") +} + +func TestAnonymizeString_StrictPeerNames(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(anonymize.LevelStrict) + + // Seed like the bundle generator does from the status: base first, then + // the full FQDN, so replacement must prefer the longer mapping. + anonBase := anonymizer.AnonymizeDomain("example.com") + anonPeer := anonymizer.AnonymizeDomain("peer1.netbird.cloud") + anonHost := anonymizer.AnonymizeDomain("host1.example.com") + + logLine := "connected to peer1.netbird.cloud via host1.example.com endpoint" + firstPass := anonymizer.AnonymizeString(logLine) + assert.NotContains(t, firstPass, "peer1", "the peer name should not survive in logs") + assert.NotContains(t, firstPass, "host1", "the host label should not survive in logs") + assert.Contains(t, firstPass, anonPeer, "the seeded peer mapping should be applied") + assert.Contains(t, firstPass, anonHost, "the seeded host mapping should be applied, not just the base mapping") + assert.NotContains(t, firstPass, "host1."+anonBase, "the base mapping must not preempt the longer FQDN mapping") + assert.Equal(t, firstPass, anonymizer.AnonymizeString(firstPass), "second pass should not change the result") +} + func TestAnonymizeDNSLogLine(t *testing.T) { anonymizer := anonymize.NewAnonymizer(netip.Addr{}, netip.Addr{}) tests := []struct { diff --git a/client/anonymize/reverse_zone.go b/client/anonymize/reverse_zone.go new file mode 100644 index 000000000..b521b71b7 --- /dev/null +++ b/client/anonymize/reverse_zone.go @@ -0,0 +1,174 @@ +package anonymize + +import ( + "encoding/hex" + "net/netip" + "regexp" + "strconv" + "strings" +) + +const ( + reverseZoneSuffixV4 = ".in-addr.arpa" + reverseZoneSuffixV6 = ".ip6.arpa" + + v6Nibbles = 32 + v4Octets = 4 +) + +// reverseZoneRegexes match a reverse zone or a full reverse name in free text. +// They are applied before the address passes of AnonymizeString, whose IPv4 +// pattern would otherwise consume the digit labels of a zone and replace parts +// of it with unrelated addresses. +var reverseZoneRegexes = []*regexp.Regexp{ + regexp.MustCompile(`(?:[0-9]{1,3}\.){1,4}in-addr\.arpa\b`), + regexp.MustCompile(`(?:[0-9a-fA-F]\.){1,32}ip6\.arpa\b`), +} + +// anonymizeReverseZone maps a reverse zone to the zone of the anonymized form +// of the prefix it encodes, so it follows the address rules rather than the +// domain ones: the zone of an address that is preserved is preserved too, and +// the zone of one that is replaced names the replacement. This keeps a reverse +// zone recognizable as such, and consistent with the addresses it belongs to +// elsewhere in the same output. It reports false for anything that is not a +// reverse zone. +func (a *Anonymizer) anonymizeReverseZone(domain string) (string, bool) { + prefix, labelCount, suffix, ok := parseReverseZone(domain) + if !ok { + return "", false + } + + anonymized := a.AnonymizeIP(prefix) + if anonymized == prefix { + return domain, true + } + + return reverseZoneName(anonymized, labelCount) + suffix, true +} + +// replaceReverseZones anonymizes every reverse zone in str and swaps each one +// for a placeholder, returning a function that puts the anonymized zones back. +// The placeholders carry no dots, digits or colons, so no later pass matches +// them. +func (a *Anonymizer) replaceReverseZones(str string) (string, func(string) string) { + var zones []string + + for _, re := range reverseZoneRegexes { + str = re.ReplaceAllStringFunc(str, func(match string) string { + zone, ok := a.anonymizeReverseZone(match) + if !ok { + return match + } + + zones = append(zones, zone) + return reverseZonePlaceholder(len(zones) - 1) + }) + } + + if len(zones) == 0 { + return str, func(s string) string { return s } + } + + return str, func(s string) string { + for i, zone := range zones { + s = strings.ReplaceAll(s, reverseZonePlaceholder(i), zone) + } + return s + } +} + +func reverseZonePlaceholder(index int) string { + return "\x00reversezone" + strconv.Itoa(index) + "\x00" +} + +// parseReverseZone turns a reverse zone into the address of the prefix its +// labels spell backwards, padding the absent low-order part with zeroes, and +// returns the label count and zone suffix so the name can be rebuilt. +func parseReverseZone(domain string) (netip.Addr, int, string, bool) { + lower := strings.ToLower(domain) + + switch { + case strings.HasSuffix(lower, reverseZoneSuffixV4): + labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV4), ".") + addr, ok := reverseZoneAddrV4(labels) + return addr, len(labels), reverseZoneSuffixV4, ok + case strings.HasSuffix(lower, reverseZoneSuffixV6): + labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV6), ".") + addr, ok := reverseZoneAddrV6(labels) + return addr, len(labels), reverseZoneSuffixV6, ok + default: + return netip.Addr{}, 0, "", false + } +} + +func reverseZoneAddrV4(labels []string) (netip.Addr, bool) { + if len(labels) == 0 || len(labels) > v4Octets { + return netip.Addr{}, false + } + + var octets [v4Octets]byte + for i, label := range labels { + octet, err := strconv.ParseUint(label, 10, 8) + if err != nil { + return netip.Addr{}, false + } + octets[len(labels)-1-i] = byte(octet) + } + + return netip.AddrFrom4(octets), true +} + +func reverseZoneAddrV6(labels []string) (netip.Addr, bool) { + if len(labels) == 0 || len(labels) > v6Nibbles { + return netip.Addr{}, false + } + + nibbles := make([]byte, 0, v6Nibbles) + for i := len(labels) - 1; i >= 0; i-- { + if len(labels[i]) != 1 || !isHexDigit(labels[i][0]) { + return netip.Addr{}, false + } + nibbles = append(nibbles, labels[i][0]) + } + for len(nibbles) < v6Nibbles { + nibbles = append(nibbles, '0') + } + + var groups []string + for i := 0; i < len(nibbles); i += 4 { + groups = append(groups, string(nibbles[i:i+4])) + } + + addr, err := netip.ParseAddr(strings.Join(groups, ":")) + if err != nil { + return netip.Addr{}, false + } + + return addr, true +} + +// reverseZoneName spells the first labelCount labels of addr backwards, the +// inverse of parseReverseZone, without the zone suffix. +func reverseZoneName(addr netip.Addr, labelCount int) string { + labels := make([]string, 0, labelCount) + + if addr.Is4() { + octets := addr.As4() + for i := labelCount - 1; i >= 0; i-- { + labels = append(labels, strconv.Itoa(int(octets[i]))) + } + return strings.Join(labels, ".") + } + + address := addr.As16() + nibbles := hex.EncodeToString(address[:]) + for i := labelCount - 1; i >= 0; i-- { + labels = append(labels, string(nibbles[i])) + } + + return strings.Join(labels, ".") +} + +func isHexDigit(c byte) bool { + return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' +} diff --git a/client/anonymize/reverse_zone_test.go b/client/anonymize/reverse_zone_test.go new file mode 100644 index 000000000..8c3b8954a --- /dev/null +++ b/client/anonymize/reverse_zone_test.go @@ -0,0 +1,171 @@ +package anonymize + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newLeveledAnonymizer(level Level) *Anonymizer { + a := NewAnonymizer(DefaultAddresses()) + a.SetLevel(level) + return a +} + +// TestAnonymizeDomainReverseZone covers reverse zones going through the address +// rules instead of the domain ones, so a zone stays a zone and an address that +// is preserved keeps the zone that names it. +func TestAnonymizeDomainReverseZone(t *testing.T) { + // 100.64.0.0/10 is the overlay range, which is CGNAT: preserved at the + // default level and replaced from the internal pool at the strict one + const overlayZone = "64.100.in-addr.arpa" + + t.Run("overlay zone preserved at the default level", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + assert.Equal(t, overlayZone, a.AnonymizeDomain(overlayZone), "should keep the zone of a preserved address") + }) + + t.Run("private zone preserved at the default level", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + assert.Equal(t, "168.192.in-addr.arpa", a.AnonymizeDomain("168.192.in-addr.arpa"), "should keep the zone of a private address") + }) + + t.Run("overlay zone replaced at the strict level", func(t *testing.T) { + a := newLeveledAnonymizer(LevelStrict) + + got := a.AnonymizeDomain(overlayZone) + require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got) + assert.NotEqual(t, overlayZone, got, "should replace the encoded prefix") + assert.Len(t, strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV4), "."), 2, + "should keep the label count, got %q", got) + }) + + t.Run("public zone replaced at the default level", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + got := a.AnonymizeDomain("113.0.203.in-addr.arpa") + require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got) + assert.NotEqual(t, "113.0.203.in-addr.arpa", got, "should replace a public prefix") + }) + + t.Run("zone of an address keeps that address mapping", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + anonymizedAddr := a.AnonymizeIPString("203.0.113.7") + got := a.AnonymizeDomain("7.113.0.203.in-addr.arpa") + + octets := strings.Split(anonymizedAddr, ".") + want := octets[3] + "." + octets[2] + "." + octets[1] + "." + octets[0] + reverseZoneSuffixV4 + assert.Equal(t, want, got, "should name the same replacement as the address itself") + }) + + t.Run("ipv6 nibble labels stay single digits", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6 + got := a.AnonymizeDomain(zone) + + require.True(t, strings.HasSuffix(got, reverseZoneSuffixV6), "should stay a reverse zone, got %q", got) + labels := strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV6), ".") + assert.Len(t, labels, 28, "should keep every nibble label, got %q", got) + for _, label := range labels { + assert.Len(t, label, 1, "nibble label %q should stay a single digit", label) + } + }) + + t.Run("trailing dot is kept", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + assert.Equal(t, "64.100.in-addr.arpa.", a.AnonymizeDomain("64.100.in-addr.arpa."), "should keep the trailing dot") + }) + + t.Run("a domain that only looks like a zone is anonymized as a domain", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + got := a.AnonymizeDomain("not-a-zone.in-addr.arpa") + assert.NotContains(t, got, "in-addr.arpa", "should fall back to domain anonymization") + }) +} + +// TestAnonymizeStringReverseZone verifies that a zone inside free text, such as +// a DNS log line, is not chewed up by the address passes. The IPv4 pattern +// matches any run of dotted digits, which a reverse zone is made of. +func TestAnonymizeStringReverseZone(t *testing.T) { + t.Run("ipv6 zone survives the address passes", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6 + got := a.AnonymizeString("question: domain=" + zone + " type=PTR") + + assert.Contains(t, got, "type=PTR", "should keep the rest of the line") + assert.NotContains(t, got, "198.51.100", "should not rewrite nibble labels as an address") + + labels := strings.Split(strings.TrimSuffix(strings.TrimPrefix(got, "question: domain="), reverseZoneSuffixV6+" type=PTR"), ".") + assert.Len(t, labels, 28, "should keep every nibble label, got %q", got) + }) + + t.Run("preserved ipv4 zone is untouched", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + line := "reverse zone 64.100.in-addr.arpa registered" + assert.Equal(t, line, a.AnonymizeString(line), "should keep the zone of a preserved address") + }) + + t.Run("public ipv4 zone is replaced consistently", func(t *testing.T) { + a := newLeveledAnonymizer(LevelDefault) + + got := a.AnonymizeString("zone 113.0.203.in-addr.arpa and address 203.0.113.7") + assert.NotContains(t, got, "113.0.203.in-addr.arpa", "should replace the zone") + assert.NotContains(t, got, "203.0.113.7", "should replace the address") + assert.Contains(t, got, reverseZoneSuffixV4, "should keep the zone suffix") + }) +} + +func TestParseReverseZone(t *testing.T) { + tests := []struct { + name string + zone string + addr string + labels int + }{ + {name: "v4 two labels", zone: "0.100" + reverseZoneSuffixV4, addr: "100.0.0.0", labels: 2}, + {name: "v4 three labels", zone: "1.168.192" + reverseZoneSuffixV4, addr: "192.168.1.0", labels: 3}, + {name: "v4 full address", zone: "7.113.0.203" + reverseZoneSuffixV4, addr: "203.0.113.7", labels: 4}, + { + name: "v6 prefix", + zone: "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6, + addr: "2::", + labels: 28, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + addr, labels, suffix, ok := parseReverseZone(tc.zone) + require.True(t, ok, "should decode the reverse zone") + assert.Equal(t, tc.addr, addr.String(), "should decode to the encoded prefix") + assert.Equal(t, tc.labels, labels, "should count the labels") + assert.Equal(t, tc.zone, reverseZoneName(addr, labels)+suffix, "should re-encode to the original zone") + }) + } +} + +func TestParseReverseZoneRejectsNonZones(t *testing.T) { + tests := []string{ + "example.com", + "in-addr.arpa", + "x.100" + reverseZoneSuffixV4, + "256" + reverseZoneSuffixV4, + "1.2.3.4.5" + reverseZoneSuffixV4, + "ab" + reverseZoneSuffixV6, + "g" + reverseZoneSuffixV6, + } + + for _, zone := range tests { + t.Run(zone, func(t *testing.T) { + _, _, _, ok := parseReverseZone(zone) + assert.False(t, ok, "should reject %q", zone) + }) + } +} diff --git a/client/cmd/daemon_error.go b/client/cmd/daemon_error.go new file mode 100644 index 000000000..0d5b1307e --- /dev/null +++ b/client/cmd/daemon_error.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// daemonCallError prepares a daemon error for display. A refusal the daemon +// raised because the operation needs root/administrator is already guidance +// written for the user, so it is surfaced on its own instead of buried under the +// gRPC envelope and the name of the RPC that hit it. Anything else is wrapped +// with context as usual. +func daemonCallError(context string, err error) error { + if guidance, ok := privilegeGuidance(err); ok { + return errors.New(guidance) + } + return fmt.Errorf("%s: %w", context, err) +} + +// privilegeGuidance renders the daemon's privilege refusal as a summary and the +// command that performs the operation with the privileges it needs. It reports +// false for any other error. +func privilegeGuidance(err error) (string, bool) { + info, ok := privilegeErrorInfo(err) + if !ok { + return "", false + } + + summary := info.GetMetadata()[ipcauth.ErrorMetaSummary] + command := info.GetMetadata()[ipcauth.ErrorMetaCommand] + if summary == "" { + // Detail without a summary: fall back to the status message, which + // carries the same text. + summary = strings.TrimSpace(gstatus.Convert(err).Message()) + } + if command == "" { + return summary, true + } + + return fmt.Sprintf("%s\n\n %s\n", summary, command), true +} + +// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error +// carries one. +func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) { + if err == nil { + return nil, false + } + + for _, detail := range gstatus.Convert(err).Details() { + info, ok := detail.(*errdetails.ErrorInfo) + if !ok { + continue + } + if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain { + return info, true + } + } + return nil, false +} diff --git a/client/cmd/debug.go b/client/cmd/debug.go index bc7b0e98c..893b1e248 100644 --- a/client/cmd/debug.go +++ b/client/cmd/debug.go @@ -27,10 +27,11 @@ import ( const errCloseConnection = "Failed to close connection: %v" var ( - logFileCount uint32 - systemInfoFlag bool - uploadBundleFlag bool - uploadBundleURLFlag string + logFileCount uint32 + systemInfoFlag bool + uploadBundleFlag bool + uploadBundleURLFlag string + uploadBundleInsecureFlag bool ) var debugCmd = &cobra.Command{ @@ -130,7 +131,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 { @@ -155,6 +156,11 @@ func debugConfigDump(cmd *cobra.Command, _ []string) error { // request. Returns an error if the RPC fails or if the daemon reports // an upload failure reason. func debugBundle(cmd *cobra.Command, _ []string) error { + anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize() + if err != nil { + return err + } + conn, err := getClient(cmd) if err != nil { return err @@ -167,17 +173,19 @@ func debugBundle(cmd *cobra.Command, _ []string) error { client := proto.NewDaemonServiceClient(conn) request := &proto.DebugBundleRequest{ - Anonymize: anonymizeFlag, - SystemInfo: systemInfoFlag, - LogFileCount: logFileCount, - CliVersion: version.NetbirdVersion(), + Anonymize: anonymizeEnabled, + AnonymizeLevel: anonymizeLevel.String(), + SystemInfo: systemInfoFlag, + LogFileCount: logFileCount, + CliVersion: version.NetbirdVersion(), } if uploadBundleFlag { request.UploadURL = uploadBundleURLFlag + request.UploadInsecure = uploadBundleInsecureFlag } resp, err := client.DebugBundle(cmd.Context(), request) if err != nil { - return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message()) + return daemonCallError("bundle debug", err) } cmd.Printf("Local file:\n%s\n", resp.GetPath()) @@ -227,6 +235,11 @@ func runForDuration(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid duration format: %v", err) } + anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize() + if err != nil { + return err + } + conn, err := getClient(cmd) if err != nil { return err @@ -366,17 +379,19 @@ func runForDuration(cmd *cobra.Command, args []string) error { cmd.Println("Creating debug bundle...") request := &proto.DebugBundleRequest{ - Anonymize: anonymizeFlag, - SystemInfo: systemInfoFlag, - LogFileCount: logFileCount, - CliVersion: version.NetbirdVersion(), + Anonymize: anonymizeEnabled, + AnonymizeLevel: anonymizeLevel.String(), + SystemInfo: systemInfoFlag, + LogFileCount: logFileCount, + CliVersion: version.NetbirdVersion(), } if uploadBundleFlag { request.UploadURL = uploadBundleURLFlag + request.UploadInsecure = uploadBundleInsecureFlag } resp, err := client.DebugBundle(cmd.Context(), request) if err != nil { - return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message()) + return daemonCallError("bundle debug", err) } if needsRestoreUp { @@ -524,10 +539,12 @@ func init() { debugBundleCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle") debugBundleCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server") debugBundleCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle") + debugBundleCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root") forCmd.Flags().Uint32VarP(&logFileCount, "log-file-count", "C", 1, "Number of rotated log files to include in debug bundle") forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle") forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server") forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle") + forCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root") forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle") } diff --git a/client/cmd/login.go b/client/cmd/login.go index a7ee960b1..6aa019896 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "os/user" - "runtime" "strings" log "github.com/sirupsen/logrus" @@ -17,16 +16,26 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/profilemanager" + nbnet "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/util" ) +// extendSessionFlag drives the `netbird login --extend` flow: refresh the +// SSO session expiry on the management server without tearing down the +// tunnel. Mutually exclusive with setup-key login (a setup-key cannot +// refresh an SSO-tracked peer — see auth.errSetupKeyOnSSOExpiredPeer). +var extendSessionFlag bool + func init() { loginCmd.PersistentFlags().BoolVar(&noBrowser, noBrowserFlag, false, noBrowserDesc) loginCmd.PersistentFlags().BoolVar(&showQR, showQRFlag, false, showQRDesc) loginCmd.PersistentFlags().StringVar(&profileName, profileNameFlag, "", profileNameDesc) loginCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "(DEPRECATED) Netbird config file location") + loginCmd.PersistentFlags().BoolVar(&extendSessionFlag, "extend", false, + "refresh the SSO session expiry without tearing down the tunnel (requires an active connection)") } var loginCmd = &cobra.Command{ @@ -61,6 +70,16 @@ var loginCmd = &cobra.Command{ return err } + if extendSessionFlag { + if providedSetupKey != "" { + return fmt.Errorf("--extend cannot be combined with a setup key; setup keys can only enrol new peers") + } + if err := doExtendSession(ctx, cmd); err != nil { + return fmt.Errorf("extend session failed: %v", err) + } + return nil + } + // workaround to run without service if util.FindFirstLogPath(logFiles) == "" { if err := doForegroundLogin(ctx, cmd, providedSetupKey, activeProf); err != nil { @@ -101,7 +120,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str loginRequest := proto.LoginRequest{ SetupKey: providedSetupKey, ManagementUrl: managementURL, - IsUnixDesktopClient: isUnixRunningDesktop(), + IsUnixDesktopClient: util.HasGraphicalSession(), Hostname: hostName, DnsLabels: dnsLabelsReq, ProfileName: &handle, @@ -152,6 +171,66 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str return nil } +// doExtendSession drives the daemon's RequestExtendAuthSession / +// WaitExtendAuthSession pair. The user is sent through a regular SSO flow +// (browser + verification URL) and the resulting JWT is forwarded to the +// management server's ExtendAuthSession RPC. The tunnel stays up +// throughout — no Down/Up, no network-map resync. +func doExtendSession(ctx context.Context, cmd *cobra.Command) 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() + + client := proto.NewDaemonServiceClient(conn) + + // the CLI runs in the user's session, the daemon does not: tell it what we can see + req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: util.HasGraphicalSession()} + // Pre-fill the IdP login hint from the active profile so the user + // doesn't have to retype their email. Best-effort: we still proceed + // without a hint if the lookup fails. + pm := profilemanager.NewProfileManager() + if active, perr := pm.GetActiveProfile(); perr == nil { + if profState, sperr := pm.GetProfileState(active.ID); sperr == nil && profState.Email != "" { + req.Hint = &profState.Email + } + } + + startResp, err := client.RequestExtendAuthSession(ctx, req) + if err != nil { + return fmt.Errorf("start extend session: %v", err) + } + + uri := startResp.GetVerificationURIComplete() + if uri == "" { + uri = startResp.GetVerificationURI() + } + openURL(cmd, uri, startResp.GetUserCode(), noBrowser, showQR) + + waitResp, err := client.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{ + DeviceCode: startResp.GetDeviceCode(), + UserCode: startResp.GetUserCode(), + }) + if err != nil { + return fmt.Errorf("wait for extend session: %v", err) + } + + if ts := waitResp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() { + deadline := ts.AsTime().Local() + cmd.Printf("Session extended. New expiry: %s\n", deadline.Format("2006-01-02 15:04:05 MST")) + } else { + // Management reported the peer is not eligible (e.g. login + // expiration disabled on the account). Surface that fact + // instead of pretending the call succeeded. + cmd.Println("Session extension call completed, but the management server did not return a new deadline (peer may not be SSO-tracked or login expiration is disabled).") + } + return nil +} + func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) (*profilemanager.Profile, error) { // switch profile if provided @@ -254,6 +333,14 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, return fmt.Errorf("read config file %s: %v", configFilePath, err) } + // Mirror runInForegroundMode: recover residual state (DNS, firewall, + // ssh config, legacy routing) from a previous unclean shutdown and + // enable advanced routing before dialing management. + if err := server.RestoreResidualState(ctx, profilemanager.NewServiceManager(configFilePath).GetStatePath()); err != nil { + log.Warnf("failed to restore residual state: %v", err) + } + nbnet.Init() + err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.ID) if err != nil { return fmt.Errorf("foreground login failed: %v", err) @@ -321,7 +408,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro hint = profileState.Email } - oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isUnixRunningDesktop(), false, hint) + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint) if err != nil { return nil, err } @@ -371,14 +458,6 @@ func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBro } } -// isUnixRunningDesktop checks if a Linux OS is running desktop environment -func isUnixRunningDesktop() bool { - if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { - return false - } - return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != "" -} - func setEnvAndFlags(cmd *cobra.Command) error { SetFlagsFromEnvVars(rootCmd) diff --git a/client/cmd/logout.go b/client/cmd/logout.go index 1a5281acb..dcd7b5075 100644 --- a/client/cmd/logout.go +++ b/client/cmd/logout.go @@ -46,7 +46,7 @@ var logoutCmd = &cobra.Command{ } if _, err := daemonClient.Logout(ctx, req); err != nil { - return fmt.Errorf("deregister: %v", err) + return daemonCallError("deregister", err) } cmd.Println("Deregistered successfully") diff --git a/client/cmd/root.go b/client/cmd/root.go index f3fde2f1c..ccad78942 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -20,8 +20,8 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" + "github.com/netbirdio/netbird/client/anonymize" daddr "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/profilemanager" ) @@ -70,13 +70,16 @@ var ( autoConnectDisabled bool extraIFaceBlackList []string anonymizeFlag bool + anonymizeLevelFlag string dnsRouteInterval time.Duration - lazyConnEnabled bool - mtu uint16 - profilesDisabled bool - updateSettingsDisabled bool - captureEnabled bool - networksDisabled bool + // lazyConnEnabled is the parse target for the deprecated --enable-lazy-connection + // flag. The flag is inert; the value is no longer read (use NB_LAZY_CONN instead). + lazyConnEnabled bool + mtu uint16 + profilesDisabled bool + updateSettingsDisabled bool + captureEnabled bool + networksDisabled bool rootCmd = &cobra.Command{ Use: "netbird", @@ -89,6 +92,7 @@ var ( // Don't resolve for service commands — they create the socket, not connect to it. if !isServiceCmd(cmd) { daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr) + daemonAddr = daddr.ResolveDaemonAddr(daemonAddr) } return nil }, @@ -141,10 +145,10 @@ func init() { defaultDaemonAddr := "unix:///var/run/netbird.sock" if runtime.GOOS == "windows" { - defaultDaemonAddr = "tcp://127.0.0.1:41731" + defaultDaemonAddr = daddr.WindowsPipeAddr } - rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]") + rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]") rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL)) rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL)) rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level") @@ -154,7 +158,8 @@ func init() { rootCmd.MarkFlagsMutuallyExclusive("setup-key", "setup-key-file") rootCmd.PersistentFlags().StringVar(&preSharedKey, preSharedKeyFlag, "", "Sets WireGuard PreSharedKey property. If set, then only peers that have the same key can communicate.") rootCmd.PersistentFlags().StringVarP(&hostName, "hostname", "n", "", "Sets a custom hostname for the device") - rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize IP addresses and non-netbird.io domains in logs and status output") + rootCmd.PersistentFlags().BoolVarP(&anonymizeFlag, "anonymize", "A", false, "anonymize public IP addresses, MAC addresses, and non-netbird.io domains in logs and status output; private, CGNAT, and link-local IP ranges are kept (see --anonymize-level strict)") + rootCmd.PersistentFlags().StringVar(&anonymizeLevelFlag, "anonymize-level", "", "anonymization level: \"default\" or \"strict\"; strict also anonymizes private, CGNAT, and link-local IP ranges, peer names, and WireGuard public keys. Setting this flag implies --anonymize") rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", profilemanager.DefaultConfigPath, "Overrides the default profile file location") rootCmd.AddCommand(upCmd) @@ -210,7 +215,8 @@ func init() { upCmd.PersistentFlags().BoolVar(&rosenpassEnabled, enableRosenpassFlag, false, "[Experimental] Enable Rosenpass feature. If enabled, the connection will be post-quantum secured via Rosenpass.") upCmd.PersistentFlags().BoolVar(&rosenpassPermissive, rosenpassPermissiveFlag, false, "[Experimental] Enable Rosenpass in permissive mode to allow this peer to accept WireGuard connections without requiring Rosenpass functionality from peers that do not have Rosenpass enabled.") upCmd.PersistentFlags().BoolVar(&autoConnectDisabled, disableAutoConnectFlag, false, "Disables auto-connect feature. If enabled, then the client won't connect automatically when the service starts.") - upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "[Experimental] Enable the lazy connection feature. If enabled, the client will establish connections on-demand. Note: this setting may be overridden by management configuration.") + upCmd.PersistentFlags().BoolVar(&lazyConnEnabled, enableLazyConnectionFlag, false, "Deprecated: no longer used. Lazy connections are controlled by the server and the NB_LAZY_CONN environment variable.") + _ = upCmd.PersistentFlags().MarkDeprecated(enableLazyConnectionFlag, "no longer used; lazy connections are controlled by the server and the NB_LAZY_CONN environment variable") } @@ -266,12 +272,10 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e ctx, cancel := context.WithTimeout(ctx, time.Second*10) defer cancel() - return grpc.DialContext( - ctx, - strings.TrimPrefix(addr, "tcp://"), - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithBlock(), - ) + target, opts := daddr.DialTarget(addr) + opts = append(opts, grpc.WithBlock()) + + return grpc.DialContext(ctx, target, opts...) } // WithBackOff execute function in backoff cycle. @@ -292,6 +296,19 @@ var CLIBackOffSettings = &backoff.ExponentialBackOff{ Clock: backoff.SystemClock, } +// effectiveAnonymize resolves the --anonymize and --anonymize-level flags: +// setting a level implies anonymization, and an invalid level is rejected. +func effectiveAnonymize() (bool, anonymize.Level, error) { + if anonymizeLevelFlag == "" { + return anonymizeFlag, anonymize.LevelDefault, nil + } + level := anonymize.ParseLevel(anonymizeLevelFlag) + if !strings.EqualFold(anonymizeLevelFlag, level.String()) { + return false, anonymize.LevelDefault, fmt.Errorf("invalid anonymize level %q: use %q or %q", anonymizeLevelFlag, anonymize.LevelDefault.String(), anonymize.LevelStrict.String()) + } + return true, level, nil +} + func getSetupKey() (string, error) { if setupKeyPath != "" && setupKey == "" { return getSetupKeyFromFile(setupKeyPath) diff --git a/client/cmd/service.go b/client/cmd/service.go index 56d8a8726..7410d60ea 100644 --- a/client/cmd/service.go +++ b/client/cmd/service.go @@ -5,6 +5,7 @@ package cmd import ( "context" "fmt" + "net/http" "runtime" "strings" "sync" @@ -22,15 +23,26 @@ var serviceCmd = &cobra.Command{ Short: "Manage the NetBird daemon service", } +const defaultJSONSocket = "unix:///var/run/netbird-http.sock" + var ( - serviceName string - serviceEnvVars []string + serviceName string + serviceEnvVars []string + jsonSocket string + enableJSONSocket bool ) type program struct { - ctx context.Context - cancel context.CancelFunc - serv *grpc.Server + ctx context.Context + cancel context.CancelFunc + serv *grpc.Server + jsonServ *http.Server + // jsonClient is the gateway's own connection to the daemon. It is held so + // shutting the gateway down also closes it: nothing else references it once + // the handlers are registered, so its transport goroutines would otherwise + // outlive the server. + jsonClient *grpc.ClientConn + jsonServMu sync.Mutex serverInstance *server.Server serverInstanceMu sync.Mutex } @@ -46,6 +58,8 @@ func init() { serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings") serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture") serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks") + serviceCmd.PersistentFlags().BoolVar(&enableJSONSocket, "enable-json-socket", false, "Enables the HTTP/JSON API socket served by grpc-gateway. To persist, use: netbird service install --enable-json-socket") + serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp]://[path|host:port]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket") rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name") serviceEnvDesc := `Sets extra environment variables for the service. ` + diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go index 8de147946..b187a7b87 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -5,9 +5,7 @@ package cmd import ( "context" "fmt" - "net" - "os" - "strings" + "runtime" "time" "github.com/kardianos/service" @@ -16,69 +14,157 @@ import ( "github.com/spf13/cobra" "google.golang.org/grpc" + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/util" ) +func validateJSONSocketFlags() error { + if serviceCmd.PersistentFlags().Changed("json-socket") && !enableJSONSocket { + return fmt.Errorf("--json-socket requires --enable-json-socket to configure the daemon JSON gateway") + } + return nil +} + +// daemonServerOptions installs the transport credentials that expose each +// caller's kernel-authenticated identity to the handlers, which is what lets +// the daemon require root/administrator for privileged operations. +// +// The handshake exchanges no bytes, so older CLI and UI binaries still +// interoperate. Callers on a TCP socket carry no identity at all: the daemon +// keeps serving them, and the privileged operations deny them, so a warning is +// logged to make the loss of functionality visible. +func daemonServerOptions(network string) []grpc.ServerOption { + if network == "tcp" { + log.Warnf("daemon is listening on TCP (%s): callers carry no verifiable identity over TCP, "+ + "so privileged operations (SSH root login, SSH auth, enabling the SSH server, management URL changes, "+ + "deregistration) will be denied. Use a unix socket, or npipe:// on Windows", daemonAddr) + return nil + } + + creds := ipcauth.NewTransportCredentials() //nolint:staticcheck + if creds == nil { //nolint:staticcheck // nil only on platforms without a peer-identity primitive + log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied", runtime.GOOS) + return nil + } + + return []grpc.ServerOption{grpc.Creds(creds)} +} + func (p *program) Start(svc service.Service) error { // Start should not block. Do the actual work async. log.Info("starting NetBird service") //nolint + if err := validateJSONSocketFlags(); err != nil { + return err + } + // Collect static system and platform information system.UpdateStaticInfoAsync() - // in any case, even if configuration does not exists we run daemon to serve CLI gRPC API. - p.serv = grpc.NewServer() - - split := strings.Split(daemonAddr, "://") - switch split[0] { - case "unix": - // cleanup failed close - stat, err := os.Stat(split[1]) - if err == nil && !stat.IsDir() { - if err := os.Remove(split[1]); err != nil { - log.Debugf("remove socket file: %v", err) - } - } - case "tcp": - default: - return fmt.Errorf("unsupported daemon address protocol: %v", split[0]) + // A daemon installed before named-pipe support has the loopback TCP address + // persisted. Move it to the named pipe so an upgraded daemon can identify + // its callers instead of silently serving an unauthenticated socket. + if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok { + log.Infof("daemon address %q predates named-pipe support, listening on %q so callers can be identified", daemonAddr, migrated) + daemonAddr = migrated } - listen, err := net.Listen(split[0], split[1]) + network, _, err := parseListenAddress(daemonAddr) if err != nil { - return fmt.Errorf("listen daemon interface: %w", err) + return fmt.Errorf("parse daemon address: %w", err) } + + // in any case, even if configuration does not exists we run daemon to serve CLI gRPC API. + p.serv = grpc.NewServer(daemonServerOptions(network)...) + + daemonListener, jsonListener, err := listenDaemonSockets() + if err != nil { + return err + } + go func() { - defer listen.Close() - - if split[0] == "unix" { - if err := os.Chmod(split[1], 0666); err != nil { - log.Errorf("failed setting daemon permissions: %v", split[1]) - return - } - } - - serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled) - if err := serverInstance.Start(); err != nil { - log.Fatalf("failed to start daemon: %v", err) - } - proto.RegisterDaemonServiceServer(p.serv, serverInstance) - - p.serverInstanceMu.Lock() - p.serverInstance = serverInstance - p.serverInstanceMu.Unlock() - - log.Printf("started daemon server: %v", split[1]) - if err := p.serv.Serve(listen); err != nil { - log.Errorf("failed to serve daemon requests: %v", err) + // Fatal here rather than inside serve, so serve's deferred listener + // closes run before the process exits. + if err := p.serve(daemonListener, jsonListener); err != nil { + log.Fatalf("failed to %v", err) } }() return nil } +// listenDaemonSockets opens the daemon control socket and, when it is enabled, the +// JSON gateway socket. The control socket is closed again if the second one fails, +// so a failed start leaves nothing listening. The returned JSON listener is nil +// when the socket is disabled. +func listenDaemonSockets() (*socketListener, *socketListener, error) { + daemonListener, err := listenOnAddress(daemonAddr) + if err != nil { + return nil, nil, fmt.Errorf("listen daemon interface: %w", err) + } + + if !enableJSONSocket { + removeStaleUnixSocketForAddress(jsonSocket) + return daemonListener, nil, nil + } + + jsonListener, err := listenOnAddress(jsonSocket) + if err != nil { + if cerr := daemonListener.Close(); cerr != nil { + log.Debugf("close daemon listener: %v", cerr) + } + return nil, nil, fmt.Errorf("listen daemon JSON interface: %w", err) + } + + return daemonListener, jsonListener, nil +} + +// serve brings up the daemon server on an already-open control socket and blocks +// until it stops. jsonListener is nil when the JSON socket is disabled. A returned +// error means the daemon cannot run at all and the caller is expected to exit; the +// failures it recovers from on its own are logged here. +func (p *program) serve(daemonListener, jsonListener *socketListener) error { + defer daemonListener.Close() + if jsonListener != nil { + defer jsonListener.Close() + } + + // chmodUnixSocket is a no-op for a nil listener and for a non-unix one. + if err := daemonListener.chmodUnixSocket("daemon"); err != nil { + log.Error(err) + return nil + } + if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil { + log.Error(err) + return nil + } + + serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled) + if err := serverInstance.Start(); err != nil { + return fmt.Errorf("start daemon: %w", err) + } + proto.RegisterDaemonServiceServer(p.serv, serverInstance) + + p.serverInstanceMu.Lock() + p.serverInstance = serverInstance + p.serverInstanceMu.Unlock() + + if jsonListener == nil { + log.Debug("daemon JSON socket disabled") + } else if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil { + return fmt.Errorf("start daemon JSON server: %w", err) + } + + log.Printf("started daemon server: %v", daemonListener.address) + if err := p.serv.Serve(daemonListener.Listener); err != nil { + log.Errorf("failed to serve daemon requests: %v", err) + } + return nil +} + func (p *program) Stop(srv service.Service) error { p.serverInstanceMu.Lock() if p.serverInstance != nil { @@ -92,6 +178,25 @@ func (p *program) Stop(srv service.Service) error { p.cancel() + p.jsonServMu.Lock() + jsonServ, jsonClient := p.jsonServ, p.jsonClient + p.jsonServMu.Unlock() + if jsonClient != nil { + if err := jsonClient.Close(); err != nil { + log.Debugf("close daemon JSON gateway client: %v", err) + } + } + if jsonServ != nil { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second) + if err := jsonServ.Shutdown(shutdownCtx); err != nil { + log.Errorf("failed to stop daemon JSON server gracefully: %v", err) + if err := jsonServ.Close(); err != nil { + log.Errorf("failed to close daemon JSON server: %v", err) + } + } + shutdownCancel() + } + if p.serv != nil { p.serv.Stop() } @@ -148,6 +253,9 @@ var runCmd = &cobra.Command{ if err != nil { return err } + if err := validateJSONSocketFlags(); err != nil { + return err + } return s.Run() }, @@ -162,6 +270,9 @@ var startCmd = &cobra.Command{ if err != nil { return err } + if err := validateJSONSocketFlags(); err != nil { + return err + } if err := s.Start(); err != nil { return fmt.Errorf("start service: %w", err) @@ -198,6 +309,9 @@ var restartCmd = &cobra.Command{ if err != nil { return err } + if err := validateJSONSocketFlags(); err != nil { + return err + } if err := s.Restart(); err != nil { return fmt.Errorf("restart service: %w", err) diff --git a/client/cmd/service_installer.go b/client/cmd/service_installer.go index 2d45fa063..ae2dfb9fa 100644 --- a/client/cmd/service_installer.go +++ b/client/cmd/service_installer.go @@ -67,6 +67,10 @@ func buildServiceArguments() []string { args = append(args, "--disable-networks") } + if enableJSONSocket { + args = append(args, "--enable-json-socket", "--json-socket", jsonSocket) + } + return args } @@ -106,6 +110,10 @@ func configurePlatformSpecificSettings(svcConfig *service.Config) error { // Create fully configured service config for install/reconfigure func createServiceConfigForInstall() (*service.Config, error) { + if err := validateJSONSocketFlags(); err != nil { + return nil, err + } + svcConfig, err := newSVCConfig() if err != nil { return nil, fmt.Errorf("create service config: %w", err) diff --git a/client/cmd/service_json_gateway.go b/client/cmd/service_json_gateway.go new file mode 100644 index 000000000..b6864f338 --- /dev/null +++ b/client/cmd/service_json_gateway.go @@ -0,0 +1,150 @@ +//go:build !ios && !android + +package cmd + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/proto" +) + +// jsonPeerIdentity is the context key under which the connecting HTTP client's +// identity is stashed for the lifetime of its connection. +type jsonPeerIdentity struct{} + +// jsonPeerIdentityValue pairs the identity with whether it could be read at +// all, so an unreadable identity is forwarded as "unknown" rather than omitted. +type jsonPeerIdentityValue struct { + id ipcauth.Identity + known bool +} + +// jsonConnContext reads the identity of the client connecting to the JSON +// socket and stashes it on the connection's context. The gateway re-dials the +// daemon in-process, so the daemon would otherwise see every JSON request as +// coming from the daemon itself. +func jsonConnContext(ctx context.Context, c net.Conn) context.Context { + value := jsonPeerIdentityValue{} + id, err := ipcauth.ConnIdentity(c) + if err != nil { + log.Warnf("json gateway: cannot read HTTP client identity, privileged operations will be denied for this connection: %v", err) + } else { + value.id = id + value.known = true + } + return context.WithValue(ctx, jsonPeerIdentity{}, value) +} + +// forwardIdentity stamps the HTTP client's identity onto every call the gateway +// makes to the daemon. +// +// It is an interceptor on the gateway's client connection rather than a +// runtime.WithMetadata annotator because grpc-gateway skips annotators when no +// request header maps to metadata, which an HTTP/1.0 request with no Host header +// over a unix socket achieves. The daemon would then receive no marker, see its own +// identity as the transport peer, and authorize the request as the daemon itself. +// An interceptor runs for every RPC whatever the request looked like. +func forwardIdentity(ctx context.Context) context.Context { + value, ok := ctx.Value(jsonPeerIdentity{}).(jsonPeerIdentityValue) + if !ok { + // No ConnContext ran for this request, so forward an unknown identity: + // the daemon must not mistake its own identity for the client's. + return ipcauth.WithForwardedIdentity(ctx, ipcauth.Identity{}, false) + } + return ipcauth.WithForwardedIdentity(ctx, value.id, value.known) +} + +func forwardIdentityUnary(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + return invoker(forwardIdentity(ctx), method, req, reply, cc, opts...) +} + +func forwardIdentityStream(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { + return streamer(forwardIdentity(ctx), desc, cc, method, opts...) +} + +// reservedHeaderWarning limits the dropped-header warning to the first occurrence. +var reservedHeaderWarning sync.Once + +// jsonIncomingHeaderMatcher keeps an HTTP client from supplying the metadata the +// gateway uses to forward its identity. grpc-gateway turns "Grpc-Metadata-" +// headers into gRPC metadata and joins them ahead of what its annotators add, so +// without this filter a JSON client could send its own x-netbird-fwd-uid and the +// daemon would authorize that instead of the client's real identity. +func jsonIncomingHeaderMatcher(key string) (string, bool) { + mapped, ok := runtime.DefaultHeaderMatcher(key) + if !ok { + return "", false + } + if ipcauth.IsReservedForwardKey(mapped) { + // Warn once: any client can send these on every request, so warning each + // time hands it a way to fill the log. The rest are debug-level. + reservedHeaderWarning.Do(func() { + log.Warnf("json gateway: dropping reserved header %q from a request: only the gateway may set the caller's identity", key) + }) + log.Debugf("json gateway: dropping reserved header %q", key) + return "", false + } + return mapped, true +} + +func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error { + if jsonListener.network == "tcp" { + log.Warnf("daemon JSON socket is listening on TCP (%s): callers carry no verifiable identity over TCP, "+ + "so privileged operations will be denied for JSON clients", jsonListener.address) + } + + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + + // grpc.NewClient does not connect until the first request, so registering + // the handler here cannot block daemon startup. + target, opts := daemonaddr.DialTarget(daemonEndpoint) + opts = append(opts, + grpc.WithChainUnaryInterceptor(forwardIdentityUnary), + grpc.WithChainStreamInterceptor(forwardIdentityStream), + ) + conn, err := grpc.NewClient(target, opts...) + if err != nil { + return fmt.Errorf("create daemon client for JSON gateway: %w", err) + } + if err := proto.RegisterDaemonServiceHandler(p.ctx, mux, conn); err != nil { + if cerr := conn.Close(); cerr != nil { + log.Debugf("close daemon client after failed JSON gateway registration: %v", cerr) + } + return err + } + + jsonServer := &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + BaseContext: func(net.Listener) context.Context { + return p.ctx + }, + ConnContext: jsonConnContext, + } + + p.jsonServMu.Lock() + p.jsonServ = jsonServer + p.jsonClient = conn + p.jsonServMu.Unlock() + + go func() { + log.Printf("started daemon JSON server: %v", jsonListener.address) + if err := jsonServer.Serve(jsonListener.Listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Errorf("failed to serve daemon JSON requests: %v", err) + } + }() + + return nil +} diff --git a/client/cmd/service_json_gateway_test.go b/client/cmd/service_json_gateway_test.go new file mode 100644 index 000000000..dfeef1c46 --- /dev/null +++ b/client/cmd/service_json_gateway_test.go @@ -0,0 +1,261 @@ +//go:build !windows && !ios && !android + +package cmd + +import ( + "context" + "net" + "net/http" + "path/filepath" + "testing" + "time" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// The JSON gateway runs inside the daemon and re-dials it locally, so every JSON +// request reaches a handler with the daemon's own identity as the transport peer. +// The gateway therefore forwards its HTTP client's identity as metadata, and the +// daemon authorizes that instead of itself. These tests drive the real wiring +// (jsonConnContext, forwardIdentity, jsonIncomingHeaderMatcher) and check the +// identity a handler would end up authorizing. + +// daemonSideCtx is what a handler sees for a gateway-relayed call. The transport +// peer must be this process's own identity: the gateway is the daemon, so the two +// cannot differ, and hardcoding root here instead would describe a state that +// never occurs. +func daemonSideCtx(t *testing.T, md metadata.MD) context.Context { + t.Helper() + self, err := ipcauth.CurrentProcessIdentity() + if err != nil { + t.Skipf("cannot read this process's identity: %v", err) + } + ctx := peer.NewContext(context.Background(), &peer.Peer{ + AuthInfo: ipcauth.AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: self, + }, + }) + return metadata.NewIncomingContext(ctx, md) +} + +// gatewayMetadata reproduces what the daemon receives for a JSON request: the +// mux annotates the context from the request's headers, then the interceptor on the +// gateway's client connection stamps the caller's identity. The order matters, +// since the interceptor must win over anything a header put there. +func gatewayMetadata(t *testing.T, req *http.Request, ctx context.Context) metadata.MD { + t.Helper() + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + annotated, err := runtime.AnnotateContext(ctx, mux, req, + "/daemon.DaemonService/SetConfig", + runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + t.Fatalf("annotate: %v", err) + } + + md, ok := metadata.FromOutgoingContext(forwardIdentity(annotated)) + if !ok { + t.Fatal("the interceptor produced no metadata") + } + return md +} + +// clientCtx is the connection context jsonConnContext would have produced for an +// HTTP client whose identity the gateway could read. +func clientCtx(id ipcauth.Identity, known bool) context.Context { + return context.WithValue(context.Background(), jsonPeerIdentity{}, + jsonPeerIdentityValue{id: id, known: known}) +} + +// An HTTP client must not be able to name its own identity. grpc-gateway turns +// Grpc-Metadata- headers into gRPC metadata, so without the header filter and +// the interceptor overwriting the reserved keys, this request would authorize as +// uid 0. +func TestJSONGateway_ForgedIdentityHeaderIsDropped(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Uid", "0") + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Gid", "0") + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd", "1") + req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Sid", "S-1-5-18") + + caller := ipcauth.Identity{UID: 31000, GID: 31000} + md := gatewayMetadata(t, req, clientCtx(caller, true)) + + id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)) + if !ok { + t.Fatal("the forwarded identity should be usable") + } + if id.IsPrivileged() { + t.Errorf("forged header was believed: authorized as %v", id) + } + if id.UID != caller.UID { + t.Errorf("authorized as uid %d, want the real client %d", id.UID, caller.UID) + } +} + +// A request with no headers at all (HTTP/1.0 needs no Host, and a unix socket +// yields no host:port) makes grpc-gateway produce no metadata whatsoever and skip +// its annotators: "if len(pairs) == 0 { return ctx, nil, nil }" in +// runtime/context.go. That is why the identity is stamped by an interceptor +// instead. This is the case that previously reached the gate as the daemon itself. +func TestJSONGateway_HeaderlessRequestIsStillMarkedForwarded(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + req.Header = http.Header{} + req.Host = "" + + caller := ipcauth.Identity{UID: 31000, GID: 31000} + ctx := clientCtx(caller, true) + + // Pin the skip path itself: if grpc-gateway ever produced a pair here, this + // test would still pass below while no longer covering what it was written for. + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + annotated, err := runtime.AnnotateContext(ctx, mux, req, + "/daemon.DaemonService/SetConfig", + runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + t.Fatalf("annotate: %v", err) + } + if md, ok := metadata.FromOutgoingContext(annotated); ok { + t.Fatalf("grpc-gateway produced metadata %v for a headerless request; "+ + "this test no longer covers the annotator-skip path", md) + } + + md := gatewayMetadata(t, req, ctx) + + id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)) + if !ok { + t.Fatal("the forwarded identity should be usable") + } + if id.UID != caller.UID || id.IsPrivileged() { + t.Errorf("authorized as %v, want the real client uid %d", id, caller.UID) + } +} + +// When the gateway cannot read its client's identity (a TCP JSON socket, say) it +// forwards the marker alone. The daemon must then report "unidentified" so the +// privileged operations refuse, rather than falling back to the gateway's own +// identity. +func TestJSONGateway_UnreadableClientIdentityIsUnidentified(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + + md := gatewayMetadata(t, req, clientCtx(ipcauth.Identity{}, false)) + + if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok { + t.Errorf("a request with no client identity was authorized as %v", id) + } +} + +// A request that never passed through jsonConnContext (no stashed identity) must +// also come out unidentified rather than as the daemon. +func TestJSONGateway_MissingConnContextIsUnidentified(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil) + if err != nil { + t.Fatal(err) + } + + md := gatewayMetadata(t, req, context.Background()) + + if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok { + t.Errorf("a request with no connection context was authorized as %v", id) + } +} + +// End to end over a real unix socket: the gateway reads the connecting client's +// identity from the socket itself, so a client cannot present anything else. +func TestJSONGateway_IdentityComesFromTheSocket(t *testing.T) { + mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher)) + + type observed struct { + md metadata.MD + } + seen := make(chan observed, 1) + + srv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, err := runtime.AnnotateContext(r.Context(), mux, r, + "/daemon.DaemonService/SetConfig", + runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + t.Errorf("annotate: %v", err) + return + } + md, _ := metadata.FromOutgoingContext(forwardIdentity(ctx)) + seen <- observed{md: md} + w.WriteHeader(http.StatusOK) + }), + ReadHeaderTimeout: 5 * time.Second, + ConnContext: jsonConnContext, + } + + sock := filepath.Join(t.TempDir(), "http.sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := srv.Close(); err != nil { + t.Logf("close server: %v", err) + } + }) + go func() { + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + t.Logf("serve: %v", err) + } + }() + + conn, err := net.Dial("unix", sock) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := conn.Close(); err != nil { + t.Logf("close conn: %v", err) + } + }) + + // Forge the identity headers on the wire as well. + request := "POST /daemon.DaemonService/SetConfig HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Grpc-Metadata-X-Netbird-Fwd: 1\r\n" + + "Grpc-Metadata-X-Netbird-Fwd-Uid: 0\r\n" + + "Content-Length: 0\r\n\r\n" + if _, err := conn.Write([]byte(request)); err != nil { + t.Fatal(err) + } + + select { + case got := <-seen: + self, err := ipcauth.CurrentProcessIdentity() + if err != nil { + t.Skipf("cannot read this process's identity: %v", err) + } + // The socket peer is this test process, so that is the identity the + // gateway must forward, not the uid 0 the request asked for. + if uids := got.md.Get("x-netbird-fwd-uid"); len(uids) != 1 { + t.Fatalf("x-netbird-fwd-uid = %v, want exactly the gateway's own value", uids) + } + id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, got.md)) + if !ok { + t.Fatal("the forwarded identity should be usable") + } + if id.UID != self.UID { + t.Errorf("authorized as uid %d, want the socket peer %d", id.UID, self.UID) + } + case <-time.After(5 * time.Second): + t.Fatal("the gateway never handled the request") + } +} diff --git a/client/cmd/service_json_socket_test.go b/client/cmd/service_json_socket_test.go new file mode 100644 index 000000000..4b39794d7 --- /dev/null +++ b/client/cmd/service_json_socket_test.go @@ -0,0 +1,176 @@ +//go:build !ios && !android + +package cmd + +import ( + "net" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func preserveJSONSocketTestState(t *testing.T) { + t.Helper() + + origJSONSocket := jsonSocket + origEnableJSONSocket := enableJSONSocket + origChanged := map[string]bool{} + serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + origChanged[flag.Name] = flag.Changed + }) + + t.Cleanup(func() { + jsonSocket = origJSONSocket + enableJSONSocket = origEnableJSONSocket + serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + flag.Changed = origChanged[flag.Name] + }) + }) +} + +func TestJSONSocketFlagsArePositiveEnableOnly(t *testing.T) { + assert.NotNil(t, serviceCmd.PersistentFlags().Lookup("enable-json-socket")) + assert.NotNil(t, serviceCmd.PersistentFlags().Lookup("json-socket")) + assert.Nil(t, serviceCmd.PersistentFlags().Lookup("disable-json-socket")) + assert.Equal(t, "false", serviceCmd.PersistentFlags().Lookup("enable-json-socket").DefValue) +} + +func TestBuildServiceArgumentsDefaultDisablesJSONSocket(t *testing.T) { + preserveJSONSocketTestState(t) + + enableJSONSocket = false + jsonSocket = "tcp://127.0.0.1:8080" + + args := buildServiceArguments() + + assert.NotContains(t, args, "--enable-json-socket") + assert.NotContains(t, args, "--json-socket") +} + +func TestBuildServiceArgumentsIncludesJSONSocketWhenEnabled(t *testing.T) { + preserveJSONSocketTestState(t) + + enableJSONSocket = true + jsonSocket = "tcp://127.0.0.1:8080" + + args := buildServiceArguments() + + enableIndex := indexOfArg(args, "--enable-json-socket") + jsonIndex := indexOfArg(args, "--json-socket") + require.NotEqual(t, -1, enableIndex) + require.NotEqual(t, -1, jsonIndex) + require.Less(t, enableIndex, jsonIndex) + require.Less(t, jsonIndex+1, len(args)) + assert.Equal(t, "tcp://127.0.0.1:8080", args[jsonIndex+1]) +} + +func TestJSONSocketWithoutEnableValidation(t *testing.T) { + preserveJSONSocketTestState(t) + + enableJSONSocket = false + require.NoError(t, serviceCmd.PersistentFlags().Set("json-socket", "tcp://127.0.0.1:8080")) + + err := validateJSONSocketFlags() + + require.Error(t, err) + assert.Contains(t, err.Error(), "--enable-json-socket") +} + +func TestJSONSocketWithEnableValidation(t *testing.T) { + preserveJSONSocketTestState(t) + + require.NoError(t, serviceCmd.PersistentFlags().Set("enable-json-socket", "true")) + require.NoError(t, serviceCmd.PersistentFlags().Set("json-socket", "tcp://127.0.0.1:8080")) + + assert.NoError(t, validateJSONSocketFlags()) +} + +func TestJSONSocketServiceParamsPersistEnableAndAddress(t *testing.T) { + preserveJSONSocketTestState(t) + serviceCmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + flag.Changed = false + }) + + enableJSONSocket = true + jsonSocket = "tcp://127.0.0.1:8080" + + params := currentServiceParams() + require.True(t, params.EnableJSONSocket) + require.Equal(t, "tcp://127.0.0.1:8080", params.JSONSocket) + + enableJSONSocket = false + jsonSocket = defaultJSONSocket + applyServiceParams(testServiceEnvCommand(), params) + + assert.True(t, enableJSONSocket) + assert.Equal(t, "tcp://127.0.0.1:8080", jsonSocket) +} + +func TestRemoveStaleUnixSocketDoesNotRemoveRegularFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "netbird-http.sock") + require.NoError(t, os.WriteFile(path, []byte("not a socket"), 0600)) + + removeStaleUnixSocket(path) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, []byte("not a socket"), data) +} + +func TestRemoveStaleUnixSocketRemovesSocket(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix sockets are not available on Windows") + } + + path := filepath.Join(t.TempDir(), "netbird-http.sock") + addr := &net.UnixAddr{Name: path, Net: "unix"} + listener, err := net.ListenUnix("unix", addr) + require.NoError(t, err) + listener.SetUnlinkOnClose(false) + require.NoError(t, listener.Close()) + + _, err = os.Lstat(path) + require.NoError(t, err, "test setup must leave a stale Unix socket path") + + removeStaleUnixSocket(path) + + _, err = os.Lstat(path) + assert.True(t, os.IsNotExist(err), "expected stale Unix socket to be removed, got %v", err) +} + +func TestRemoveStaleUnixSocketDoesNotRemoveLiveSocket(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix sockets are not available on Windows") + } + + path := filepath.Join(t.TempDir(), "netbird-http.sock") + listener, err := net.Listen("unix", path) + require.NoError(t, err) + defer listener.Close() + + removeStaleUnixSocket(path) + + _, err = os.Lstat(path) + assert.NoError(t, err, "expected live Unix socket to be preserved") +} + +func testServiceEnvCommand() *cobra.Command { + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + return cmd +} + +func indexOfArg(args []string, arg string) int { + for i, candidate := range args { + if candidate == arg { + return i + } + } + return -1 +} diff --git a/client/cmd/service_params.go b/client/cmd/service_params.go index 192e0ac60..750b22ae6 100644 --- a/client/cmd/service_params.go +++ b/client/cmd/service_params.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/netbirdio/netbird/client/configs" + "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/util" ) @@ -23,6 +24,7 @@ const serviceParamsFile = "service.json" type serviceParams struct { LogLevel string `json:"log_level"` DaemonAddr string `json:"daemon_addr"` + JSONSocket string `json:"json_socket"` ManagementURL string `json:"management_url,omitempty"` ConfigPath string `json:"config_path,omitempty"` LogFiles []string `json:"log_files,omitempty"` @@ -30,6 +32,7 @@ type serviceParams struct { DisableUpdateSettings bool `json:"disable_update_settings,omitempty"` EnableCapture bool `json:"enable_capture,omitempty"` DisableNetworks bool `json:"disable_networks,omitempty"` + EnableJSONSocket bool `json:"enable_json_socket,omitempty"` ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"` } @@ -75,6 +78,7 @@ func currentServiceParams() *serviceParams { params := &serviceParams{ LogLevel: logLevel, DaemonAddr: daemonAddr, + JSONSocket: jsonSocket, ManagementURL: managementURL, ConfigPath: configPath, LogFiles: logFiles, @@ -82,6 +86,7 @@ func currentServiceParams() *serviceParams { DisableUpdateSettings: updateSettingsDisabled, EnableCapture: captureEnabled, DisableNetworks: networksDisabled, + EnableJSONSocket: enableJSONSocket, } if len(serviceEnvVars) > 0 { @@ -113,15 +118,29 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) { return } - // For fields with non-empty defaults (log-level, daemon-addr), keep the - // != "" guard so that an older service.json missing the field doesn't - // clobber the default with an empty string. + // For fields with non-empty defaults, keep the != "" guard so that an older + // service.json missing the field doesn't clobber the default with an empty string. if !rootCmd.PersistentFlags().Changed("log-level") && params.LogLevel != "" { logLevel = params.LogLevel } if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" { daemonAddr = params.DaemonAddr + // An install that predates named-pipe support has the loopback TCP + // address saved. Callers carry no identity over TCP, so move it to the + // pipe instead of restoring a socket the daemon cannot authorize on. + if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok { + cmd.Printf("Moving the saved daemon address from %s to %s so the daemon can identify its callers\n", daemonAddr, migrated) + daemonAddr = migrated + } + } + + if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" { + jsonSocket = params.JSONSocket + } + + if !serviceCmd.PersistentFlags().Changed("enable-json-socket") { + enableJSONSocket = params.EnableJSONSocket } // For optional fields where empty means "use default", always apply so diff --git a/client/cmd/service_params_test.go b/client/cmd/service_params_test.go index f338c12f4..94f98a0ce 100644 --- a/client/cmd/service_params_test.go +++ b/client/cmd/service_params_test.go @@ -41,6 +41,8 @@ func TestSaveAndLoadServiceParams(t *testing.T) { params := &serviceParams{ LogLevel: "debug", DaemonAddr: "unix:///var/run/netbird.sock", + JSONSocket: "tcp://127.0.0.1:8080", + EnableJSONSocket: true, ManagementURL: "https://my.server.com", ConfigPath: "/etc/netbird/config.json", LogFiles: []string{"/var/log/netbird/client.log", "console"}, @@ -63,6 +65,8 @@ func TestSaveAndLoadServiceParams(t *testing.T) { assert.Equal(t, params.LogLevel, loaded.LogLevel) assert.Equal(t, params.DaemonAddr, loaded.DaemonAddr) + assert.Equal(t, params.JSONSocket, loaded.JSONSocket) + assert.Equal(t, params.EnableJSONSocket, loaded.EnableJSONSocket) assert.Equal(t, params.ManagementURL, loaded.ManagementURL) assert.Equal(t, params.ConfigPath, loaded.ConfigPath) assert.Equal(t, params.LogFiles, loaded.LogFiles) @@ -101,6 +105,8 @@ func TestLoadServiceParams_InvalidJSON(t *testing.T) { func TestCurrentServiceParams(t *testing.T) { origLogLevel := logLevel origDaemonAddr := daemonAddr + origJSONSocket := jsonSocket + origEnableJSONSocket := enableJSONSocket origManagementURL := managementURL origConfigPath := configPath origLogFiles := logFiles @@ -110,6 +116,8 @@ func TestCurrentServiceParams(t *testing.T) { t.Cleanup(func() { logLevel = origLogLevel daemonAddr = origDaemonAddr + jsonSocket = origJSONSocket + enableJSONSocket = origEnableJSONSocket managementURL = origManagementURL configPath = origConfigPath logFiles = origLogFiles @@ -120,6 +128,8 @@ func TestCurrentServiceParams(t *testing.T) { logLevel = "trace" daemonAddr = "tcp://127.0.0.1:9999" + jsonSocket = "tcp://127.0.0.1:8080" + enableJSONSocket = true managementURL = "https://mgmt.example.com" configPath = "/tmp/test-config.json" logFiles = []string{"/tmp/test.log"} @@ -131,6 +141,8 @@ func TestCurrentServiceParams(t *testing.T) { assert.Equal(t, "trace", params.LogLevel) assert.Equal(t, "tcp://127.0.0.1:9999", params.DaemonAddr) + assert.Equal(t, "tcp://127.0.0.1:8080", params.JSONSocket) + assert.True(t, params.EnableJSONSocket) assert.Equal(t, "https://mgmt.example.com", params.ManagementURL) assert.Equal(t, "/tmp/test-config.json", params.ConfigPath) assert.Equal(t, []string{"/tmp/test.log"}, params.LogFiles) @@ -142,6 +154,8 @@ func TestCurrentServiceParams(t *testing.T) { func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { origLogLevel := logLevel origDaemonAddr := daemonAddr + origJSONSocket := jsonSocket + origEnableJSONSocket := enableJSONSocket origManagementURL := managementURL origConfigPath := configPath origLogFiles := logFiles @@ -151,6 +165,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { t.Cleanup(func() { logLevel = origLogLevel daemonAddr = origDaemonAddr + jsonSocket = origJSONSocket + enableJSONSocket = origEnableJSONSocket managementURL = origManagementURL configPath = origConfigPath logFiles = origLogFiles @@ -162,6 +178,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { // Reset all flags to defaults. logLevel = "info" daemonAddr = "unix:///var/run/netbird.sock" + jsonSocket = defaultJSONSocket + enableJSONSocket = false managementURL = "" configPath = "/etc/netbird/config.json" logFiles = []string{"/var/log/netbird/client.log"} @@ -184,6 +202,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { saved := &serviceParams{ LogLevel: "debug", DaemonAddr: "tcp://127.0.0.1:5555", + JSONSocket: "tcp://127.0.0.1:8080", + EnableJSONSocket: true, ManagementURL: "https://saved.example.com", ConfigPath: "/saved/config.json", LogFiles: []string{"/saved/client.log"}, @@ -201,6 +221,8 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { // All other fields were not Changed, so they should use saved values. assert.Equal(t, "tcp://127.0.0.1:5555", daemonAddr) + assert.Equal(t, "tcp://127.0.0.1:8080", jsonSocket) + assert.True(t, enableJSONSocket) assert.Equal(t, "https://saved.example.com", managementURL) assert.Equal(t, "/saved/config.json", configPath) assert.Equal(t, []string{"/saved/client.log"}, logFiles) @@ -212,14 +234,17 @@ func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) { origProfilesDisabled := profilesDisabled origUpdateSettingsDisabled := updateSettingsDisabled + origEnableJSONSocket := enableJSONSocket t.Cleanup(func() { profilesDisabled = origProfilesDisabled updateSettingsDisabled = origUpdateSettingsDisabled + enableJSONSocket = origEnableJSONSocket }) // Simulate current state where booleans are true (e.g. set by previous install). profilesDisabled = true updateSettingsDisabled = true + enableJSONSocket = true // Reset Changed state so flags appear unset. serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { @@ -238,6 +263,7 @@ func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) { assert.False(t, profilesDisabled, "saved false should override current true") assert.False(t, updateSettingsDisabled, "saved false should override current true") + assert.False(t, enableJSONSocket, "saved false should override current true") } func TestApplyServiceParams_ClearManagementURL(t *testing.T) { @@ -530,6 +556,7 @@ func fieldToGlobalVar(field string) string { m := map[string]string{ "LogLevel": "logLevel", "DaemonAddr": "daemonAddr", + "JSONSocket": "jsonSocket", "ManagementURL": "managementURL", "ConfigPath": "configPath", "LogFiles": "logFiles", @@ -537,6 +564,7 @@ func fieldToGlobalVar(field string) string { "DisableUpdateSettings": "updateSettingsDisabled", "EnableCapture": "captureEnabled", "DisableNetworks": "networksDisabled", + "EnableJSONSocket": "enableJSONSocket", "ServiceEnvVars": "serviceEnvVars", } if v, ok := m[field]; ok { diff --git a/client/cmd/service_pipe_other.go b/client/cmd/service_pipe_other.go new file mode 100644 index 000000000..c7cc72469 --- /dev/null +++ b/client/cmd/service_pipe_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package cmd + +import ( + "fmt" + "net" +) + +// listenNamedPipe is Windows-only: no other platform serves the daemon on a +// named pipe. +func listenNamedPipe(string) (net.Listener, string, error) { + return nil, "", fmt.Errorf("named pipes are only supported on Windows") +} diff --git a/client/cmd/service_pipe_windows.go b/client/cmd/service_pipe_windows.go new file mode 100644 index 000000000..b6e860f51 --- /dev/null +++ b/client/cmd/service_pipe_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package cmd + +import ( + "errors" + "fmt" + "net" + + "github.com/Microsoft/go-winio" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// listenNamedPipe creates the daemon control pipe and reports the path it ended +// up on. The security descriptor lets any local caller connect, as a Unix socket +// at 0666 does, and the privileged operations are authorized separately from the +// caller's token. +// +// The protected name comes first so that an unprivileged process cannot take the +// name before the service does. Creating it requires being an administrator or +// LocalSystem, so a daemon an ordinary user runs themselves, as in netstack mode, +// falls back to the plain name; clients try both and check who serves them. +func listenNamedPipe(name string) (net.Listener, string, error) { + var errs []error + for _, path := range daemonaddr.PipePaths(name) { + listener, err := winio.ListenPipe(path, &winio.PipeConfig{ + SecurityDescriptor: ipcauth.DefaultPipeSDDL(), + }) + if err != nil { + log.Debugf("not serving the daemon on %s: %v", path, err) + errs = append(errs, fmt.Errorf("%s: %w", path, err)) + continue + } + return listener, path, nil + } + + return nil, "", errors.Join(errs...) +} diff --git a/client/cmd/service_privileged_test.go b/client/cmd/service_privileged_test.go new file mode 100644 index 000000000..075d7f378 --- /dev/null +++ b/client/cmd/service_privileged_test.go @@ -0,0 +1,196 @@ +//go:build privileged + +package cmd + +import ( + "context" + "fmt" + "os" + "runtime" + "testing" + "time" + + "github.com/kardianos/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + serviceStartTimeout = 10 * time.Second + serviceStopTimeout = 5 * time.Second + statusPollInterval = 500 * time.Millisecond +) + +// waitForServiceStatus waits for service to reach expected status with timeout +func waitForServiceStatus(expectedStatus service.Status, timeout time.Duration) (bool, error) { + cfg, err := newSVCConfig() + if err != nil { + return false, err + } + + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + if err != nil { + return false, err + } + + ctx, timeoutCancel := context.WithTimeout(context.Background(), timeout) + defer timeoutCancel() + + ticker := time.NewTicker(statusPollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return false, fmt.Errorf("timeout waiting for service status %v", expectedStatus) + case <-ticker.C: + status, err := s.Status() + if err != nil { + // Continue polling on transient errors + continue + } + if status == expectedStatus { + return true, nil + } + } + } +} + +// TestServiceLifecycle tests the complete service lifecycle +func TestServiceLifecycle(t *testing.T) { + // TODO: Add support for Windows and macOS + if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { + t.Skipf("Skipping service lifecycle test on unsupported OS: %s", runtime.GOOS) + } + + if os.Getenv("CONTAINER") == "true" { + t.Skip("Skipping service lifecycle test in container environment") + } + + originalServiceName := serviceName + serviceName = "netbirdtest" + fmt.Sprintf("%d", time.Now().Unix()) + defer func() { + serviceName = originalServiceName + }() + + tempDir := t.TempDir() + configPath = fmt.Sprintf("%s/netbird-test-config.json", tempDir) + logLevel = "info" + daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir) + + // Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run. + t.Cleanup(func() { + cfg, err := newSVCConfig() + if err != nil { + t.Errorf("cleanup: create service config: %v", err) + return + } + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + if err != nil { + t.Errorf("cleanup: create service: %v", err) + return + } + + // If the subtests already cleaned up, there's nothing to do. + if _, err := s.Status(); err != nil { + return + } + + if err := s.Stop(); err != nil { + t.Errorf("cleanup: stop service: %v", err) + } + if err := s.Uninstall(); err != nil { + t.Errorf("cleanup: uninstall service: %v", err) + } + }) + + ctx := context.Background() + + t.Run("Install", func(t *testing.T) { + installCmd.SetContext(ctx) + err := installCmd.RunE(installCmd, []string{}) + require.NoError(t, err) + + cfg, err := newSVCConfig() + require.NoError(t, err) + + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + require.NoError(t, err) + + status, err := s.Status() + assert.NoError(t, err) + assert.NotEqual(t, service.StatusUnknown, status) + }) + + t.Run("Start", func(t *testing.T) { + startCmd.SetContext(ctx) + err := startCmd.RunE(startCmd, []string{}) + require.NoError(t, err) + + running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) + require.NoError(t, err) + assert.True(t, running) + }) + + t.Run("Restart", func(t *testing.T) { + restartCmd.SetContext(ctx) + err := restartCmd.RunE(restartCmd, []string{}) + require.NoError(t, err) + + running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) + require.NoError(t, err) + assert.True(t, running) + }) + + t.Run("Reconfigure", func(t *testing.T) { + originalLogLevel := logLevel + logLevel = "debug" + defer func() { + logLevel = originalLogLevel + }() + + reconfigureCmd.SetContext(ctx) + err := reconfigureCmd.RunE(reconfigureCmd, []string{}) + require.NoError(t, err) + + running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) + require.NoError(t, err) + assert.True(t, running) + }) + + t.Run("Stop", func(t *testing.T) { + stopCmd.SetContext(ctx) + err := stopCmd.RunE(stopCmd, []string{}) + require.NoError(t, err) + + stopped, err := waitForServiceStatus(service.StatusStopped, serviceStopTimeout) + require.NoError(t, err) + assert.True(t, stopped) + }) + + t.Run("Uninstall", func(t *testing.T) { + uninstallCmd.SetContext(ctx) + err := uninstallCmd.RunE(uninstallCmd, []string{}) + require.NoError(t, err) + + cfg, err := newSVCConfig() + require.NoError(t, err) + + ctxSvc, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newSVC(newProgram(ctxSvc, cancel), cfg) + require.NoError(t, err) + + _, err = s.Status() + assert.Error(t, err) + }) +} diff --git a/client/cmd/service_socket.go b/client/cmd/service_socket.go new file mode 100644 index 000000000..bf3122f7c --- /dev/null +++ b/client/cmd/service_socket.go @@ -0,0 +1,119 @@ +//go:build !ios && !android + +package cmd + +import ( + "errors" + "fmt" + "net" + "os" + "strings" + "syscall" + "time" + + log "github.com/sirupsen/logrus" +) + +type socketListener struct { + net.Listener + network string + address string +} + +func listenOnAddress(addr string) (*socketListener, error) { + network, address, err := parseListenAddress(addr) + if err != nil { + return nil, err + } + + if network == "npipe" { + listener, path, err := listenNamedPipe(address) //nolint:staticcheck + if err != nil { //nolint:staticcheck // always errors on non-Windows builds + return nil, err + } + return &socketListener{Listener: listener, network: network, address: path}, nil + } + + if network == "unix" { + removeStaleUnixSocket(address) + } + + listener, err := net.Listen(network, address) + if err != nil { + return nil, err + } + + return &socketListener{Listener: listener, network: network, address: address}, nil +} + +func parseListenAddress(addr string) (string, string, error) { + network, address, ok := strings.Cut(addr, "://") + if !ok || network == "" || address == "" { + return "", "", fmt.Errorf("address must be in [unix|tcp|npipe]://[path|host:port|name] format: %q", addr) + } + + switch network { + case "unix", "tcp", "npipe": + return network, address, nil + default: + return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network) + } +} + +func removeStaleUnixSocket(path string) { + stat, err := os.Lstat(path) + if err != nil { + if !os.IsNotExist(err) { + log.Debugf("stat socket file: %v", err) + } + return + } + + if stat.Mode()&os.ModeSocket == 0 { + return + } + + if !isStaleUnixSocket(path) { + return + } + + if err := os.Remove(path); err != nil { + log.Debugf("remove socket file: %v", err) + } +} + +func isStaleUnixSocket(path string) bool { + conn, err := net.DialTimeout("unix", path, 100*time.Millisecond) + if err == nil { + if closeErr := conn.Close(); closeErr != nil { + log.Debugf("close unix socket probe: %v", closeErr) + } + return false + } + + if os.IsNotExist(err) || os.IsPermission(err) || os.IsTimeout(err) { + log.Debugf("not removing unix socket %s after probe error: %v", path, err) + return false + } + + return errors.Is(err, syscall.ECONNREFUSED) +} + +func removeStaleUnixSocketForAddress(addr string) { + network, address, err := parseListenAddress(addr) + if err != nil || network != "unix" { + return + } + removeStaleUnixSocket(address) +} + +func (l *socketListener) chmodUnixSocket(description string) error { + if l == nil || l.network != "unix" { + return nil + } + + if err := os.Chmod(l.address, 0666); err != nil { + return fmt.Errorf("failed setting %s permissions for %s: %w", description, l.address, err) + } + return nil +} diff --git a/client/cmd/service_test.go b/client/cmd/service_test.go index ce6f71550..22eba206d 100644 --- a/client/cmd/service_test.go +++ b/client/cmd/service_test.go @@ -1,16 +1,12 @@ package cmd import ( - "context" - "fmt" "os" "os/signal" "runtime" "syscall" "testing" - "time" - "github.com/kardianos/service" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,186 +27,6 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -const ( - serviceStartTimeout = 10 * time.Second - serviceStopTimeout = 5 * time.Second - statusPollInterval = 500 * time.Millisecond -) - -// waitForServiceStatus waits for service to reach expected status with timeout -func waitForServiceStatus(expectedStatus service.Status, timeout time.Duration) (bool, error) { - cfg, err := newSVCConfig() - if err != nil { - return false, err - } - - ctxSvc, cancel := context.WithCancel(context.Background()) - defer cancel() - - s, err := newSVC(newProgram(ctxSvc, cancel), cfg) - if err != nil { - return false, err - } - - ctx, timeoutCancel := context.WithTimeout(context.Background(), timeout) - defer timeoutCancel() - - ticker := time.NewTicker(statusPollInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return false, fmt.Errorf("timeout waiting for service status %v", expectedStatus) - case <-ticker.C: - status, err := s.Status() - if err != nil { - // Continue polling on transient errors - continue - } - if status == expectedStatus { - return true, nil - } - } - } -} - -// TestServiceLifecycle tests the complete service lifecycle -func TestServiceLifecycle(t *testing.T) { - // TODO: Add support for Windows and macOS - if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { - t.Skipf("Skipping service lifecycle test on unsupported OS: %s", runtime.GOOS) - } - - if os.Getenv("CONTAINER") == "true" { - t.Skip("Skipping service lifecycle test in container environment") - } - - originalServiceName := serviceName - serviceName = "netbirdtest" + fmt.Sprintf("%d", time.Now().Unix()) - defer func() { - serviceName = originalServiceName - }() - - tempDir := t.TempDir() - configPath = fmt.Sprintf("%s/netbird-test-config.json", tempDir) - logLevel = "info" - daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir) - - // Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run. - t.Cleanup(func() { - cfg, err := newSVCConfig() - if err != nil { - t.Errorf("cleanup: create service config: %v", err) - return - } - ctxSvc, cancel := context.WithCancel(context.Background()) - defer cancel() - s, err := newSVC(newProgram(ctxSvc, cancel), cfg) - if err != nil { - t.Errorf("cleanup: create service: %v", err) - return - } - - // If the subtests already cleaned up, there's nothing to do. - if _, err := s.Status(); err != nil { - return - } - - if err := s.Stop(); err != nil { - t.Errorf("cleanup: stop service: %v", err) - } - if err := s.Uninstall(); err != nil { - t.Errorf("cleanup: uninstall service: %v", err) - } - }) - - ctx := context.Background() - - t.Run("Install", func(t *testing.T) { - installCmd.SetContext(ctx) - err := installCmd.RunE(installCmd, []string{}) - require.NoError(t, err) - - cfg, err := newSVCConfig() - require.NoError(t, err) - - ctxSvc, cancel := context.WithCancel(context.Background()) - defer cancel() - - s, err := newSVC(newProgram(ctxSvc, cancel), cfg) - require.NoError(t, err) - - status, err := s.Status() - assert.NoError(t, err) - assert.NotEqual(t, service.StatusUnknown, status) - }) - - t.Run("Start", func(t *testing.T) { - startCmd.SetContext(ctx) - err := startCmd.RunE(startCmd, []string{}) - require.NoError(t, err) - - running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) - require.NoError(t, err) - assert.True(t, running) - }) - - t.Run("Restart", func(t *testing.T) { - restartCmd.SetContext(ctx) - err := restartCmd.RunE(restartCmd, []string{}) - require.NoError(t, err) - - running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) - require.NoError(t, err) - assert.True(t, running) - }) - - t.Run("Reconfigure", func(t *testing.T) { - originalLogLevel := logLevel - logLevel = "debug" - defer func() { - logLevel = originalLogLevel - }() - - reconfigureCmd.SetContext(ctx) - err := reconfigureCmd.RunE(reconfigureCmd, []string{}) - require.NoError(t, err) - - running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout) - require.NoError(t, err) - assert.True(t, running) - }) - - t.Run("Stop", func(t *testing.T) { - stopCmd.SetContext(ctx) - err := stopCmd.RunE(stopCmd, []string{}) - require.NoError(t, err) - - stopped, err := waitForServiceStatus(service.StatusStopped, serviceStopTimeout) - require.NoError(t, err) - assert.True(t, stopped) - }) - - t.Run("Uninstall", func(t *testing.T) { - uninstallCmd.SetContext(ctx) - err := uninstallCmd.RunE(uninstallCmd, []string{}) - require.NoError(t, err) - - cfg, err := newSVCConfig() - require.NoError(t, err) - - ctxSvc, cancel := context.WithCancel(context.Background()) - defer cancel() - - s, err := newSVC(newProgram(ctxSvc, cancel), cfg) - require.NoError(t, err) - - _, err = s.Status() - assert.Error(t, err) - }) -} - // TestServiceEnvVars tests environment variable parsing func TestServiceEnvVars(t *testing.T) { tests := []struct { diff --git a/client/cmd/status.go b/client/cmd/status.go index 5a7559cf1..f2e5bcc66 100644 --- a/client/cmd/status.go +++ b/client/cmd/status.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "strings" + "time" "github.com/spf13/cobra" "google.golang.org/grpc/status" @@ -115,8 +116,19 @@ func statusFunc(cmd *cobra.Command, args []string) error { // manager only knows the active profile ID, not its display name. profName := getActiveProfileName(ctx) + var sessionExpiresAt time.Time + if ts := resp.GetSessionExpiresAt(); ts.IsValid() { + sessionExpiresAt = ts.AsTime().UTC() + } + + anonymizeEnabled, anonymizeLevel, err := effectiveAnonymize() + if err != nil { + return err + } + var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{ - Anonymize: anonymizeFlag, + Anonymize: anonymizeEnabled, + AnonymizeLevel: anonymizeLevel, DaemonVersion: resp.GetDaemonVersion(), DaemonStatus: nbstatus.ParseDaemonStatus(status), StatusFilter: statusFilter, @@ -125,6 +137,7 @@ func statusFunc(cmd *cobra.Command, args []string) error { IPsFilter: ipsFilterMap, ConnectionTypeFilter: connectionTypeFilter, ProfileName: profName, + SessionExpiresAt: sessionExpiresAt, }) var statusOutputString string switch { diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go index 205327ef5..f40056f83 100644 --- a/client/cmd/testutil_test.go +++ b/client/cmd/testutil_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" "google.golang.org/grpc" diff --git a/client/cmd/up.go b/client/cmd/up.go index 0506bc65b..9f4fa8c33 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -21,7 +21,9 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" + nbnet "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/util" @@ -229,6 +231,24 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr _, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath) + // Restore residual state left by a previous run that did not shut down + // cleanly, mirroring what the daemon does before connecting: it recovers + // DNS config (a stale resolv.conf takeover can make the management + // hostname unresolvable), firewall rules, ssh config and legacy routing. + // Route cleanup itself happens at engine start; nbnet.Init() below lets + // the management dial bypass a leftover fwmark rule until then. + // Foreground mode is particularly exposed in containers: a crashed + // container restarts inside the same (pod) network namespace, so stale + // state survives while the process does not. + if err := server.RestoreResidualState(ctx, profilemanager.NewServiceManager(configPath).GetStatePath()); err != nil { + log.Warnf("failed to restore residual state: %v", err) + } + + // Enable advanced routing (as the daemon does on startup) so the + // management dial bypasses a leftover fwmark rule instead of being + // shunted into a stale routing table. + nbnet.Init() + err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.ID) if err != nil { return fmt.Errorf("foreground login failed: %v", err) @@ -305,7 +325,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable { log.Warnf("setConfig method is not available in the daemon: %s", st.Message()) } else { - return fmt.Errorf("call service setConfig method: %v", err) + return daemonCallError("call service setConfig method", err) } } @@ -359,7 +379,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ } if loginErr != nil { - return fmt.Errorf("login failed: %v", loginErr) + return daemonCallError("login failed", loginErr) } if loginResp.NeedsSSOLogin { @@ -372,7 +392,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ ProfileName: &profileID, Username: &username, }); err != nil { - return fmt.Errorf("call service up method: %v", err) + return daemonCallError("call service up method", err) } return nil @@ -479,10 +499,6 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro req.DisableIpv6 = &disableIPv6 } - if cmd.Flag(enableLazyConnectionFlag).Changed { - req.LazyConnectionEnabled = &lazyConnEnabled - } - return &req } @@ -600,9 +616,6 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil ic.DisableIPv6 = &disableIPv6 } - if cmd.Flag(enableLazyConnectionFlag).Changed { - ic.LazyConnectionEnabled = &lazyConnEnabled - } return &ic, nil } @@ -613,7 +626,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte NatExternalIPs: natExternalIPs, CleanNATExternalIPs: natExternalIPs != nil && len(natExternalIPs) == 0, CustomDNSAddress: customDNSAddressConverted, - IsUnixDesktopClient: isUnixRunningDesktop(), + IsUnixDesktopClient: util.HasGraphicalSession(), Hostname: hostName, ExtraIFaceBlacklist: extraIFaceBlackList, DnsLabels: dnsLabels, @@ -718,9 +731,6 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte loginRequest.DisableIpv6 = &disableIPv6 } - if cmd.Flag(enableLazyConnectionFlag).Changed { - loginRequest.LazyConnectionEnabled = &lazyConnEnabled - } return &loginRequest, nil } diff --git a/client/configs/configs.go b/client/configs/configs.go index 8f9c3ba28..a1ecf0feb 100644 --- a/client/configs/configs.go +++ b/client/configs/configs.go @@ -6,6 +6,11 @@ import ( "runtime" ) +// UILogFile is the file name the desktop UI writes its log to. It is defined +// here so the UI (writer), the daemon's RegisterUILog validation, and the debug +// bundle collector all share one definition. +const UILogFile = "gui-client.log" + var StateDir string func init() { diff --git a/client/embed/embed.go b/client/embed/embed.go index 0e8991be2..079e03c63 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -21,7 +21,7 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" - sshcommon "github.com/netbirdio/netbird/client/ssh" + nbssh "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/shared/management/domain" mgmProto "github.com/netbirdio/netbird/shared/management/proto" @@ -91,6 +91,13 @@ type Options struct { // when the embedded client must never act as a stepping stone into // the host's local network (e.g. the proxy's overlay peer). BlockLANAccess bool + // LazyConnectionEnabled is a tri-state local override for lazy connections, + // mirroring the NB_LAZY_CONN env var. Nil defers to the management feature + // flag; a set value overrides it in both directions. A short-lived client + // that reaches only a few known peers can set this to false, so its peers + // connect eagerly and the first request does not wait for the connection to + // be established. + LazyConnectionEnabled *bool // WireguardPort is the port for the tunnel interface. Use 0 for a random port. WireguardPort *int // MTU is the MTU for the tunnel interface. @@ -220,6 +227,15 @@ func New(opts Options) (*Client, error) { config.PrivateKey = opts.PrivateKey } + if opts.LazyConnectionEnabled != nil { + // Runtime-only override, read back through lazyconn.ParseState; a set value + // wins over the management feature flag in both directions. + config.LazyConnection = "off" + if *opts.LazyConnectionEnabled { + config.LazyConnection = "on" + } + } + if opts.Performance.PreallocatedBuffersPerPool != nil { wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool) } @@ -279,9 +295,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()) @@ -468,7 +486,7 @@ func (c *Client) Status() (peer.FullStatus, error) { if connect != nil { engine := connect.Engine() if engine != nil { - _ = engine.RunHealthProbes(false) + _ = engine.RunHealthProbes(context.Background(), false) } } @@ -519,12 +537,7 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error { return err } - storedKey, found := engine.GetPeerSSHKey(peerAddress) - if !found { - return sshcommon.ErrPeerNotFound - } - - return sshcommon.VerifyHostKey(storedKey, key, peerAddress) + return nbssh.PeerKeyLookup(engine.GetPeerSSHKey).VerifySSHHostKey(peerAddress, key) } // SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go index a2f438975..27beb8934 100644 --- a/client/embed/embed_test.go +++ b/client/embed/embed_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "google.golang.org/grpc" diff --git a/client/firewall/firewalld/firewalld.go b/client/firewall/firewalld/firewalld.go index 188ea61dd..38a4efdbc 100644 --- a/client/firewall/firewalld/firewalld.go +++ b/client/firewall/firewalld/firewalld.go @@ -2,8 +2,8 @@ // its wg interface into firewalld's "trusted" zone. This is required because // firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent // versions, which returns EPERM to any other process that tries to insert -// rules into them. The workaround mirrors what Tailscale does: let firewalld -// itself add the accept rules to its own chains by trusting the interface. +// rules into them. Trusting the interface makes firewalld itself add the +// accept rules to its own chains instead. package firewalld // TrustedZone is the firewalld zone name used for interfaces whose traffic diff --git a/client/firewall/iptables/acl_linux.go b/client/firewall/iptables/acl_linux.go index 4b4cebf9c..89d1ebf7c 100644 --- a/client/firewall/iptables/acl_linux.go +++ b/client/firewall/iptables/acl_linux.go @@ -42,6 +42,7 @@ type aclManager struct { optionalEntries map[string][]entry ipsetStore *ipsetStore v6 bool + ipsetSupported bool stateManager *statemanager.Manager } @@ -60,6 +61,8 @@ func newAclManager(iptablesClient *iptables.IPTables, wgIface iFaceMapper) (*acl func (m *aclManager) init(stateManager *statemanager.Manager) error { m.stateManager = stateManager + m.ipsetSupported = m.probeIPSetSupport() + m.seedInitialEntries() m.seedInitialOptionalEntries() @@ -91,6 +94,12 @@ func (m *aclManager) AddPeerFiltering( if m.v6 && ipsetName != "" { ipsetName += "-v6" } + // When the kernel lacks the required ipset hash module, fall back to + // per-IP iptables rules (pre-0.68 behavior) so ACLs keep working instead + // of silently leaving the chain empty. + if ipsetName != "" && !m.ipsetSupported { + ipsetName = "" + } proto := protoForFamily(protocol, m.v6) specs := filterRuleSpecs(ip, proto, sPort, dPort, action, ipsetName) @@ -498,6 +507,40 @@ func transformIPsetName(ipsetName string, sPort, dPort *firewall.Port, action fi } } +// probeIPSetSupport checks whether the kernel can create the ipset type used for +// ACL rules. On kernels lacking the required ipset hash module, ipset creation +// fails (e.g. "invalid argument"), which would otherwise leave the ACL chain +// empty and silently drop all policy-permitted inbound traffic. When unsupported, +// the manager falls back to per-IP iptables rules. +func (m *aclManager) probeIPSetSupport() bool { + // Use a unique name so concurrent processes don't collide and we only ever + // destroy the set we created ourselves. ipset names are limited to 31 chars, + // so use a short random suffix. + probeName := "nb-probe-" + uuid.New().String()[:8] + + opts := ipset.CreateOptions{ + Replace: true, + } + if m.v6 { + opts.Family = ipset.FamilyIPV6 + } + + if err := ipset.Create(probeName, ipset.TypeHashNet, opts); err != nil { + log.Warnf("ipset is not available (failed to create probe set: %v); "+ + "falling back to per-IP iptables ACL rules. Ensure the kernel provides "+ + "the ipset hash:net module (ip_set_hash_net) for better performance with large rule sets", err) + return false + } + + defer func() { + if err := ipset.Destroy(probeName); err != nil { + log.Debugf("destroy ipset probe set %q: %v", probeName, err) + } + }() + + return true +} + func (m *aclManager) createIPSet(name string) error { opts := ipset.CreateOptions{ Replace: true, diff --git a/client/firewall/iptables/dnat_refcount_linux_test.go b/client/firewall/iptables/dnat_refcount_linux_test.go new file mode 100644 index 000000000..681bc0b99 --- /dev/null +++ b/client/firewall/iptables/dnat_refcount_linux_test.go @@ -0,0 +1,240 @@ +//go:build privileged + +package iptables + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +func iptRefcountIfaceV4() *iFaceMock { + return &iFaceMock{ + NameFunc: func() string { return "wt-refcount" }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("10.20.0.1"), + Network: netip.MustParsePrefix("10.20.0.0/24"), + } + }, + } +} + +func iptRefcountIfaceDual() *iFaceMock { + return &iFaceMock{ + NameFunc: func() string { return "wt-refcount" }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("10.20.0.1"), + Network: netip.MustParsePrefix("10.20.0.0/24"), + IPv6: netip.MustParseAddr("fd00::1"), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + } + }, + } +} + +func newIptRefcountManager(t *testing.T, dual bool) *Manager { + t.Helper() + var ifMock *iFaceMock + if dual { + ifMock = iptRefcountIfaceDual() + } else { + ifMock = iptRefcountIfaceV4() + } + m, err := Create(ifMock, iface.DefaultMTU) + require.NoError(t, err, "create manager") + require.NoError(t, m.Init(nil), "init manager") + t.Cleanup(func() { + require.NoError(t, m.Close(nil), "close manager") + }) + return m +} + +func iptDnatV4(port uint16) fw.ForwardRule { + return fw.ForwardRule{ + Protocol: fw.ProtocolTCP, + DestinationPort: fw.Port{Values: []uint16{port}}, + TranslatedAddress: netip.MustParseAddr("10.20.0.2"), + TranslatedPort: fw.Port{Values: []uint16{80}}, + } +} + +func iptDnatV6(port uint16) fw.ForwardRule { + return fw.ForwardRule{ + Protocol: fw.ProtocolTCP, + DestinationPort: fw.Port{Values: []uint16{port}}, + TranslatedAddress: netip.MustParseAddr("fd00::2"), + TranslatedPort: fw.Port{Values: []uint16{80}}, + } +} + +// TestIptablesRouting_RepeatedEnableSingleReference verifies that EnableRouting +// (called on every network-map update) holds at most one reference per family +// and a single DisableRouting drops both back to zero. +func TestIptablesRouting_RepeatedEnableSingleReference(t *testing.T) { + m := newIptRefcountManager(t, true) + state := m.router.ipFwdState + + require.NoError(t, m.EnableRouting(), "first enable") + require.NoError(t, m.EnableRouting(), "second enable") + require.NoError(t, m.EnableRouting(), "third enable") + v4, v6 := state.Counts() + assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference") + assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference") + + require.NoError(t, m.DisableRouting(), "disable") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4, "single disable releases the v4 reference") + assert.Equal(t, 0, v6, "single disable releases the v6 reference") +} + +// TestIptablesRouting_DisableKeepsDNATReference verifies that an unpaired +// DisableRouting does not release references held by active DNAT rules. +func TestIptablesRouting_DisableKeepsDNATReference(t *testing.T) { + m := newIptRefcountManager(t, true) + state := m.router.ipFwdState + + r1, err := m.AddDNATRule(iptDnatV6(9095)) + require.NoError(t, err, "add v6 dnat") + + require.NoError(t, m.DisableRouting(), "unpaired disable") + _, v6 := state.Counts() + assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting") + + require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat") + _, v6 = state.Counts() + assert.Equal(t, 0, v6, "delete releases the DNAT reference") +} + +// TestIptablesDNAT_RefcountBalancedV4 covers a Balanced Add/Delete pair on v4. +func TestIptablesDNAT_RefcountBalancedV4(t *testing.T) { + m := newIptRefcountManager(t, false) + state := m.router.ipFwdState + + r1, err := m.AddDNATRule(iptDnatV4(7081)) + require.NoError(t, err, "add v4 dnat 1") + v4, v6 := state.Counts() + assert.Equal(t, 1, v4, "v4 refcount after first add") + assert.Equal(t, 0, v6, "v6 refcount unchanged") + + r2, err := m.AddDNATRule(iptDnatV4(7082)) + require.NoError(t, err, "add v4 dnat 2") + v4, v6 = state.Counts() + assert.Equal(t, 2, v4, "v4 refcount after second add") + assert.Equal(t, 0, v6, "v6 refcount unchanged") + + require.NoError(t, m.DeleteDNATRule(r1)) + v4, v6 = state.Counts() + assert.Equal(t, 1, v4, "v4 refcount after first delete") + assert.Equal(t, 0, v6, "v6 refcount unchanged") + + require.NoError(t, m.DeleteDNATRule(r2)) + v4, v6 = state.Counts() + assert.Equal(t, 0, v4, "v4 refcount after second delete") + assert.Equal(t, 0, v6, "v6 refcount unchanged") +} + +// TestIptablesDNAT_RefcountBalancedV6 checks the v6 path increments v6 only and +// decrements back to zero. +func TestIptablesDNAT_RefcountBalancedV6(t *testing.T) { + m := newIptRefcountManager(t, true) + require.NotNil(t, m.router6, "v6 router") + require.Same(t, m.router.ipFwdState, m.router6.ipFwdState, "shared state") + state := m.router.ipFwdState + + r1, err := m.AddDNATRule(iptDnatV6(9081)) + require.NoError(t, err, "add v6 dnat 1") + v4, v6 := state.Counts() + assert.Equal(t, 0, v4) + assert.Equal(t, 1, v6, "v6 refcount after first add") + + r2, err := m.AddDNATRule(iptDnatV6(9082)) + require.NoError(t, err, "add v6 dnat 2") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4, "v4 refcount unchanged") + assert.Equal(t, 2, v6, "v6 refcount after second add") + + require.NoError(t, m.DeleteDNATRule(r1)) + v4, v6 = state.Counts() + assert.Equal(t, 0, v4, "v4 refcount unchanged") + assert.Equal(t, 1, v6, "v6 refcount after first delete") + + require.NoError(t, m.DeleteDNATRule(r2)) + v4, v6 = state.Counts() + assert.Equal(t, 0, v4) + assert.Equal(t, 0, v6, "v6 refcount after second delete") +} + +// TestIptablesDNAT_DuplicateAddNoLeak verifies the duplicate-rule path returns +// without bumping the refcount. +func TestIptablesDNAT_DuplicateAddNoLeak(t *testing.T) { + m := newIptRefcountManager(t, true) + state := m.router.ipFwdState + + rule := iptDnatV4(7083) + r1, err := m.AddDNATRule(rule) + require.NoError(t, err) + v4, _ := state.Counts() + assert.Equal(t, 1, v4) + + _, err = m.AddDNATRule(rule) + require.NoError(t, err, "duplicate add") + v4, _ = state.Counts() + assert.Equal(t, 1, v4, "duplicate add must not increment") + + require.NoError(t, m.DeleteDNATRule(r1)) + v4, _ = state.Counts() + assert.Equal(t, 0, v4, "single delete must drop to zero") +} + +// TestIptablesDNAT_DeleteMissingNoUnderflow verifies Delete on an unknown rule +// neither errors nor releases the refcount. +func TestIptablesDNAT_DeleteMissingNoUnderflow(t *testing.T) { + m := newIptRefcountManager(t, true) + state := m.router.ipFwdState + + phantom := iptDnatV4(7099) + require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4") + v4, v6 := state.Counts() + assert.Equal(t, 0, v4) + assert.Equal(t, 0, v6) + + phantom6 := iptDnatV6(9099) + require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4) + assert.Equal(t, 0, v6) + + r1, err := m.AddDNATRule(iptDnatV4(7100)) + require.NoError(t, err) + v4, _ = state.Counts() + assert.Equal(t, 1, v4, "real add still increments after phantom delete") + require.NoError(t, m.DeleteDNATRule(r1)) +} + +// TestIptablesDNAT_DoubleDeleteNoUnderflow verifies a second Delete on the same +// rule is a no-op. +func TestIptablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) { + m := newIptRefcountManager(t, true) + state := m.router.ipFwdState + + r1, err := m.AddDNATRule(iptDnatV6(9083)) + require.NoError(t, err) + _, v6 := state.Counts() + assert.Equal(t, 1, v6) + + require.NoError(t, m.DeleteDNATRule(r1), "first delete") + _, v6 = state.Counts() + assert.Equal(t, 0, v6) + + require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op") + _, v6 = state.Counts() + assert.Equal(t, 0, v6, "double delete must not underflow") +} diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go index 696537dd8..aa052d933 100644 --- a/client/firewall/iptables/manager_linux.go +++ b/client/firewall/iptables/manager_linux.go @@ -89,7 +89,7 @@ func (m *Manager) createIPv6Components(wgIface iFaceMapper, mtu uint16) error { } // Share the same IP forwarding state with the v4 router, since - // EnableIPForwarding controls both v4 and v6 sysctls. + // Forwarding refcounter is per-family but shared between v4 and v6 routers. m.router6.ipFwdState = m.router.ipFwdState m.aclMgr6, err = newAclManager(ip6Client, wgIface) @@ -402,17 +402,12 @@ func (m *Manager) SetLogLevel(log.Level) { } func (m *Manager) EnableRouting() error { - if err := m.router.ipFwdState.RequestForwarding(); err != nil { - return fmt.Errorf("enable IP forwarding: %w", err) - } - return nil + // v6 only when the overlay actually has v6. + return m.router.ipFwdState.RequestRouting(m.router6 != nil) } func (m *Manager) DisableRouting() error { - if err := m.router.ipFwdState.ReleaseForwarding(); err != nil { - return fmt.Errorf("disable IP forwarding: %w", err) - } - return nil + return m.router.ipFwdState.ReleaseRouting() } // AddDNATRule adds a DNAT rule diff --git a/client/firewall/iptables/manager_linux_test.go b/client/firewall/iptables/manager_linux_test.go index cc4bda0e0..2c3c1a08e 100644 --- a/client/firewall/iptables/manager_linux_test.go +++ b/client/firewall/iptables/manager_linux_test.go @@ -1,3 +1,5 @@ +//go:build privileged + package iptables import ( @@ -289,3 +291,40 @@ func TestIptablesCreatePerformance(t *testing.T) { }) } } + +// TestIptablesACLIPSetFallback verifies that when the kernel lacks ipset support, +// the ACL manager falls back to per-IP iptables rules (-s ) instead of +// silently leaving the chain empty. See discussion #6125. +func TestIptablesACLIPSetFallback(t *testing.T) { + ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) + require.NoError(t, err) + + // Use Create()/Init() so the router-owned chains (chainRTFWDIN/OUT) are + // created before the ACL manager's createDefaultChains() references them. + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + + aclMgr := manager.aclMgr + // Simulate a kernel without the ipset hash module. + aclMgr.ipsetSupported = false + + defer func() { + require.NoError(t, manager.Close(nil)) + }() + + ip := netip.MustParseAddr("10.20.0.42") + port := &fw.Port{Values: []uint16{22}} + + rules, err := aclMgr.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionAccept, "nb0000001") + require.NoError(t, err, "AddPeerFiltering should succeed via fallback") + require.NotEmpty(t, rules) + + rule := rules[0].(*Rule) + require.Empty(t, rule.ipsetName, "fallback rule must not reference an ipset") + require.Contains(t, strings.Join(rule.specs, " "), "-s 10.20.0.42", "fallback rule must match by source IP") + require.NotContains(t, strings.Join(rule.specs, " "), "--match-set", "fallback rule must not use ipset matching") + + // The rule must actually be present in the ACL chain (not silently dropped). + checkRuleSpecs(t, ipv4Client, rule.chain, true, rule.specs...) +} diff --git a/client/firewall/iptables/router_linux.go b/client/firewall/iptables/router_linux.go index 42d305f5c..01b18570c 100644 --- a/client/firewall/iptables/router_linux.go +++ b/client/firewall/iptables/router_linux.go @@ -102,7 +102,7 @@ func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint1 wgIface: wgIface, mtu: mtu, v6: iptablesClient.Proto() == iptables.ProtocolIPv6, - ipFwdState: ipfwdstate.NewIPForwardingState(), + ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()), } r.ipsetCounter = refcounter.New( @@ -770,10 +770,6 @@ func (r *router) updateState() { } func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - if err := r.ipFwdState.RequestForwarding(); err != nil { - return nil, err - } - ruleKey := rule.ID() if _, exists := r.rules[ruleKey+dnatSuffix]; exists { return rule, nil @@ -840,18 +836,34 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { for key, ruleInfo := range rules { if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { - if rollbackErr := r.rollbackRules(rules); rollbackErr != nil { - log.Errorf("rollback failed: %v", rollbackErr) - } + r.cleanupFailedDNATAdd(rules) return nil, fmt.Errorf("add rule %s: %w", key, err) } r.rules[key] = ruleInfo.rule } + if err := r.ipFwdState.RequestForwarding(r.v6); err != nil { + r.cleanupFailedDNATAdd(rules) + return nil, fmt.Errorf("enable forwarding: %w", err) + } + r.updateState() return rule, nil } +// cleanupFailedDNATAdd removes the bookkeeping written by a partially applied +// AddDNATRule before rolling back the kernel rules, so no entries remain that +// never got a forwarding refcount. rollbackRules re-adds entries it failed to +// remove from the kernel. +func (r *router) cleanupFailedDNATAdd(rules map[string]ruleInfo) { + for key := range rules { + delete(r.rules, key) + } + if err := r.rollbackRules(rules); err != nil { + log.Errorf("rollback failed: %v", err) + } +} + func (r *router) rollbackRules(rules map[string]ruleInfo) error { var merr *multierror.Error for key, ruleInfo := range rules { @@ -868,32 +880,47 @@ func (r *router) rollbackRules(rules map[string]ruleInfo) error { } func (r *router) DeleteDNATRule(rule firewall.Rule) error { - if err := r.ipFwdState.ReleaseForwarding(); err != nil { - log.Errorf("%v", err) - } - ruleKey := rule.ID() + _, hadDNAT := r.rules[ruleKey+dnatSuffix] + _, hadSNAT := r.rules[ruleKey+snatSuffix] + _, hadFWD := r.rules[ruleKey+fwdSuffix] + if !hadDNAT && !hadSNAT && !hadFWD { + return nil + } + var merr *multierror.Error if dnatRule, exists := r.rules[ruleKey+dnatSuffix]; exists { if err := r.iptablesClient.Delete(tableNat, chainRTRDR, dnatRule...); err != nil { merr = multierror.Append(merr, fmt.Errorf("delete DNAT rule: %w", err)) + } else { + delete(r.rules, ruleKey+dnatSuffix) } - delete(r.rules, ruleKey+dnatSuffix) } if snatRule, exists := r.rules[ruleKey+snatSuffix]; exists { if err := r.iptablesClient.Delete(tableNat, chainRTNAT, snatRule...); err != nil { merr = multierror.Append(merr, fmt.Errorf("delete SNAT rule: %w", err)) + } else { + delete(r.rules, ruleKey+snatSuffix) } - delete(r.rules, ruleKey+snatSuffix) } if fwdRule, exists := r.rules[ruleKey+fwdSuffix]; exists { if err := r.iptablesClient.Delete(tableFilter, chainRTFWDOUT, fwdRule...); err != nil { merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err)) + } else { + delete(r.rules, ruleKey+fwdSuffix) + } + } + + // Release the refcount only once all rules are gone from the kernel. On + // partial failure the failed entries stay in r.rules so a retry can remove + // them and release then. + if merr == nil { + if err := r.ipFwdState.ReleaseForwarding(r.v6); err != nil { + log.Errorf("%v", err) } - delete(r.rules, ruleKey+fwdSuffix) } r.updateState() diff --git a/client/firewall/iptables/router_linux_test.go b/client/firewall/iptables/router_linux_test.go index 6707573be..9ca6b9f7e 100644 --- a/client/firewall/iptables/router_linux_test.go +++ b/client/firewall/iptables/router_linux_test.go @@ -1,4 +1,4 @@ -//go:build !android +//go:build !android && privileged package iptables diff --git a/client/firewall/nftables/dnat_refcount_linux_test.go b/client/firewall/nftables/dnat_refcount_linux_test.go new file mode 100644 index 000000000..86079676f --- /dev/null +++ b/client/firewall/nftables/dnat_refcount_linux_test.go @@ -0,0 +1,249 @@ +//go:build privileged + +package nftables + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +func nftRefcountIfaceV4() *iFaceMock { + return &iFaceMock{ + NameFunc: func() string { return "wt-refcount" }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("100.96.0.1"), + Network: netip.MustParsePrefix("100.96.0.0/16"), + } + }, + } +} + +func nftRefcountIfaceDual() *iFaceMock { + return &iFaceMock{ + NameFunc: func() string { return "wt-refcount" }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("100.96.0.1"), + Network: netip.MustParsePrefix("100.96.0.0/16"), + IPv6: netip.MustParseAddr("fd00::1"), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + } + }, + } +} + +func newNftRefcountManager(t *testing.T, dual bool) *Manager { + t.Helper() + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + var ifMock *iFaceMock + if dual { + ifMock = nftRefcountIfaceDual() + } else { + ifMock = nftRefcountIfaceV4() + } + m, err := Create(ifMock, iface.DefaultMTU) + require.NoError(t, err, "create manager") + require.NoError(t, m.Init(nil), "init manager") + t.Cleanup(func() { + require.NoError(t, m.Close(nil), "close manager") + }) + return m +} + +func dnatV4(port uint16) fw.ForwardRule { + return fw.ForwardRule{ + Protocol: fw.ProtocolTCP, + DestinationPort: fw.Port{Values: []uint16{port}}, + TranslatedAddress: netip.MustParseAddr("100.96.0.2"), + TranslatedPort: fw.Port{Values: []uint16{80}}, + } +} + +func dnatV6(port uint16) fw.ForwardRule { + return fw.ForwardRule{ + Protocol: fw.ProtocolTCP, + DestinationPort: fw.Port{Values: []uint16{port}}, + TranslatedAddress: netip.MustParseAddr("fd00::2"), + TranslatedPort: fw.Port{Values: []uint16{80}}, + } +} + +// TestNftablesDNAT_RefcountBalancedV4 verifies that Add/Delete pairs leave the +// v4 refcount at zero. +func TestNftablesDNAT_RefcountBalancedV4(t *testing.T) { + m := newNftRefcountManager(t, false) + state := m.router.ipFwdState + + r1, err := m.AddDNATRule(dnatV4(8081)) + require.NoError(t, err, "add v4 dnat 1") + v4, v6 := state.Counts() + assert.Equal(t, 1, v4, "v4 refcount after first add") + assert.Equal(t, 0, v6, "v6 refcount unchanged") + + r2, err := m.AddDNATRule(dnatV4(8082)) + require.NoError(t, err, "add v4 dnat 2") + v4, v6 = state.Counts() + assert.Equal(t, 2, v4, "v4 refcount after second add") + assert.Equal(t, 0, v6, "v6 refcount unchanged") + + require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat 1") + v4, v6 = state.Counts() + assert.Equal(t, 1, v4, "v4 refcount after first delete") + assert.Equal(t, 0, v6, "v6 refcount unchanged") + + require.NoError(t, m.DeleteDNATRule(r2), "delete v4 dnat 2") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4, "v4 refcount after second delete") + assert.Equal(t, 0, v6, "v6 refcount unchanged") +} + +// TestNftablesDNAT_RefcountBalancedV6 verifies the v6 path increments v6 only +// and decrements back to zero on Delete. +func TestNftablesDNAT_RefcountBalancedV6(t *testing.T) { + m := newNftRefcountManager(t, true) + require.NotNil(t, m.router6, "v6 router") + require.Same(t, m.router.ipFwdState, m.router6.ipFwdState, "shared state") + state := m.router.ipFwdState + + r1, err := m.AddDNATRule(dnatV6(9091)) + require.NoError(t, err, "add v6 dnat 1") + v4, v6 := state.Counts() + assert.Equal(t, 0, v4, "v4 refcount unchanged") + assert.Equal(t, 1, v6, "v6 refcount after first add") + + r2, err := m.AddDNATRule(dnatV6(9092)) + require.NoError(t, err, "add v6 dnat 2") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4) + assert.Equal(t, 2, v6, "v6 refcount after second add") + + require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat 1") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4, "v4 refcount unchanged") + assert.Equal(t, 1, v6, "v6 refcount after first delete") + + require.NoError(t, m.DeleteDNATRule(r2), "delete v6 dnat 2") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4) + assert.Equal(t, 0, v6, "v6 refcount after second delete") +} + +// TestNftablesDNAT_DuplicateAddNoLeak verifies that a duplicate Add (same +// ForwardRule) does not double-increment the refcount. +func TestNftablesDNAT_DuplicateAddNoLeak(t *testing.T) { + m := newNftRefcountManager(t, true) + state := m.router.ipFwdState + + rule := dnatV4(8083) + r1, err := m.AddDNATRule(rule) + require.NoError(t, err, "add v4 dnat") + v4, _ := state.Counts() + assert.Equal(t, 1, v4) + + // duplicate add: same rule ID, must be a no-op for the refcount. + _, err = m.AddDNATRule(rule) + require.NoError(t, err, "duplicate add") + v4, _ = state.Counts() + assert.Equal(t, 1, v4, "duplicate add must not increment") + + require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat") + v4, _ = state.Counts() + assert.Equal(t, 0, v4, "single delete must drop to zero") +} + +// TestNftablesDNAT_DeleteMissingNoUnderflow verifies deleting a rule that was +// never added does not underflow the refcount. +func TestNftablesDNAT_DeleteMissingNoUnderflow(t *testing.T) { + m := newNftRefcountManager(t, true) + state := m.router.ipFwdState + + // Construct a Rule reference for something never added. The router stores + // rules by ID(), and DeleteDNATRule looks them up in r.rules; a missing + // entry must be a no-op rather than calling Release. + phantom := dnatV4(8099) + require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4 dnat") + v4, v6 := state.Counts() + assert.Equal(t, 0, v4, "v4 refcount unaffected by missing delete") + assert.Equal(t, 0, v6, "v6 refcount unaffected") + + phantom6 := dnatV6(9099) + require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6 dnat") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4) + assert.Equal(t, 0, v6, "v6 refcount unaffected by missing delete") + + // And after a phantom delete, a real add still results in count=1. + r1, err := m.AddDNATRule(dnatV4(8100)) + require.NoError(t, err, "add v4 dnat after phantom delete") + v4, _ = state.Counts() + assert.Equal(t, 1, v4, "real add still increments after phantom delete") + require.NoError(t, m.DeleteDNATRule(r1)) +} + +// TestNftablesRouting_RepeatedEnableSingleReference verifies that EnableRouting +// (called on every network-map update) holds at most one reference per family +// and a single DisableRouting drops both back to zero. +func TestNftablesRouting_RepeatedEnableSingleReference(t *testing.T) { + m := newNftRefcountManager(t, true) + state := m.router.ipFwdState + + require.NoError(t, m.EnableRouting(), "first enable") + require.NoError(t, m.EnableRouting(), "second enable") + require.NoError(t, m.EnableRouting(), "third enable") + v4, v6 := state.Counts() + assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference") + assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference") + + require.NoError(t, m.DisableRouting(), "disable") + v4, v6 = state.Counts() + assert.Equal(t, 0, v4, "single disable releases the v4 reference") + assert.Equal(t, 0, v6, "single disable releases the v6 reference") +} + +// TestNftablesRouting_DisableKeepsDNATReference verifies that an unpaired +// DisableRouting does not release references held by active DNAT rules. +func TestNftablesRouting_DisableKeepsDNATReference(t *testing.T) { + m := newNftRefcountManager(t, true) + state := m.router.ipFwdState + + r1, err := m.AddDNATRule(dnatV6(9095)) + require.NoError(t, err, "add v6 dnat") + + require.NoError(t, m.DisableRouting(), "unpaired disable") + _, v6 := state.Counts() + assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting") + + require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat") + _, v6 = state.Counts() + assert.Equal(t, 0, v6, "delete releases the DNAT reference") +} + +// TestNftablesDNAT_DoubleDeleteNoUnderflow verifies that deleting the same rule +// twice does not underflow the refcount (the second delete is a no-op). +func TestNftablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) { + m := newNftRefcountManager(t, true) + state := m.router.ipFwdState + + r1, err := m.AddDNATRule(dnatV6(9093)) + require.NoError(t, err) + _, v6 := state.Counts() + assert.Equal(t, 1, v6) + + require.NoError(t, m.DeleteDNATRule(r1), "first delete") + _, v6 = state.Counts() + assert.Equal(t, 0, v6) + + require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op") + _, v6 = state.Counts() + assert.Equal(t, 0, v6, "double delete must not underflow") +} diff --git a/client/firewall/nftables/legacy_rule_linux_test.go b/client/firewall/nftables/legacy_rule_linux_test.go new file mode 100644 index 000000000..dc2f1c7a0 --- /dev/null +++ b/client/firewall/nftables/legacy_rule_linux_test.go @@ -0,0 +1,60 @@ +package nftables + +import ( + "testing" + + "github.com/google/nftables/expr" + "github.com/stretchr/testify/require" +) + +func TestBuildLegacyRouteRuleExpressions(t *testing.T) { + sourcePayload := &expr.Payload{} + sourceCmp := &expr.Cmp{} + destinationPayload := &expr.Payload{} + destinationCmp := &expr.Cmp{} + nilSourceDestination := &expr.Payload{} + nilDestinationSource := &expr.Cmp{} + + tests := []struct { + name string + source []expr.Any + destination []expr.Any + matches []expr.Any + }{ + { + name: "both non-empty", + source: []expr.Any{sourcePayload, sourceCmp}, + destination: []expr.Any{destinationPayload, destinationCmp}, + matches: []expr.Any{sourcePayload, sourceCmp, destinationPayload, destinationCmp}, + }, + { + name: "nil source", + destination: []expr.Any{nilSourceDestination}, + matches: []expr.Any{nilSourceDestination}, + }, + { + name: "nil destination", + source: []expr.Any{nilDestinationSource}, + matches: []expr.Any{nilDestinationSource}, + }, + { + name: "both nil", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildLegacyRouteRuleExpressions(tt.source, tt.destination) + + require.Len(t, got, len(tt.matches)+2) + for i, match := range tt.matches { + require.Same(t, match, got[i]) + } + + require.IsType(t, &expr.Counter{}, got[len(tt.matches)]) + verdict, ok := got[len(tt.matches)+1].(*expr.Verdict) + require.True(t, ok) + require.Equal(t, expr.VerdictAccept, verdict.Kind) + }) + } +} diff --git a/client/firewall/nftables/manager_linux.go b/client/firewall/nftables/manager_linux.go index fdc7c2f3c..984b1c3ba 100644 --- a/client/firewall/nftables/manager_linux.go +++ b/client/firewall/nftables/manager_linux.go @@ -105,8 +105,8 @@ func (m *Manager) createIPv6Components(tableName string, wgIface iFaceMapper, mt return fmt.Errorf("create v6 router: %w", err) } - // Share the same IP forwarding state with the v4 router, since - // EnableIPForwarding controls both v4 and v6 sysctls. + // Share the per-family forwarding refcounter with the v4 router so a v4 + // rule and a v6 rule against the same state machine cooperate cleanly. m.router6.ipFwdState = m.router.ipFwdState m.aclManager6, err = newAclManager(workTable6, wgIface, chainNameRoutingFw) @@ -530,17 +530,12 @@ func (m *Manager) SetLogLevel(log.Level) { } func (m *Manager) EnableRouting() error { - if err := m.router.ipFwdState.RequestForwarding(); err != nil { - return fmt.Errorf("enable IP forwarding: %w", err) - } - return nil + // v6 only when the overlay actually has v6. + return m.router.ipFwdState.RequestRouting(m.router6 != nil) } func (m *Manager) DisableRouting() error { - if err := m.router.ipFwdState.ReleaseForwarding(); err != nil { - return fmt.Errorf("disable IP forwarding: %w", err) - } - return nil + return m.router.ipFwdState.ReleaseRouting() } // Flush rule/chain/set operations from the buffer diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go index be4f65881..4eb466281 100644 --- a/client/firewall/nftables/manager_linux_test.go +++ b/client/firewall/nftables/manager_linux_test.go @@ -1,3 +1,5 @@ +//go:build privileged + package nftables import ( diff --git a/client/firewall/nftables/router_linux.go b/client/firewall/nftables/router_linux.go index 4214455a9..d3e031c5f 100644 --- a/client/firewall/nftables/router_linux.go +++ b/client/firewall/nftables/router_linux.go @@ -93,7 +93,7 @@ func newRouter(workTable *nftables.Table, wgIface iFaceMapper, mtu uint16) (*rou rules: make(map[string]*nftables.Rule), af: familyForAddr(workTable.Family == nftables.TableFamilyIPv4), wgIface: wgIface, - ipFwdState: ipfwdstate.NewIPForwardingState(), + ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()), mtu: mtu, } @@ -953,6 +953,17 @@ func (r *router) addMSSClampingRules() error { return r.conn.Flush() } +func buildLegacyRouteRuleExpressions(sourceExp, destExp []expr.Any) []expr.Any { + exprs := make([]expr.Any, 0, len(sourceExp)+len(destExp)+2) + exprs = append(exprs, sourceExp...) + exprs = append(exprs, destExp...) + exprs = append(exprs, + &expr.Counter{}, + &expr.Verdict{Kind: expr.VerdictAccept}, + ) + return exprs +} + // addLegacyRouteRule adds a legacy routing rule for mgmt servers pre route acls func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error { sourceExp, err := r.applyNetwork(pair.Source, nil, true) @@ -965,15 +976,7 @@ func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error { return fmt.Errorf("apply destination: %w", err) } - exprs := []expr.Any{ - &expr.Counter{}, - &expr.Verdict{ - Kind: expr.VerdictAccept, - }, - } - - exprs = append(exprs, sourceExp...) - exprs = append(exprs, destExp...) + exprs := buildLegacyRouteRuleExpressions(sourceExp, destExp) ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair) @@ -1550,10 +1553,6 @@ func (r *router) refreshRulesMap() error { } func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - if err := r.ipFwdState.RequestForwarding(); err != nil { - return nil, err - } - ruleKey := rule.ID() if _, exists := r.rules[ruleKey+dnatSuffix]; exists { return rule, nil @@ -1564,7 +1563,18 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { return nil, fmt.Errorf("convert protocol to number: %w", err) } + // Request forwarding before queueing rules: addDnatRedirect/addDnatMasq + // buffer netlink messages on r.conn that the next caller's Flush would + // commit if we returned without flushing them ourselves. + v6 := r.af.tableFamily == nftables.TableFamilyIPv6 + if err := r.ipFwdState.RequestForwarding(v6); err != nil { + return nil, fmt.Errorf("enable forwarding: %w", err) + } + if err := r.addDnatRedirect(rule, protoNum, ruleKey); err != nil { + if rerr := r.ipFwdState.ReleaseForwarding(v6); rerr != nil { + log.Warnf("rollback forwarding refcount: %v", rerr) + } return nil, err } @@ -1576,6 +1586,11 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { // TODO: find chains with drop policies and add rules there if err := r.conn.Flush(); err != nil { + if rerr := r.ipFwdState.ReleaseForwarding(v6); rerr != nil { + log.Warnf("rollback forwarding refcount: %v", rerr) + } + delete(r.rules, ruleKey+dnatSuffix) + delete(r.rules, ruleKey+snatSuffix) return nil, fmt.Errorf("flush rules: %w", err) } @@ -1778,16 +1793,18 @@ func (r *router) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleKey } func (r *router) DeleteDNATRule(rule firewall.Rule) error { - if err := r.ipFwdState.ReleaseForwarding(); err != nil { - log.Errorf("%v", err) - } - ruleKey := rule.ID() if err := r.refreshRulesMap(); err != nil { return fmt.Errorf(refreshRulesMapError, err) } + _, hadDNAT := r.rules[ruleKey+dnatSuffix] + _, hadSNAT := r.rules[ruleKey+snatSuffix] + if !hadDNAT && !hadSNAT { + return nil + } + var merr *multierror.Error var needsFlush bool @@ -1819,9 +1836,16 @@ func (r *router) DeleteDNATRule(rule firewall.Rule) error { } } + // Release the refcount only once the rules are gone from the kernel. On + // failure (including the refreshRulesMap error above) the rules and their + // map entries remain, keeping forwarding on until a retry removes them. if merr == nil { delete(r.rules, ruleKey+dnatSuffix) delete(r.rules, ruleKey+snatSuffix) + + if err := r.ipFwdState.ReleaseForwarding(r.af.tableFamily == nftables.TableFamilyIPv6); err != nil { + log.Errorf("%v", err) + } } return nberrors.FormatErrorOrNil(merr) diff --git a/client/firewall/nftables/router_linux_test.go b/client/firewall/nftables/router_linux_test.go index c5d6729d9..2fc664d51 100644 --- a/client/firewall/nftables/router_linux_test.go +++ b/client/firewall/nftables/router_linux_test.go @@ -1,4 +1,4 @@ -//go:build !android +//go:build !android && privileged package nftables diff --git a/client/firewall/uspfilter/filter.go b/client/firewall/uspfilter/filter.go index 91866dcab..7376e59ca 100644 --- a/client/firewall/uspfilter/filter.go +++ b/client/firewall/uspfilter/filter.go @@ -121,6 +121,7 @@ type Manager struct { udpTracker *conntrack.UDPTracker icmpTracker *conntrack.ICMPTracker tcpTracker *conntrack.TCPTracker + fragments *fragmentTracker forwarder atomic.Pointer[forwarder.Forwarder] pendingCapture atomic.Pointer[forwarder.PacketCapture] logger *nblog.Logger @@ -183,6 +184,41 @@ func (d *decoder) decodePacket(data []byte) error { } } +// decodeTransport decodes the transport header of a first fragment (which +// gopacket leaves undecoded) into the decoder and appends its layer type to +// decoded, so the ACL pipeline can evaluate it like a normal packet. It returns +// false if the protocol is unsupported or the header is truncated. +func (d *decoder) decodeTransport(proto layers.IPProtocol, payload []byte) bool { + var l4 gopacket.DecodingLayer + var layerType gopacket.LayerType + var minLen int + switch proto { + case layers.IPProtocolTCP: + l4, layerType, minLen = &d.tcp, layers.LayerTypeTCP, 20 + case layers.IPProtocolUDP: + l4, layerType, minLen = &d.udp, layers.LayerTypeUDP, 8 + case layers.IPProtocolICMPv4: + l4, layerType, minLen = &d.icmp4, layers.LayerTypeICMPv4, 8 + case layers.IPProtocolICMPv6: + l4, layerType, minLen = &d.icmp6, layers.LayerTypeICMPv6, 8 + default: + return false + } + + // Reject a fragment too small to hold the full transport header before + // decoding: it can't be ACL-evaluated (tiny-fragment attack), and skipping + // the decode avoids gopacket allocating an error on the drop path. + if len(payload) < minLen { + return false + } + + if err := l4.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil { + return false + } + d.decoded = append(d.decoded, layerType) + return true +} + // Create userspace firewall manager constructor func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) { return create(iface, nil, disableServerRoutes, flowLogger, mtu) @@ -286,6 +322,8 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe if err := m.localipmanager.UpdateLocalIPs(iface); err != nil { return nil, fmt.Errorf("update local IPs: %w", err) } + m.fragments = newFragmentTracker(m.logger) + if disableConntrack { log.Info("conntrack is disabled") } else { @@ -299,6 +337,7 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe } } if err := iface.SetFilter(m); err != nil { + m.fragments.Close() return nil, fmt.Errorf("set filter: %w", err) } return m, nil @@ -694,6 +733,10 @@ func (m *Manager) resetState() { m.tcpTracker.Close() } + if m.fragments != nil { + m.fragments.Close() + } + if fwder := m.forwarder.Load(); fwder != nil { fwder.SetCapture(nil) fwder.Stop() @@ -1046,19 +1089,20 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool { return true } - // TODO: pass fragments of routed packets to forwarder + // gopacket does not decode the transport header of any IP fragment, so + // fragments take a dedicated path: the first fragment's header is decoded + // and ACL-evaluated here, and the remaining fragments inherit its verdict. if fragment { - if m.logger.Enabled(nblog.LevelTrace) { - if d.decoded[0] == layers.LayerTypeIPv4 { - m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v", - srcIP, dstIP, d.ip4.Id, d.ip4.Flags) - } else { - m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP) - } - } - return false + return m.filterInboundFragment(d, srcIP, dstIP, size) } + return m.filterInboundDecoded(d, srcIP, dstIP, packetData, size) +} + +// filterInboundDecoded runs the ACL, DNAT and conntrack pipeline on a fully +// decoded (non-fragment) inbound packet. It returns true if the packet should +// be dropped. +func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool { // TODO: optimize port DNAT by caching matched rules in conntrack if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated { // Re-decode after port DNAT translation to update port information @@ -1089,33 +1133,226 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool { return m.handleRoutedTraffic(d, srcIP, dstIP, packetData, size) } +// fragmentMeta holds the reassembly identity and layout of an IP fragment, +// extracted uniformly for IPv4 and IPv6. +type fragmentMeta struct { + key fragmentKey + // offset is the fragment offset in 8-byte units (zero for the first + // fragment). + offset uint16 + // moreFragments is the More Fragments bit. A first fragment with it unset is + // an IPv6 atomic fragment (a complete datagram, RFC 6946): it has no trailing + // fragments to inherit a verdict, so it must not be recorded. + moreFragments bool + proto layers.IPProtocol + // l4payload is the fragmentable payload of this fragment. For the first + // fragment it starts with the transport header. + l4payload []byte + // headerEndOctets is the first fragment's payload length in 8-byte units: + // the smallest offset a trailing fragment may start at without overlapping + // the inspected transport header. + headerEndOctets uint16 +} + +// fragmentMetadata extracts the fragment identity and layout from a decoded IP +// fragment. It returns false for fragments it can't interpret (e.g. an IPv6 +// fragment header shorter than 8 bytes), which are then dropped. +func fragmentMetadata(d *decoder, srcIP, dstIP netip.Addr) (fragmentMeta, bool) { + switch d.decoded[0] { + case layers.LayerTypeIPv4: + payload := d.ip4.Payload + return fragmentMeta{ + key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: uint32(d.ip4.Id), proto: uint8(d.ip4.Protocol)}, + offset: d.ip4.FragOffset, + moreFragments: d.ip4.Flags&layers.IPv4MoreFragments != 0, + proto: d.ip4.Protocol, + l4payload: payload, + headerEndOctets: octets(len(payload)), + }, true + + case layers.LayerTypeIPv6: + // IPv6 fragment extension header: 8 bytes, followed by the fragmentable + // payload. Layout: next header (1), reserved (1), offset+flags (2), id (4). + payload := d.ip6.Payload + if len(payload) < 8 { + return fragmentMeta{}, false + } + nextHeader := layers.IPProtocol(payload[0]) + offsetFlags := binary.BigEndian.Uint16(payload[2:4]) + id := binary.BigEndian.Uint32(payload[4:8]) + l4 := payload[8:] + return fragmentMeta{ + key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: id, proto: uint8(nextHeader)}, + offset: offsetFlags >> 3, + moreFragments: offsetFlags&1 != 0, + proto: nextHeader, + l4payload: l4, + headerEndOctets: octets(len(l4)), + }, true + + default: + return fragmentMeta{}, false + } +} + +// octets rounds a byte length up to whole 8-byte units, the granularity of the +// IP fragment offset field. +func octets(nbytes int) uint16 { + return uint16((nbytes + 7) / 8) +} + +// filterInboundFragment decides the fate of an IP fragment. gopacket stops +// decoding at the network layer for every fragment, so the first fragment's +// transport header is decoded and ACL-evaluated here and its verdict recorded; +// the remaining (headerless) fragments inherit that verdict. Anything that +// cannot be tied to an allowed, non-overlapping first fragment is dropped. +func (m *Manager) filterInboundFragment(d *decoder, srcIP, dstIP netip.Addr, size int) bool { + meta, ok := fragmentMetadata(d, srcIP, dstIP) + if !ok { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("dropping unsupported fragment: src=%v dst=%v", srcIP, dstIP) + } + return true + } + + if meta.offset != 0 { + return m.filterTrailingFragment(meta, srcIP, dstIP) + } + + // A new first fragment supersedes any recorded verdict for this datagram, so + // a re-sent or overlapping offset-zero fragment can't inherit the old one. + m.fragments.poison(meta.key) + + // First fragment: decode its transport header so the ACL can evaluate it. A + // decode failure means the fragment is too small to hold the full transport + // header (RFC 1858 §3 tiny-fragment attack); it can't be evaluated, so drop it. + if !d.decodeTransport(meta.proto, meta.l4payload) { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping first fragment without full L4 header: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + } + + return m.filterFirstFragment(d, meta, srcIP, dstIP, size) +} + +// filterTrailingFragment applies a recorded first-fragment verdict to a +// non-first fragment. +func (m *Manager) filterTrailingFragment(meta fragmentMeta, srcIP, dstIP netip.Addr) bool { + switch m.fragments.verdict(meta.key, meta.offset) { + case fragmentAllow: + return false + case fragmentOverlap: + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping overlapping fragment rewriting inspected header: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + default: + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace3("dropping fragment with no allowed first fragment: src=%v dst=%v id=%v", + srcIP, dstIP, meta.key.id) + } + return true + } +} + +// filterFirstFragment runs the verdict part of the inbound pipeline on a first +// fragment with its transport header decoded. It mirrors filterInboundDecoded +// but skips DNAT (port rewriting on fragments is unsupported) and forwarder +// injection (fragments are left to the stack to reassemble, not forwarded). +// Allowed fragments have their verdict recorded so the datagram's trailing +// fragments inherit it. +func (m *Manager) filterFirstFragment(d *decoder, meta fragmentMeta, srcIP, dstIP netip.Addr, size int) bool { + if m.stateful && m.isValidTrackedConnection(d, srcIP, dstIP, size) { + m.recordFirstFragment(meta) + return false + } + + if m.localipmanager.IsLocalIP(dstIP) { + ruleID, blocked := m.peerACLsBlock(srcIP, d, nil) + if blocked { + m.storeDropFlow("Dropping local first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) + return true + } + m.trackInbound(d, srcIP, dstIP, ruleID, size) + m.recordFirstFragment(meta) + return false + } + + if !m.routingEnabled.Load() { + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace2("Dropping routed fragment (routing disabled): src=%s dst=%s", srcIP, dstIP) + } + return true + } + if m.nativeRouter.Load() { + m.trackInbound(d, srcIP, dstIP, nil, size) + m.recordFirstFragment(meta) + return false + } + + // TODO: pass fragments of routed packets to the forwarder; until then + // allowed routed fragments go to the native stack. + srcPort, dstPort := getPortsFromPacket(d) + ruleID, pass := m.routeACLsPass(srcIP, dstIP, d.decoded[1], srcPort, dstPort) + if !pass { + m.storeDropFlow("Dropping routed first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) + return true + } + + m.recordFirstFragment(meta) + return false +} + +// recordFirstFragment caches an allowed first fragment's verdict for its +// trailing fragments to inherit. Atomic fragments (no More Fragments bit) are +// complete datagrams with no trailing fragments, so they are not cached and +// cannot exhaust the verdict table. +func (m *Manager) recordFirstFragment(meta fragmentMeta) { + if !meta.moreFragments { + return + } + m.fragments.recordAllowed(meta.key, meta.headerEndOctets) +} + +// storeDropFlow logs and records a netflow drop event for an inbound packet +// denied by the ACLs. msg is the trace format taking rule id, protocol, source +// and destination. +func (m *Manager) storeDropFlow(msg string, d *decoder, srcIP, dstIP netip.Addr, ruleID []byte, size int) { + pnum := getProtocolFromPacket(d) + srcPort, dstPort := getPortsFromPacket(d) + + if m.logger.Enabled(nblog.LevelTrace) { + m.logger.Trace6(msg, ruleID, pnum, srcIP, srcPort, dstIP, dstPort) + } + + m.flowLogger.StoreEvent(nftypes.EventFields{ + FlowID: uuid.New(), + Type: nftypes.TypeDrop, + RuleID: ruleID, + Direction: nftypes.Ingress, + Protocol: pnum, + SourceIP: srcIP, + DestIP: dstIP, + SourcePort: srcPort, + DestPort: dstPort, + // TODO: icmp type/code + RxPackets: 1, + RxBytes: uint64(size), + }) +} + // handleLocalTraffic handles local traffic. // If it returns true, the packet should be dropped. func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool { ruleID, blocked := m.peerACLsBlock(srcIP, d, packetData) if blocked { - pnum := getProtocolFromPacket(d) - srcPort, dstPort := getPortsFromPacket(d) - - if m.logger.Enabled(nblog.LevelTrace) { - m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", - ruleID, pnum, srcIP, srcPort, dstIP, dstPort) - } - - m.flowLogger.StoreEvent(nftypes.EventFields{ - FlowID: uuid.New(), - Type: nftypes.TypeDrop, - RuleID: ruleID, - Direction: nftypes.Ingress, - Protocol: pnum, - SourceIP: srcIP, - DestIP: dstIP, - SourcePort: srcPort, - DestPort: dstPort, - // TODO: icmp type/code - RxPackets: 1, - RxBytes: uint64(size), - }) + m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) return true } @@ -1168,27 +1405,8 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort) if !pass { - proto := getProtocolFromPacket(d) - - if m.logger.Enabled(nblog.LevelTrace) { - m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", - ruleID, proto, srcIP, srcPort, dstIP, dstPort) - } - - m.flowLogger.StoreEvent(nftypes.EventFields{ - FlowID: uuid.New(), - Type: nftypes.TypeDrop, - RuleID: ruleID, - Direction: nftypes.Ingress, - Protocol: proto, - SourceIP: srcIP, - DestIP: dstIP, - SourcePort: srcPort, - DestPort: dstPort, - // TODO: icmp type/code - RxPackets: 1, - RxBytes: uint64(size), - }) + m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) return true } diff --git a/client/firewall/uspfilter/filter_filter_test.go b/client/firewall/uspfilter/filter_filter_test.go index a64c83138..5ca8538be 100644 --- a/client/firewall/uspfilter/filter_filter_test.go +++ b/client/firewall/uspfilter/filter_filter_test.go @@ -5,7 +5,7 @@ import ( "net/netip" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/stretchr/testify/require" diff --git a/client/firewall/uspfilter/filter_routeacl_test.go b/client/firewall/uspfilter/filter_routeacl_test.go index 449554d8b..b6397d09b 100644 --- a/client/firewall/uspfilter/filter_routeacl_test.go +++ b/client/firewall/uspfilter/filter_routeacl_test.go @@ -4,7 +4,7 @@ import ( "net/netip" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket/layers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/client/firewall/uspfilter/forwarder/forwarder.go b/client/firewall/uspfilter/forwarder/forwarder.go index 6291eb285..28320ad88 100644 --- a/client/firewall/uspfilter/forwarder/forwarder.go +++ b/client/firewall/uspfilter/forwarder/forwarder.go @@ -5,7 +5,9 @@ import ( "fmt" "net" "net/netip" + "os" "runtime" + "strconv" "sync" "time" @@ -31,6 +33,11 @@ const ( defaultMaxInFlight = 1024 iosReceiveWindow = 16384 iosMaxInFlight = 256 + + // envForceTCPRACK overrides the platform default for gVisor's RACK loss + // detection. Set to a truthy value to force RACK on, or a falsy value to + // force it off, on any platform. + envForceTCPRACK = "NB_FORCE_TCP_RACK" ) type Forwarder struct { @@ -152,6 +159,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow maxInFlight = iosMaxInFlight } + configureTCPRecovery(s) + tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP) s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket) @@ -466,3 +475,31 @@ func probeRawICMP(network, addr string, logger *nblog.Logger) bool { logger.Debug1("forwarder: raw %s socket access available", network) return true } + +// configureTCPRecovery disables gVisor's RACK loss detection on Windows, where +// it interacts poorly with the host and collapses throughput on routed TCP +// connections (gVisor issue #9778). Other platforms keep the default. The +// EnvForceTCPRACK environment variable overrides the platform default. +func configureTCPRecovery(s *stack.Stack) { + disableRACK := runtime.GOOS == "windows" + + if val := os.Getenv(envForceTCPRACK); val != "" { + force, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("parse %s: %v", envForceTCPRACK, err) + } else { + disableRACK = !force + } + } + + if !disableRACK { + return + } + + opt := tcpip.TCPRecovery(0) + if err := s.SetTransportProtocolOption(tcp.ProtocolNumber, &opt); err != nil { + log.Warnf("disable TCP RACK loss detection: %v", err) + return + } + log.Info("forwarder: TCP RACK loss detection disabled") +} diff --git a/client/firewall/uspfilter/fragment.go b/client/firewall/uspfilter/fragment.go new file mode 100644 index 000000000..accc54365 --- /dev/null +++ b/client/firewall/uspfilter/fragment.go @@ -0,0 +1,204 @@ +package uspfilter + +import ( + "context" + "net/netip" + "os" + "strconv" + "sync" + "time" + + nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" +) + +const ( + // defaultFragmentTimeout bounds how long a first-fragment verdict is kept + // while the remaining fragments arrive. It mirrors the Linux IP reassembly + // timeout (net.ipv4.ipfrag_time). + defaultFragmentTimeout = 30 * time.Second + // fragmentCleanupInterval is how often expired verdicts are purged. + fragmentCleanupInterval = 10 * time.Second + // defaultMaxFragmentEntries caps the number of concurrently tracked + // fragmented datagrams. The table stays bounded because each datagram is a + // single small entry regardless of how many fragments it is split into, and + // the 13-bit IPv4 fragment-offset field limits any datagram to 64 KiB. + defaultMaxFragmentEntries = 16384 + + // EnvFragmentMaxEntries overrides defaultMaxFragmentEntries. + EnvFragmentMaxEntries = "NB_FRAGMENT_MAX_ENTRIES" +) + +// fragmentVerdict is the decision for a trailing (headerless) fragment. +type fragmentVerdict int + +const ( + // fragmentDeny drops the fragment: no allowed first fragment is on record. + fragmentDeny fragmentVerdict = iota + // fragmentAllow passes the fragment: it belongs to an allowed datagram and + // does not overlap the already-inspected transport header. + fragmentAllow + // fragmentOverlap drops the fragment and poisons its datagram: it overlaps + // the transport header the ACL inspected (RFC 1858 §4, RFC 3128; RFC 5722 + // requires discarding the whole datagram on overlap for IPv6). + fragmentOverlap +) + +// fragmentKey identifies a fragmented datagram. It matches the RFC 791 / RFC +// 8200 reassembly key: source, destination, protocol and identification. The id +// is 32-bit to hold both the IPv4 (16-bit) and IPv6 (32-bit) identification. +type fragmentKey struct { + srcIP netip.Addr + dstIP netip.Addr + id uint32 + proto uint8 +} + +// fragmentEntry records the verdict of an allowed first fragment. +type fragmentEntry struct { + // headerEndOctets is the offset, in 8-byte units, at which the first + // fragment's payload ended. A trailing fragment starting before this + // overlaps bytes the ACL already inspected and is rejected. + headerEndOctets uint16 + // recordedAt is when the first fragment was accepted. The verdict expires a + // fixed timeout later and is not refreshed, mirroring the kernel reassembly + // timer so a trailing-fragment flood can't keep a datagram alive. + recordedAt time.Time +} + +// fragmentTracker records the ACL verdict of a datagram's first fragment so the +// remaining fragments, which carry no L4 header, can inherit the decision +// without reassembling the datagram. Only allowed first fragments are stored; +// anything that cannot be tied to an allowed, non-overlapping first fragment is +// dropped (fail closed). +type fragmentTracker struct { + logger *nblog.Logger + mutex sync.Mutex + entries map[fragmentKey]fragmentEntry + timeout time.Duration + // maxEntries caps the table; atCapacity dedups the capacity warning until + // the table drains below the cap again. + maxEntries int + atCapacity bool + cleanupTicker *time.Ticker + cancel context.CancelFunc +} + +func newFragmentTracker(logger *nblog.Logger) *fragmentTracker { + ctx, cancel := context.WithCancel(context.Background()) + t := &fragmentTracker{ + logger: logger, + entries: make(map[fragmentKey]fragmentEntry), + timeout: defaultFragmentTimeout, + maxEntries: fragmentMaxEntries(logger), + cleanupTicker: time.NewTicker(fragmentCleanupInterval), + cancel: cancel, + } + go t.cleanupRoutine(ctx) + return t +} + +func fragmentMaxEntries(logger *nblog.Logger) int { + v := os.Getenv(EnvFragmentMaxEntries) + if v == "" { + return defaultMaxFragmentEntries + } + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + logger.Warn2("invalid %s=%q, using default", EnvFragmentMaxEntries, v) + return defaultMaxFragmentEntries + } + return n +} + +// recordAllowed stores the verdict of an allowed first fragment. headerEndOctets +// is the first fragment's payload length in 8-byte units. When the table is full +// the record is dropped, which fails closed: the datagram's trailing fragments +// will be denied. +func (t *fragmentTracker) recordAllowed(key fragmentKey, headerEndOctets uint16) { + t.mutex.Lock() + defer t.mutex.Unlock() + + if t.entries == nil { + return + } + if _, ok := t.entries[key]; !ok && len(t.entries) >= t.maxEntries { + if !t.atCapacity { + t.atCapacity = true + t.logger.Warn2("fragment verdict table at capacity (%d/%d): trailing fragments of new datagrams will be dropped", + len(t.entries), t.maxEntries) + } + return + } + t.entries[key] = fragmentEntry{ + headerEndOctets: headerEndOctets, + recordedAt: time.Now(), + } +} + +// poison drops any recorded verdict for a datagram, so its later fragments are +// denied until a new allowed first fragment is recorded. Called on every +// offset-zero fragment to defeat offset-zero overlap rewrites (RFC 3128). +func (t *fragmentTracker) poison(key fragmentKey) { + t.mutex.Lock() + defer t.mutex.Unlock() + delete(t.entries, key) +} + +// verdict decides the fate of a trailing fragment at fragOffsetOctets (the IPv4 +// fragment offset, in 8-byte units). A fragment overlapping the inspected +// header poisons the datagram: the entry is removed so all further fragments of +// that datagram are denied too. +func (t *fragmentTracker) verdict(key fragmentKey, fragOffsetOctets uint16) fragmentVerdict { + t.mutex.Lock() + defer t.mutex.Unlock() + + entry, ok := t.entries[key] + if !ok { + return fragmentDeny + } + if time.Since(entry.recordedAt) > t.timeout { + delete(t.entries, key) + return fragmentDeny + } + if fragOffsetOctets < entry.headerEndOctets { + delete(t.entries, key) + return fragmentOverlap + } + return fragmentAllow +} + +func (t *fragmentTracker) cleanupRoutine(ctx context.Context) { + defer t.cleanupTicker.Stop() + for { + select { + case <-t.cleanupTicker.C: + t.cleanup() + case <-ctx.Done(): + return + } + } +} + +func (t *fragmentTracker) cleanup() { + t.mutex.Lock() + defer t.mutex.Unlock() + + for key, entry := range t.entries { + if time.Since(entry.recordedAt) > t.timeout { + delete(t.entries, key) + } + } + + if len(t.entries) < t.maxEntries { + t.atCapacity = false + } +} + +// Close stops the cleanup routine and releases resources. +func (t *fragmentTracker) Close() { + t.cancel() + + t.mutex.Lock() + t.entries = nil + t.mutex.Unlock() +} diff --git a/client/firewall/uspfilter/fragment_bench_test.go b/client/firewall/uspfilter/fragment_bench_test.go new file mode 100644 index 000000000..a9e6d2d13 --- /dev/null +++ b/client/firewall/uspfilter/fragment_bench_test.go @@ -0,0 +1,115 @@ +package uspfilter + +import ( + "encoding/binary" + "testing" +) + +// benchFilterInbound drives filterInbound over a fixed packet in a tight loop. +// Packets are built once, outside the timed region, so the benchmark measures +// only pipeline cost, which is what an attacker can amplify. +func benchFilterInbound(b *testing.B, pkt []byte) { + b.Helper() + b.ReportAllocs() + b.SetBytes(int64(len(pkt))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + m := benchManager + m.filterInbound(pkt, len(pkt)) + } +} + +// benchManager is a package-level manager reused across fragment benchmarks so +// setup cost stays out of the timed region. +var benchManager *Manager + +func setupBenchManager(b *testing.B) *Manager { + b.Helper() + m := newFragmentTestManager(b) + allowUDP(b, m, 8080) + // Disable conntrack so the allowed-first-fragment path measures transport + // decode + ACL every iteration instead of matching the connection tracked + // on the first iteration. + m.stateful = false + benchManager = m + return m +} + +// BenchmarkInbound_NormalPacket is the baseline: a full, non-fragmented UDP +// packet that passes the ACL. Fragment paths should stay comparable to this. +func BenchmarkInbound_NormalPacket(b *testing.B) { + setupBenchManager(b) + pkt := normalUDPPacket(b, 8080, 32) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_FirstFragmentAllowed measures the first-fragment path: +// transport decode + ACL evaluation + verdict record. +func BenchmarkInbound_FirstFragmentAllowed(b *testing.B) { + setupBenchManager(b) + pkt := firstFragmentUDP(b, 0x2000, 8080, 32) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TrailingFragmentAllowed measures the common trailing-fragment +// path: a single map lookup after the first fragment is on record. +func BenchmarkInbound_TrailingFragmentAllowed(b *testing.B) { + m := setupBenchManager(b) + first := firstFragmentUDP(b, 0x3000, 8080, 32) + m.filterInbound(first, len(first)) + pkt := trailingFragment(b, 0x3000, 5, false, 24) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TrailingFragmentNoFirst is the primary DoS vector: an +// attacker floods trailing fragments with no first fragment on record. Each is +// a map miss and must be cheap. +func BenchmarkInbound_TrailingFragmentNoFirst(b *testing.B) { + setupBenchManager(b) + pkt := trailingFragment(b, 0x4000, 185, false, 40) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TinyFirstFragment measures the tiny-fragment drop path: a +// first fragment too small to decode a transport header. +func BenchmarkInbound_TinyFirstFragment(b *testing.B) { + setupBenchManager(b) + pkt := trailingFragment(b, 0x5000, 0, true, 4) + benchFilterInbound(b, pkt) +} + +// BenchmarkInbound_TrailingFragmentDistinctIDs is the worst case for the +// verdict table: an attacker varies the datagram id on every packet so no first +// fragment ever matches. Verdict lookups always miss and nothing is recorded, +// so the table cannot grow. Each iteration rewrites the id field in place. +func BenchmarkInbound_TrailingFragmentDistinctIDs(b *testing.B) { + setupBenchManager(b) + pkt := trailingFragment(b, 0x6000, 185, false, 40) + m := benchManager + + b.ReportAllocs() + b.SetBytes(int64(len(pkt))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + // IPv4 identification field is at bytes 4:6. + binary.BigEndian.PutUint16(pkt[4:6], uint16(i)) + m.filterInbound(pkt, len(pkt)) + } +} + +// BenchmarkInbound_FirstFragmentDistinctIDs measures sustained first-fragment +// pressure with distinct ids: transport decode + ACL + verdict insert until the +// table caps, exercising the map growth and capacity guard. +func BenchmarkInbound_FirstFragmentDistinctIDs(b *testing.B) { + setupBenchManager(b) + pkt := firstFragmentUDP(b, 0x7000, 8080, 32) + m := benchManager + + b.ReportAllocs() + b.SetBytes(int64(len(pkt))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + binary.BigEndian.PutUint16(pkt[4:6], uint16(i)) + m.filterInbound(pkt, len(pkt)) + } +} diff --git a/client/firewall/uspfilter/fragment_test.go b/client/firewall/uspfilter/fragment_test.go new file mode 100644 index 000000000..6960e4dda --- /dev/null +++ b/client/firewall/uspfilter/fragment_test.go @@ -0,0 +1,554 @@ +package uspfilter + +import ( + "encoding/binary" + "net" + "net/netip" + "testing" + "time" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/require" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + nbiface "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +const ( + fragTestSrc = "100.10.0.1" + fragTestDst = "100.10.0.100" + fragTestSrcV6 = "fd00::1" + fragTestDstV6 = "fd00::100" +) + +func newFragmentTestManager(tb testing.TB) *Manager { + tb.Helper() + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr(fragTestDst), + Network: netip.MustParsePrefix("100.10.0.0/16"), + IPv6: netip.MustParseAddr(fragTestDstV6), + IPv6Net: netip.MustParsePrefix("fd00::/64"), + } + }, + } + + m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + require.NoError(tb, err) + require.NoError(tb, m.UpdateLocalIPs()) + tb.Cleanup(func() { require.NoError(tb, m.Close(nil)) }) + return m +} + +// firstFragmentUDPTo builds the first fragment of a fragmented UDP datagram to +// the given destination: it carries the full UDP header plus payloadLen bytes +// of data, with the More Fragments flag set and offset zero. +func firstFragmentUDPTo(tb testing.TB, dst string, id uint16, dstPort uint16, payloadLen int) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: id, + Protocol: layers.IPProtocolUDP, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(dst), + Flags: layers.IPv4MoreFragments, + } + udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)} + require.NoError(tb, udp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen)))) + return buf.Bytes() +} + +func firstFragmentUDP(tb testing.TB, id uint16, dstPort uint16, payloadLen int) []byte { + tb.Helper() + return firstFragmentUDPTo(tb, fragTestDst, id, dstPort, payloadLen) +} + +// firstFragmentTCP builds the first fragment of a fragmented TCP datagram: the +// full 20-byte TCP header plus 12 bytes of data, with the More Fragments flag +// set and offset zero. +func firstFragmentTCP(tb testing.TB, id uint16, dstPort uint16) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: id, + Protocol: layers.IPProtocolTCP, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(fragTestDst), + Flags: layers.IPv4MoreFragments, + } + tcp := &layers.TCP{SrcPort: 40000, DstPort: layers.TCPPort(dstPort), SYN: true, Window: 64240} + require.NoError(tb, tcp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, tcp, gopacket.Payload(make([]byte, 12)))) + return buf.Bytes() +} + +// trailingFragmentTo builds a non-first fragment to the given destination: an +// IPv4 header at the given fragment offset (in 8-byte units) carrying raw +// payload and no L4 header. +func trailingFragmentTo(tb testing.TB, dst string, proto layers.IPProtocol, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: id, + Protocol: proto, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(dst), + FragOffset: fragOffsetOctets, + } + if moreFragments { + ip.Flags = layers.IPv4MoreFragments + } + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, gopacket.Payload(make([]byte, payloadLen)))) + return buf.Bytes() +} + +func trailingFragment(tb testing.TB, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte { + tb.Helper() + return trailingFragmentTo(tb, fragTestDst, layers.IPProtocolUDP, id, fragOffsetOctets, moreFragments, payloadLen) +} + +// outboundUDPPacket builds a complete outbound UDP packet from the local +// address, used to establish conntrack state for reply-direction tests. +func outboundUDPPacket(tb testing.TB, srcPort, dstPort uint16) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: 1, + Protocol: layers.IPProtocolUDP, + SrcIP: net.ParseIP(fragTestDst), + DstIP: net.ParseIP(fragTestSrc), + } + udp := &layers.UDP{SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort)} + require.NoError(tb, udp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, 16)))) + return buf.Bytes() +} + +// normalUDPPacket builds a complete, non-fragmented UDP packet for baseline +// comparisons against the fragment paths. +func normalUDPPacket(tb testing.TB, dstPort uint16, payloadLen int) []byte { + tb.Helper() + + ip := &layers.IPv4{ + Version: 4, + TTL: 64, + Id: 1, + Protocol: layers.IPProtocolUDP, + SrcIP: net.ParseIP(fragTestSrc), + DstIP: net.ParseIP(fragTestDst), + } + udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)} + require.NoError(tb, udp.SetNetworkLayerForChecksum(ip)) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen)))) + return buf.Bytes() +} + +func allowUDP(tb testing.TB, m *Manager, dstPort uint16) { + tb.Helper() + _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolUDP, nil, + &fw.Port{Values: []uint16{dstPort}}, fw.ActionAccept, "") + require.NoError(tb, err) +} + +// TestFragment_TrailingWithoutFirstDropped is the core bypass repro: a trailing +// fragment with no allowed first fragment on record must be dropped. Before the +// fix, filterInbound returned false (allow) for any fragment. +func TestFragment_TrailingWithoutFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + + frag := trailingFragment(t, 0x1234, 185, false, 40) + require.True(t, m.filterInbound(frag, len(frag)), + "trailing fragment without an allowed first fragment must be dropped") +} + +// TestFragment_AllowedFirstPassesTrailing verifies that once a first fragment +// passes the ACL, its trailing fragments inherit the allow verdict. +func TestFragment_AllowedFirstPassesTrailing(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + // First fragment: UDP header (8) + 32 payload = 40 octets -> headerEnd = 5. + first := firstFragmentUDP(t, 0x2222, 8080, 32) + require.False(t, m.filterInbound(first, len(first)), + "allowed first fragment should pass and be recorded") + + trailing := trailingFragment(t, 0x2222, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed datagram should pass") +} + +// TestFragment_DeniedFirstDropsTrailing verifies that a first fragment blocked +// by the ACL leaves no verdict, so its trailing fragments are dropped. +func TestFragment_DeniedFirstDropsTrailing(t *testing.T) { + m := newFragmentTestManager(t) + // No accept rule: local traffic defaults to deny. + + first := firstFragmentUDP(t, 0x3333, 9999, 32) + require.True(t, m.filterInbound(first, len(first)), + "first fragment to a blocked port should be dropped by the ACL") + + trailing := trailingFragment(t, 0x3333, 5, false, 24) + require.True(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of a denied datagram must be dropped") +} + +// TestFragment_OverlappingHeaderDropped covers the RFC 1858 §4 / RFC 3128 +// overlapping-fragment rewrite: a trailing fragment starting inside the range +// the ACL already inspected is dropped and poisons the datagram. TCP is used so +// the overlap lands on real header bytes (the flags at byte 13). +func TestFragment_OverlappingHeaderDropped(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "") + require.NoError(t, err) + + // First fragment: TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets. + first := firstFragmentTCP(t, 0x4444, 8080) + require.False(t, m.filterInbound(first, len(first))) + + // Overlapping fragment at offset 1 (byte 8) falls inside the inspected TCP + // header, so it could rewrite the flags or port on reassembly. + overlap := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 1, true, 32) + require.True(t, m.filterInbound(overlap, len(overlap)), + "fragment overlapping the inspected header must be dropped") + + // The datagram is now poisoned: a later, non-overlapping fragment is also + // dropped because the verdict was removed. + later := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 4, false, 24) + require.True(t, m.filterInbound(later, len(later)), + "fragments after an overlap must be dropped (datagram poisoned)") +} + +// TestFragment_OffsetZeroOverlapPoisons covers the RFC 3128 offset-zero rewrite: +// an allowed first fragment followed by a denied offset-zero fragment for the +// same datagram must not leave the earlier allow verdict in place. +func TestFragment_OffsetZeroOverlapPoisons(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + allowed := firstFragmentUDP(t, 0x5A5A, 8080, 32) + require.False(t, m.filterInbound(allowed, len(allowed)), + "allowed first fragment should pass and be recorded") + + // A second offset-zero fragment to a denied port supersedes the datagram's + // verdict; it is dropped and must not leave the allow in place. + denied := firstFragmentUDP(t, 0x5A5A, 9999, 32) + require.True(t, m.filterInbound(denied, len(denied)), + "denied offset-zero fragment must be dropped") + + trailing := trailingFragment(t, 0x5A5A, 5, false, 24) + require.True(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment must be denied after the datagram was poisoned") +} + +// TestFragment_TinyFirstDropped covers the tiny-fragment attack: a first +// fragment too small to contain the full transport header can't be +// ACL-evaluated and must be dropped. +func TestFragment_TinyFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + // IPv4 header + 4 raw bytes, MF set, offset 0: too small for the 8-byte UDP + // header, so it decodes to L3 only. + tiny := trailingFragment(t, 0x5555, 0, true, 4) + require.True(t, m.filterInbound(tiny, len(tiny)), + "tiny first fragment without a full L4 header must be dropped") +} + +// TestFragment_TCPFirstFragment verifies the TCP arm of the transport decode: a +// first fragment carrying the full 20-byte TCP header is ACL-evaluated and its +// trailing fragments inherit the verdict. +func TestFragment_TCPFirstFragment(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "") + require.NoError(t, err) + + // TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets. + first := firstFragmentTCP(t, 0x6666, 8080) + require.False(t, m.filterInbound(first, len(first)), + "allowed TCP first fragment should pass and be recorded") + + trailing := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x6666, 4, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed TCP datagram should pass") +} + +// TestFragment_TCPTinyFirstDropped verifies the TCP minimum header length: 12 +// bytes would satisfy a UDP header but falls short of the 20-byte TCP header. +func TestFragment_TCPTinyFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "") + require.NoError(t, err) + + tiny := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x7777, 0, true, 12) + require.True(t, m.filterInbound(tiny, len(tiny)), + "first fragment shorter than the TCP header must be dropped") +} + +// TestFragment_ConntrackAllowsFirstFragment verifies the conntrack branch: reply +// fragments of an outbound-established UDP flow pass without any inbound rule. +func TestFragment_ConntrackAllowsFirstFragment(t *testing.T) { + m := newFragmentTestManager(t) + + out := outboundUDPPacket(t, 12345, 40000) + require.False(t, m.filterOutbound(out, len(out))) + + first := firstFragmentUDP(t, 0x8888, 12345, 32) + require.False(t, m.filterInbound(first, len(first)), + "reply first fragment should pass via conntrack") + + trailing := trailingFragment(t, 0x8888, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of a tracked flow should pass") +} + +// TestFragment_RoutingDisabledDropsFragment verifies routed first fragments are +// dropped when routing is disabled. +func TestFragment_RoutingDisabledDropsFragment(t *testing.T) { + m := newFragmentTestManager(t) + m.routingEnabled.Store(false) + + first := firstFragmentUDPTo(t, "198.51.100.10", 0x9999, 8080, 32) + require.True(t, m.filterInbound(first, len(first)), + "routed first fragment must be dropped when routing is disabled") +} + +// TestFragment_RouteACL verifies the route-ACL branch: fragments to a non-local +// destination follow the route rules, allowed datagrams pass their trailing +// fragments and denied ones don't. +func TestFragment_RouteACL(t *testing.T) { + m := newFragmentTestManager(t) + m.routingEnabled.Store(true) + m.nativeRouter.Store(false) + + _, err := m.AddRouteFiltering( + []byte("rt-1"), + []netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")}, + fw.Network{Prefix: netip.MustParsePrefix("198.51.100.0/24")}, + fw.ProtocolUDP, + nil, + &fw.Port{Values: []uint16{8080}}, + fw.ActionAccept, + ) + require.NoError(t, err) + + first := firstFragmentUDPTo(t, "198.51.100.10", 0xAAAA, 8080, 32) + require.False(t, m.filterInbound(first, len(first)), + "route-ACL-allowed first fragment should pass") + trailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xAAAA, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed routed datagram should pass") + + denied := firstFragmentUDPTo(t, "198.51.100.10", 0xBBBB, 9999, 32) + require.True(t, m.filterInbound(denied, len(denied)), + "route-ACL-denied first fragment must be dropped") + deniedTrailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xBBBB, 5, false, 24) + require.True(t, m.filterInbound(deniedTrailing, len(deniedTrailing)), + "trailing fragment of a denied routed datagram must be dropped") +} + +// TestFragment_ExpiredVerdictDropsTrailing verifies a verdict older than the +// tracker timeout no longer admits trailing fragments. +func TestFragment_ExpiredVerdictDropsTrailing(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + first := firstFragmentUDP(t, 0xCCCC, 8080, 32) + require.False(t, m.filterInbound(first, len(first))) + + m.fragments.mutex.Lock() + for key, entry := range m.fragments.entries { + entry.recordedAt = time.Now().Add(-defaultFragmentTimeout - time.Second) + m.fragments.entries[key] = entry + } + m.fragments.mutex.Unlock() + + trailing := trailingFragment(t, 0xCCCC, 5, false, 24) + require.True(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment after verdict expiry must be dropped") +} + +// TestFragment_CapacityFailsClosed verifies the table cap: at capacity, new +// datagram verdicts are not recorded (their trailing fragments are dropped) +// while already-recorded datagrams keep working. +func TestFragment_CapacityFailsClosed(t *testing.T) { + m := newFragmentTestManager(t) + allowUDP(t, m, 8080) + + m.fragments.mutex.Lock() + m.fragments.maxEntries = 1 + m.fragments.mutex.Unlock() + + first1 := firstFragmentUDP(t, 0x0101, 8080, 32) + require.False(t, m.filterInbound(first1, len(first1))) + + first2 := firstFragmentUDP(t, 0x0202, 8080, 32) + require.False(t, m.filterInbound(first2, len(first2)), + "first fragment itself still passes at capacity") + + trailing2 := trailingFragment(t, 0x0202, 5, false, 24) + require.True(t, m.filterInbound(trailing2, len(trailing2)), + "trailing fragment of an unrecorded datagram must be dropped at capacity") + + trailing1 := trailingFragment(t, 0x0101, 5, false, 24) + require.False(t, m.filterInbound(trailing1, len(trailing1)), + "already-recorded datagram should keep passing at capacity") +} + +// v6FragmentHeader builds the 8-byte IPv6 fragment extension header for the +// given inner protocol, offset (8-byte units), More Fragments bit and id. +func v6FragmentHeader(proto layers.IPProtocol, offsetOctets uint16, moreFragments bool, id uint32) []byte { + offsetFlags := offsetOctets << 3 + if moreFragments { + offsetFlags |= 1 + } + hdr := make([]byte, 8) + hdr[0] = uint8(proto) + binary.BigEndian.PutUint16(hdr[2:4], offsetFlags) + binary.BigEndian.PutUint32(hdr[4:8], id) + return hdr +} + +func v6UDPHeader(dstPort uint16, dataLen int) []byte { + hdr := make([]byte, 8) + binary.BigEndian.PutUint16(hdr[0:2], 40000) + binary.BigEndian.PutUint16(hdr[2:4], dstPort) + binary.BigEndian.PutUint16(hdr[4:6], uint16(8+dataLen)) + return hdr +} + +// firstFragmentUDPv6 builds the first fragment of a fragmented IPv6 UDP +// datagram: fragment header (offset 0, More Fragments set) + full UDP header + +// data. +func firstFragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int) []byte { + tb.Helper() + return fragmentUDPv6(tb, id, dstPort, dataLen, true) +} + +// fragmentUDPv6 builds an offset-zero IPv6 UDP fragment. With moreFragments +// false it is an atomic fragment (a complete datagram, RFC 6946). +func fragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int, moreFragments bool) []byte { + tb.Helper() + + ip := &layers.IPv6{ + Version: 6, + NextHeader: layers.IPProtocolIPv6Fragment, + HopLimit: 64, + SrcIP: net.ParseIP(fragTestSrcV6), + DstIP: net.ParseIP(fragTestDstV6), + } + payload := append(v6FragmentHeader(layers.IPProtocolUDP, 0, moreFragments, id), v6UDPHeader(dstPort, dataLen)...) + payload = append(payload, make([]byte, dataLen)...) + + buf := gopacket.NewSerializeBuffer() + require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload))) + return buf.Bytes() +} + +// trailingFragmentV6 builds a non-first IPv6 fragment: fragment header at the +// given offset carrying raw data and no transport header. +func trailingFragmentV6(tb testing.TB, id uint32, offsetOctets uint16, moreFragments bool, dataLen int) []byte { + tb.Helper() + + ip := &layers.IPv6{ + Version: 6, + NextHeader: layers.IPProtocolIPv6Fragment, + HopLimit: 64, + SrcIP: net.ParseIP(fragTestSrcV6), + DstIP: net.ParseIP(fragTestDstV6), + } + payload := append(v6FragmentHeader(layers.IPProtocolUDP, offsetOctets, moreFragments, id), make([]byte, dataLen)...) + + buf := gopacket.NewSerializeBuffer() + require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload))) + return buf.Bytes() +} + +// TestFragmentV6_TrailingWithoutFirstDropped verifies the IPv6 bypass is closed: +// a trailing fragment with no allowed first fragment is dropped. +func TestFragmentV6_TrailingWithoutFirstDropped(t *testing.T) { + m := newFragmentTestManager(t) + + frag := trailingFragmentV6(t, 0xAABBCCDD, 100, false, 40) + require.True(t, m.filterInbound(frag, len(frag)), + "IPv6 trailing fragment without an allowed first fragment must be dropped") +} + +// TestFragmentV6_AllowedFirstPassesTrailing verifies IPv6 fragments are +// evaluated like IPv4: an allowed first fragment lets its trailing fragments +// through. +func TestFragmentV6_AllowedFirstPassesTrailing(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "") + require.NoError(t, err) + + // First fragment: UDP header (8) + 32 data = 40 octets -> headerEnd = 5. + first := firstFragmentUDPv6(t, 0xAABBCCDD, 8080, 32) + require.False(t, m.filterInbound(first, len(first)), + "allowed IPv6 first fragment should pass and be recorded") + + trailing := trailingFragmentV6(t, 0xAABBCCDD, 5, false, 24) + require.False(t, m.filterInbound(trailing, len(trailing)), + "trailing fragment of an allowed IPv6 datagram should pass") +} + +// TestFragmentV6_AtomicNotCached verifies an IPv6 atomic fragment (fragment +// header with offset 0 and no More Fragments, a complete datagram per RFC 6946) +// is evaluated but not recorded, so a flood of allowed atomic fragments can't +// exhaust the verdict table. +func TestFragmentV6_AtomicNotCached(t *testing.T) { + m := newFragmentTestManager(t) + _, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil, + &fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "") + require.NoError(t, err) + + atomic := fragmentUDPv6(t, 0xA70301C, 8080, 16, false) + require.False(t, m.filterInbound(atomic, len(atomic)), + "allowed IPv6 atomic fragment should pass") + + m.fragments.mutex.Lock() + n := len(m.fragments.entries) + m.fragments.mutex.Unlock() + require.Zero(t, n, "atomic fragment must not create a verdict entry") + + // A genuine fragmented datagram (More Fragments set) is still recorded. + first := fragmentUDPv6(t, 0xBEEF, 8080, 32, true) + require.False(t, m.filterInbound(first, len(first))) + m.fragments.mutex.Lock() + n = len(m.fragments.entries) + m.fragments.mutex.Unlock() + require.Equal(t, 1, n, "genuine first fragment must record a verdict") +} diff --git a/client/grpc/dialer_generic.go b/client/grpc/dialer_generic.go index 479575996..8a80525e9 100644 --- a/client/grpc/dialer_generic.go +++ b/client/grpc/dialer_generic.go @@ -16,28 +16,47 @@ import ( "google.golang.org/grpc" nbnet "github.com/netbirdio/netbird/client/net" + "github.com/netbirdio/netbird/client/netsweep" ) func WithCustomDialer(_ bool, _ string) grpc.DialOption { + return grpc.WithContextDialer(dialContext) +} + +// WithSweeper dials like WithCustomDialer but registers connections and +// dials with the sweeper. Append it after WithCustomDialer: gRPC applies +// dial options in order, so the later context dialer wins. +func WithSweeper(sweeper *netsweep.Sweeper) grpc.DialOption { return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { - if runtime.GOOS == "linux" { - currentUser, err := user.Current() - if err != nil { - return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err) - } + dial := sweeper.StartDial(ctx) + defer dial.Release() - // the custom dialer requires root permissions which are not required for use cases run as non-root - if currentUser.Uid != "0" { - log.Debug("Not running as root, using standard dialer") - dialer := &net.Dialer{} - return dialer.DialContext(ctx, "tcp", addr) - } - } - - conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr) + conn, err := dialContext(dial.Ctx(), addr) if err != nil { - return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err) + return nil, err } - return conn, nil + return dial.WrapConn(conn) }) } + +func dialContext(ctx context.Context, addr string) (net.Conn, error) { + if runtime.GOOS == "linux" { + currentUser, err := user.Current() + if err != nil { + return nil, status.Errorf(codes.FailedPrecondition, "failed to get current user: %v", err) + } + + // the custom dialer requires root permissions which are not required for use cases run as non-root + if currentUser.Uid != "0" { + log.Debug("Not running as root, using standard dialer") + dialer := &net.Dialer{} + return dialer.DialContext(ctx, "tcp", addr) + } + } + + conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("nbnet.NewDialer().DialContext: %w", err) + } + return conn, nil +} diff --git a/client/grpc/dialer_js.go b/client/grpc/dialer_js.go index b89ec3c21..8863756d7 100644 --- a/client/grpc/dialer_js.go +++ b/client/grpc/dialer_js.go @@ -3,6 +3,7 @@ package grpc import ( "google.golang.org/grpc" + "github.com/netbirdio/netbird/client/netsweep" "github.com/netbirdio/netbird/util/wsproxy/client" ) @@ -11,3 +12,8 @@ import ( func WithCustomDialer(tlsEnabled bool, component string) grpc.DialOption { return client.WithWebSocketDialer(tlsEnabled, component) } + +// WithSweeper is a no-op on WASM/JS: there is no network change signal. +func WithSweeper(_ *netsweep.Sweeper) grpc.DialOption { + return grpc.EmptyDialOption{} +} diff --git a/client/grpc/retry.go b/client/grpc/retry.go new file mode 100644 index 000000000..754ffa341 --- /dev/null +++ b/client/grpc/retry.go @@ -0,0 +1,49 @@ +package grpc + +import ( + "context" + "errors" + "time" + + "github.com/cenkalti/backoff/v4" + + "github.com/netbirdio/netbird/client/netstate" +) + +// Retry mirrors backoff.Retry, but the sleep between attempts also wakes on +// OS network availability transitions: an operation cut down by a network +// change retries the moment the network settles instead of sleeping through +// the recovery. A nil netState never fires, leaving plain backoff.Retry +// behavior. +func Retry(ctx context.Context, operation backoff.Operation, bo backoff.BackOff, netState *netstate.State) error { + bo.Reset() + for { + err := operation() + if err == nil { + return nil + } + + var permanent *backoff.PermanentError + if errors.As(err, &permanent) { + return permanent.Err + } + + next := bo.NextBackOff() + if next == backoff.Stop { + if cerr := ctx.Err(); cerr != nil { + return cerr + } + return err + } + + timer := time.NewTimer(next) + select { + case <-timer.C: + case <-netState.Changed(): + timer.Stop() + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + } + } +} diff --git a/client/grpc/retry_test.go b/client/grpc/retry_test.go new file mode 100644 index 000000000..4edca47b6 --- /dev/null +++ b/client/grpc/retry_test.go @@ -0,0 +1,91 @@ +package grpc + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/netstate" +) + +func TestRetryWakesOnNetworkChange(t *testing.T) { + ns := netstate.New() + attempts := 0 + operation := func() error { + attempts++ + if attempts == 1 { + return errors.New("cut by network change") + } + return nil + } + + go func() { + time.Sleep(20 * time.Millisecond) + ns.Set(false) + }() + + start := time.Now() + err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Minute), ns) + + require.NoError(t, err) + assert.Equal(t, 2, attempts, "network change must cause one immediate retry") + assert.Less(t, time.Since(start), time.Second, "the transition must cut the minute-long sleep short") +} + +func TestRetryPermanentError(t *testing.T) { + sentinel := errors.New("permission denied") + operation := func() error { + return backoff.Permanent(sentinel) + } + + err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil) + assert.ErrorIs(t, err, sentinel, "permanent errors must stop retries") +} + +func TestRetryNilNetState(t *testing.T) { + attempts := 0 + operation := func() error { + attempts++ + if attempts < 3 { + return errors.New("transient") + } + return nil + } + + err := Retry(context.Background(), operation, backoff.NewConstantBackOff(time.Millisecond), nil) + require.NoError(t, err) + assert.Equal(t, 3, attempts, "nil network state must preserve timed retries") +} + +func TestRetryStops(t *testing.T) { + failure := errors.New("still failing") + operation := func() error { + return failure + } + + err := Retry(context.Background(), operation, &backoff.StopBackOff{}, nil) + assert.ErrorIs(t, err, failure, "stop backoff must return the operation error") +} + +func TestRetryCtxCancelDuringSleep(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + operation := func() error { + return errors.New("failing") + } + + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + + start := time.Now() + err := Retry(ctx, operation, backoff.NewConstantBackOff(time.Minute), netstate.New()) + + assert.ErrorIs(t, err, context.Canceled, "context cancellation must stop the retry loop") + assert.Less(t, time.Since(start), time.Second, "context cancellation must interrupt backoff sleep") +} diff --git a/client/iface/bind/ice_bind.go b/client/iface/bind/ice_bind.go index 156450c61..2d35b9c6f 100644 --- a/client/iface/bind/ice_bind.go +++ b/client/iface/bind/ice_bind.go @@ -22,6 +22,16 @@ import ( nbnet "github.com/netbirdio/netbird/client/net" ) +const ( + // wgMsgTypeHandshakeInitiation is the lowest WireGuard message type. + wgMsgTypeHandshakeInitiation uint32 = 1 + // wgMsgTypeTransport is the highest WireGuard message type. + wgMsgTypeTransport uint32 = 4 + // wgMinMsgSize is the smallest WireGuard message: transport data with an empty + // payload, which is what a keepalive is. + wgMinMsgSize = 32 +) + type receiverCreator struct { iceBind *ICEBind } @@ -216,8 +226,15 @@ func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxO for i := 0; i < numMsgs; i++ { msg := &(*msgs)[i] - // todo: handle err - if ok, _ := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok { + if ok, err := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok { + if err != nil { + log.Debugf("failed to handle STUN packet from %s: %v", msg.Addr, err) + } + // WireGuard reuses sizes and eps across reads and only skips a slot + // whose size is below the minimum message size. Leaving a consumed + // slot untouched makes it process this buffer again under the + // previous packet's length and endpoint. + sizes[i] = 0 continue } sizes[i] = msg.N @@ -271,11 +288,16 @@ func (s *ICEBind) createOrUpdateMux() { func (s *ICEBind) filterOutStunMessages(buffers [][]byte, n int, addr net.Addr) (bool, error) { for i := range buffers { - if !stun.IsMessage(buffers[i]) { + if n > len(buffers[i]) { + continue + } + pkt := buffers[i][:n] + + if isWireGuardMsg(pkt) || !stun.IsMessage(pkt) { continue } - msg, err := s.parseSTUNMessage(buffers[i][:n]) + msg, err := s.parseSTUNMessage(pkt) if err != nil { buffers[i] = []byte{} return true, err @@ -347,18 +369,34 @@ func putMessages(msgs *[]ipv6.Message, msgsPool *sync.Pool) { msgsPool.Put(msgs) } -func isTransportPkg(buffers [][]byte, n int) bool { - // The first buffer should contain at least 4 bytes for type - if len(buffers[0]) < 4 { - return true +// isWireGuardMsg reports whether the packet carries a WireGuard message header: a +// little-endian uint32 message type in the range 1..4, which leaves the three bytes +// after the type byte zero, in a packet long enough to hold any WireGuard message. +// +// A well formed STUN message cannot take that shape. Its length field sits in the two +// bytes the type must leave zero, and for a message of at least wgMinMsgSize bytes that +// field holds at least 12, so the two framings do not overlap. The test has to be this +// tight because stun.IsMessage only looks at the magic cookie, which in a WireGuard +// message overlaps the receiver index: a session whose index happens to equal the cookie +// would otherwise have all of its inbound data misrouted to the STUN handler until the +// next rekey. +func isWireGuardMsg(pkt []byte) bool { + if len(pkt) < wgMinMsgSize { + return false } - // WireGuard packet type is a little-endian uint32 at start - packetType := binary.LittleEndian.Uint32(buffers[0][:4]) - - // Check if packetType matches known WireGuard message types - if packetType == 4 && n > 32 { - return true - } - return false + msgType := binary.LittleEndian.Uint32(pkt[:4]) + return msgType >= wgMsgTypeHandshakeInitiation && msgType <= wgMsgTypeTransport +} + +// isTransportPkg reports whether the packet is WireGuard transport data carrying a +// payload, which is what counts as peer activity. A keepalive holds no payload and is +// exactly wgMinMsgSize bytes. +func isTransportPkg(buffers [][]byte, n int) bool { + if n < 4 || n > len(buffers[0]) { + return false + } + + msgType := binary.LittleEndian.Uint32(buffers[0][:4]) + return msgType == wgMsgTypeTransport && n > wgMinMsgSize } diff --git a/client/iface/bind/stun_filter_test.go b/client/iface/bind/stun_filter_test.go new file mode 100644 index 000000000..0e118e0fd --- /dev/null +++ b/client/iface/bind/stun_filter_test.go @@ -0,0 +1,215 @@ +//go:build !js + +package bind + +import ( + "encoding/binary" + "net" + "testing" + "time" + + "github.com/pion/stun/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/net/ipv4" + wgConn "golang.zx2c4.com/wireguard/conn" +) + +// magicCookieBytes is the STUN magic cookie as it appears on the wire. In a +// WireGuard message the same offset holds the receiver (or sender) index, which is +// a random uint32, so a session can draw exactly this value. +var magicCookieBytes = []byte{0x21, 0x12, 0xA4, 0x42} + +const testBufSize = 1500 + +// wgMsg builds a WireGuard message of the given type and size, with the index field +// at bytes 4:8 set to index. +func wgMsg(msgType uint32, size int, index []byte) []byte { + pkt := make([]byte, size) + binary.LittleEndian.PutUint32(pkt[:4], msgType) + copy(pkt[4:8], index) + return pkt +} + +// intoBuffer copies pkt into a full-size receive buffer, the way the kernel read +// does, so tests see the same buffer/length split as the hot path. +func intoBuffer(pkt []byte) [][]byte { + buf := make([]byte, testBufSize) + copy(buf, pkt) + return [][]byte{buf} +} + +func TestFilterOutStunMessages_PassesWireGuardWithCookieShapedIndex(t *testing.T) { + tests := []struct { + name string + msgType uint32 + size int + }{ + {"transport data", wgMsgTypeTransport, 128}, + {"keepalive", wgMsgTypeTransport, wgMinMsgSize}, + {"handshake initiation", wgMsgTypeHandshakeInitiation, 148}, + {"handshake response", 2, 92}, + {"cookie reply", 3, 64}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + pkt := wgMsg(tc.msgType, tc.size, magicCookieBytes) + require.True(t, stun.IsMessage(pkt), "precondition: pion sees this as STUN") + + buffers := intoBuffer(pkt) + bind := &ICEBind{} + + filtered, err := bind.filterOutStunMessages(buffers, tc.size, &net.UDPAddr{}) + assert.NoError(t, err) + assert.False(t, filtered, "WireGuard message must be handed to WireGuard, not the STUN handler") + assert.Len(t, buffers[0], testBufSize, "buffer must be left intact for WireGuard") + }) + } +} + +func TestFilterOutStunMessages_FiltersRealSTUNMessage(t *testing.T) { + msg, err := stun.Build(stun.BindingRequest, stun.TransactionID, stun.Fingerprint) + require.NoError(t, err) + + buffers := intoBuffer(msg.Raw) + bind := &ICEBind{} + + filtered, err := bind.filterOutStunMessages(buffers, len(msg.Raw), &net.UDPAddr{}) + assert.NoError(t, err) + assert.True(t, filtered, "STUN message must be consumed by the STUN handler") + assert.Empty(t, buffers[0], "consumed buffer must be emptied so WireGuard does not see it") +} + +// TestIsWireGuardMsg_DisjointFromSTUN locks the invariant the filter relies on: a +// well formed STUN message long enough to be a WireGuard message always has a +// non-zero length field, so it cannot be mistaken for a WireGuard header. +func TestIsWireGuardMsg_DisjointFromSTUN(t *testing.T) { + types := []stun.MessageType{ + stun.BindingRequest, + stun.BindingSuccess, + stun.BindingError, + {Method: stun.MethodBinding, Class: stun.ClassIndication}, + } + + for _, msgType := range types { + // Long enough that the length guard is not what makes this pass. + msg, err := stun.Build(msgType, stun.TransactionID, + stun.NewUsername("remoteUfrag:localUfrag"), stun.Fingerprint) + require.NoError(t, err) + require.GreaterOrEqual(t, len(msg.Raw), wgMinMsgSize, "precondition: %s", msgType) + assert.False(t, isWireGuardMsg(msg.Raw), + "%s must not look like a WireGuard message", msgType) + } +} + +func TestIsWireGuardMsg(t *testing.T) { + tests := []struct { + name string + pkt []byte + want bool + }{ + {"transport data", wgMsg(wgMsgTypeTransport, 128, nil), true}, + {"handshake initiation", wgMsg(wgMsgTypeHandshakeInitiation, 148, nil), true}, + {"unknown type 5", wgMsg(5, 128, nil), false}, + {"type 0", wgMsg(0, 128, nil), false}, + {"non-zero reserved byte", []byte{0x04, 0x00, 0x01, 0x00}, false}, + {"too short", []byte{0x04, 0x00, 0x00}, false}, + {"empty", nil, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, isWireGuardMsg(tc.pkt), "wrong classification for %s", tc.name) + }) + } +} + +// TestFilterOutStunMessages_IgnoresBytesBeyondPacket guards against classifying on +// buffer contents left over from an earlier, longer packet. +func TestFilterOutStunMessages_IgnoresBytesBeyondPacket(t *testing.T) { + buf := make([]byte, testBufSize) + copy(buf[4:8], magicCookieBytes) + buffers := [][]byte{buf} + bind := &ICEBind{} + + filtered, err := bind.filterOutStunMessages(buffers, 2, &net.UDPAddr{}) + assert.NoError(t, err) + assert.False(t, filtered, "a 2 byte packet must not be classified from stale buffer bytes") +} + +// TestReceiveFn_ClearsSizeOfConsumedPacket covers the accounting WireGuard relies +// on: sizes is reused across reads, so a slot whose packet was consumed as STUN must +// be reported as empty. Otherwise WireGuard reprocesses the same buffer under the +// previous packet's length, which for a WireGuard-shaped packet means it is handled +// twice. +func TestReceiveFn_ClearsSizeOfConsumedPacket(t *testing.T) { + conn := listenUDP(t, "udp4", "127.0.0.1:0") + defer conn.Close() + + recvFn := receiverCreator{setupICEBind(t)}.CreateReceiverFn( + ipv4.NewPacketConn(conn), conn, false, createMsgPool(), + ) + + msg, err := stun.Build(stun.BindingRequest, stun.TransactionID, stun.Fingerprint) + require.NoError(t, err) + + sender := listenUDP(t, "udp4", "127.0.0.1:0") + defer sender.Close() + _, err = sender.WriteTo(msg.Raw, conn.LocalAddr()) + require.NoError(t, err) + + require.NoError(t, conn.SetReadDeadline(time.Now().Add(3*time.Second))) + + bufs := [][]byte{make([]byte, 1500)} + // A leftover size from an earlier read, which is what makes the missing reset + // observable. + sizes := []int{148} + eps := make([]wgConn.Endpoint, 1) + + n, err := recvFn(bufs, sizes, eps) + require.NoError(t, err) + require.Equal(t, 1, n) + assert.Zero(t, sizes[0], "consumed STUN packet must not leave a size behind for WireGuard") +} + +func TestIsTransportPkg(t *testing.T) { + tests := []struct { + name string + pkt []byte + n int + want bool + }{ + {"transport data with payload", wgMsg(wgMsgTypeTransport, 128, nil), 128, true}, + {"keepalive", wgMsg(wgMsgTypeTransport, wgMinMsgSize, nil), wgMinMsgSize, false}, + {"handshake initiation", wgMsg(wgMsgTypeHandshakeInitiation, 148, nil), 148, false}, + {"stale type bytes beyond packet", wgMsg(wgMsgTypeTransport, 128, nil), 2, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, isTransportPkg(intoBuffer(tc.pkt), tc.n), + "wrong activity classification for %s", tc.name) + }) + } +} + +// TestFilterOutStunMessages_ConsumesSTUNWithWireGuardShapedType covers the one STUN +// encoding whose leading bytes collide with a WireGuard message type: method 0x080 as a +// request encodes to 0x0200, so the type byte reads as a handshake response and the byte +// after it is zero. Only the length check keeps such a message out of WireGuard's hands. +// pion implements no method in that range, so this is a synthetic worst case rather than +// traffic ICE produces. +func TestFilterOutStunMessages_ConsumesSTUNWithWireGuardShapedType(t *testing.T) { + msg, err := stun.Build(stun.NewType(stun.Method(0x080), stun.ClassRequest), stun.TransactionID) + require.NoError(t, err) + require.Equal(t, []byte{0x02, 0x00, 0x00, 0x00}, msg.Raw[:4], + "precondition: the leading bytes read as a WireGuard message type") + + buffers := intoBuffer(msg.Raw) + bind := &ICEBind{} + + filtered, err := bind.filterOutStunMessages(buffers, len(msg.Raw), &net.UDPAddr{}) + assert.NoError(t, err) + assert.True(t, filtered, "STUN message must be consumed despite its WireGuard-shaped type") +} diff --git a/client/iface/configurer/kernel_unix.go b/client/iface/configurer/kernel_unix.go index a29fe181a..da69c2a35 100644 --- a/client/iface/configurer/kernel_unix.go +++ b/client/iface/configurer/kernel_unix.go @@ -17,12 +17,15 @@ import ( type KernelConfigurer struct { deviceName string + statsCache *statsCache } func NewKernelConfigurer(deviceName string) *KernelConfigurer { - return &KernelConfigurer{ + c := &KernelConfigurer{ deviceName: deviceName, } + c.statsCache = newStatsCache(statsCacheTTL, c.fetchStats) + return c } func (c *KernelConfigurer) ConfigureInterface(privateKey string, port int) error { @@ -246,12 +249,6 @@ func (c *KernelConfigurer) configure(config wgtypes.Config) error { } }() - // validate if device with name exists - _, err = wg.Device(c.deviceName) - if err != nil { - return err - } - return wg.ConfigureDevice(c.deviceName, config) } @@ -300,6 +297,14 @@ func (c *KernelConfigurer) FullStats() (*Stats, error) { } func (c *KernelConfigurer) GetStats() (map[string]WGStats, error) { + return c.statsCache.get() +} + +func (c *KernelConfigurer) LastActivities() map[string]monotime.Time { + return nil +} + +func (c *KernelConfigurer) fetchStats() (map[string]WGStats, error) { stats := make(map[string]WGStats) wg, err := wgctrl.New() if err != nil { @@ -326,7 +331,3 @@ func (c *KernelConfigurer) GetStats() (map[string]WGStats, error) { } return stats, nil } - -func (c *KernelConfigurer) LastActivities() map[string]monotime.Time { - return nil -} diff --git a/client/iface/configurer/stats_cache.go b/client/iface/configurer/stats_cache.go new file mode 100644 index 000000000..71a4e88fc --- /dev/null +++ b/client/iface/configurer/stats_cache.go @@ -0,0 +1,52 @@ +package configurer + +import ( + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +const statsCacheTTL = 1 * time.Second + +type statsCache struct { + ttl time.Duration + fetch func() (map[string]WGStats, error) + + mu sync.RWMutex + value map[string]WGStats + expireAt time.Time + + sf singleflight.Group +} + +func newStatsCache(ttl time.Duration, fetch func() (map[string]WGStats, error)) *statsCache { + return &statsCache{ttl: ttl, fetch: fetch} +} + +func (c *statsCache) get() (map[string]WGStats, error) { + c.mu.RLock() + if c.value != nil && time.Now().Before(c.expireAt) { + value := c.value + c.mu.RUnlock() + return value, nil + } + c.mu.RUnlock() + + value, err, _ := c.sf.Do("stats", func() (interface{}, error) { + res, err := c.fetch() + if err != nil { + return nil, err + } + + c.mu.Lock() + c.value = res + c.expireAt = time.Now().Add(c.ttl) + c.mu.Unlock() + return res, nil + }) + if err != nil { + return nil, err + } + return value.(map[string]WGStats), nil +} diff --git a/client/iface/configurer/stats_cache_test.go b/client/iface/configurer/stats_cache_test.go new file mode 100644 index 000000000..bcee5cd52 --- /dev/null +++ b/client/iface/configurer/stats_cache_test.go @@ -0,0 +1,70 @@ +package configurer + +import ( + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestStatsCache_CachesWithinTTL(t *testing.T) { + var calls atomic.Int64 + c := newStatsCache(50*time.Millisecond, func() (map[string]WGStats, error) { + calls.Add(1) + return map[string]WGStats{"p": {}}, nil + }) + + for i := 0; i < 10; i++ { + _, err := c.get() + require.NoError(t, err) + } + require.Equal(t, int64(1), calls.Load(), "within TTL only one underlying fetch") + + time.Sleep(60 * time.Millisecond) + _, err := c.get() + require.NoError(t, err) + require.Equal(t, int64(2), calls.Load(), "after TTL expiry a fresh fetch happens") +} + +func TestStatsCache_SingleFlight(t *testing.T) { + var calls atomic.Int64 + release := make(chan struct{}) + c := newStatsCache(time.Minute, func() (map[string]WGStats, error) { + calls.Add(1) + <-release + return map[string]WGStats{}, nil + }) + + const n = 50 + var wg sync.WaitGroup + wg.Add(n) + for i := 0; i < n; i++ { + go func() { + defer wg.Done() + _, _ = c.get() + }() + } + time.Sleep(20 * time.Millisecond) + close(release) + wg.Wait() + + require.Equal(t, int64(1), calls.Load(), "concurrent misses collapse into one fetch") +} + +func TestStatsCache_ErrorNotCached(t *testing.T) { + var calls atomic.Int64 + wantErr := errors.New("dump failed") + c := newStatsCache(time.Minute, func() (map[string]WGStats, error) { + calls.Add(1) + return nil, wantErr + }) + + _, err := c.get() + require.ErrorIs(t, err, wantErr) + _, err = c.get() + require.ErrorIs(t, err, wantErr) + require.Equal(t, int64(2), calls.Load(), "errors are not cached; each call retries") +} diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go index 9b070aab8..0a25c55bc 100644 --- a/client/iface/configurer/usp.go +++ b/client/iface/configurer/usp.go @@ -40,6 +40,7 @@ type WGUSPConfigurer struct { device *device.Device deviceName string activityRecorder *bind.ActivityRecorder + statsCache *statsCache uapiListener net.Listener } @@ -50,16 +51,19 @@ func NewUSPConfigurer(device *device.Device, deviceName string, activityRecorder deviceName: deviceName, activityRecorder: activityRecorder, } + wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats) wgCfg.startUAPI() return wgCfg } func NewUSPConfigurerNoUAPI(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer { - return &WGUSPConfigurer{ + wgCfg := &WGUSPConfigurer{ device: device, deviceName: deviceName, activityRecorder: activityRecorder, } + wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats) + return wgCfg } func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error { @@ -348,6 +352,10 @@ func (t *WGUSPConfigurer) Close() { } func (t *WGUSPConfigurer) GetStats() (map[string]WGStats, error) { + return t.statsCache.get() +} + +func (t *WGUSPConfigurer) fetchStats() (map[string]WGStats, error) { ipc, err := t.device.IpcGet() if err != nil { return nil, fmt.Errorf("ipc get: %w", err) diff --git a/client/iface/device/device_filter_test.go b/client/iface/device/device_filter_test.go index 0d86c9323..a75ef90f9 100644 --- a/client/iface/device/device_filter_test.go +++ b/client/iface/device/device_filter_test.go @@ -4,7 +4,7 @@ import ( "net" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket" "github.com/google/gopacket/layers" diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index dbeb69bc6..89c8cd16e 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -1,3 +1,5 @@ +//go:build privileged + package iface import ( @@ -462,6 +464,8 @@ func Test_RemovePeer(t *testing.T) { } func Test_ConnectPeers(t *testing.T) { + t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true") + peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400) peer1wgIP := netip.MustParsePrefix("10.99.99.17/30") peer1Key, _ := wgtypes.GeneratePrivateKey() @@ -503,12 +507,8 @@ func Test_ConnectPeers(t *testing.T) { t.Fatal(err) } - localIP, err := getLocalIP() - if err != nil { - t.Fatal(err) - } - - peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort)) + localIP1 := "127.0.0.1" + peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort)) if err != nil { t.Fatal(err) } @@ -544,7 +544,8 @@ func Test_ConnectPeers(t *testing.T) { t.Fatal(err) } - peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort)) + localIP2 := "127.0.0.1" + peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort)) if err != nil { t.Fatal(err) } @@ -567,17 +568,17 @@ func Test_ConnectPeers(t *testing.T) { if err != nil { t.Fatal(err) } - // todo: investigate why in some tests execution we need 30s + // The peers use userspace WireGuard (stdnet transport). A tight busy-loop + // here starves the wireguard-go goroutines that process the handshake, so + // poll on a ticker instead and yield the CPU between checks. WireGuard also + // only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which + // is why the overall wait can occasionally stretch to tens of seconds. timeout := 30 * time.Second timeoutChannel := time.After(timeout) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() for { - select { - case <-timeoutChannel: - t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) - default: - } - peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String()) if gpErr != nil { t.Fatal(gpErr) @@ -586,6 +587,12 @@ func Test_ConnectPeers(t *testing.T) { t.Log("peers successfully handshake") break } + + select { + case <-timeoutChannel: + t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) + case <-ticker.C: + } } } @@ -613,28 +620,3 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) { } return wgtypes.Peer{}, fmt.Errorf("peer not found") } - -func getLocalIP() (string, error) { - // Get all interfaces - addrs, err := net.InterfaceAddrs() - if err != nil { - return "", err - } - - for _, addr := range addrs { - ipNet, ok := addr.(*net.IPNet) - if !ok { - continue - } - if ipNet.IP.IsLoopback() { - continue - } - - if ipNet.IP.To4() == nil { - continue - } - return ipNet.IP.String(), nil - } - - return "", fmt.Errorf("no local IP found") -} diff --git a/client/iface/mocks/filter.go b/client/iface/mocks/filter.go index 5ae98039c..ff3dd0c8a 100644 --- a/client/iface/mocks/filter.go +++ b/client/iface/mocks/filter.go @@ -8,7 +8,7 @@ import ( "net/netip" reflect "reflect" - gomock "github.com/golang/mock/gomock" + gomock "go.uber.org/mock/gomock" ) // MockPacketFilter is a mock of PacketFilter interface. diff --git a/client/iface/mocks/tun.go b/client/iface/mocks/tun.go index 677c82b0b..519ee6005 100644 --- a/client/iface/mocks/tun.go +++ b/client/iface/mocks/tun.go @@ -8,7 +8,7 @@ import ( os "os" reflect "reflect" - gomock "github.com/golang/mock/gomock" + gomock "go.uber.org/mock/gomock" tun "golang.zx2c4.com/wireguard/tun" ) diff --git a/client/iface/netstack/env.go b/client/iface/netstack/env.go index dd8cf29a3..b069301c1 100644 --- a/client/iface/netstack/env.go +++ b/client/iface/netstack/env.go @@ -3,14 +3,31 @@ package netstack import ( - "fmt" + "net" "os" "strconv" log "github.com/sirupsen/logrus" ) -const EnvUseNetstackMode = "NB_USE_NETSTACK_MODE" +const ( + EnvUseNetstackMode = "NB_USE_NETSTACK_MODE" + + // EnvSocks5ListenerPort overrides the port the SOCKS5 proxy listens on. + EnvSocks5ListenerPort = "NB_SOCKS5_LISTENER_PORT" + + // EnvSocks5ListenerAddress overrides the host/IP the SOCKS5 proxy binds to. + // The proxy is a bridge for local host applications into the userspace + // WireGuard netstack, so it binds to loopback by default. Override this only + // when the proxy must be reachable from other hosts (e.g. a container + // gateway); doing so exposes an unauthenticated SOCKS5 proxy on that + // address. + EnvSocks5ListenerAddress = "NB_SOCKS5_LISTENER_ADDRESS" + + // defaultSocks5Host is the loopback address the SOCKS5 proxy binds to unless + // overridden via EnvSocks5ListenerAddress. + defaultSocks5Host = "127.0.0.1" +) // IsEnabled todo: move these function to cmd layer func IsEnabled() bool { @@ -18,24 +35,40 @@ func IsEnabled() bool { } func ListenAddr() string { - sPort := os.Getenv("NB_SOCKS5_LISTENER_PORT") + return net.JoinHostPort(listenHost(), strconv.Itoa(listenPort())) +} + +// listenHost returns the host/IP the SOCKS5 proxy binds to. It defaults to +// loopback and only honors EnvSocks5ListenerAddress when it holds a valid IP. +func listenHost() string { + addr := os.Getenv(EnvSocks5ListenerAddress) + if addr == "" { + return defaultSocks5Host + } + if net.ParseIP(addr) == nil { + log.Warnf("invalid socks5 listener address %q, falling back to default: %s", addr, defaultSocks5Host) + return defaultSocks5Host + } + return addr +} + +// listenPort returns the port the SOCKS5 proxy binds to, defaulting to +// DefaultSocks5Port when EnvSocks5ListenerPort is unset or invalid. +func listenPort() int { + sPort := os.Getenv(EnvSocks5ListenerPort) if sPort == "" { - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } port, err := strconv.Atoi(sPort) if err != nil { log.Warnf("invalid socks5 listener port, unable to convert it to int, falling back to default: %d", DefaultSocks5Port) - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } if port < 1 || port > 65535 { log.Warnf("invalid socks5 listener port, it should be in the range 1-65535, falling back to default: %d", DefaultSocks5Port) - return listenAddr(DefaultSocks5Port) + return DefaultSocks5Port } - return listenAddr(port) -} - -func listenAddr(port int) string { - return fmt.Sprintf("0.0.0.0:%d", port) + return port } diff --git a/client/iface/netstack/env_test.go b/client/iface/netstack/env_test.go new file mode 100644 index 000000000..1083435a4 --- /dev/null +++ b/client/iface/netstack/env_test.go @@ -0,0 +1,63 @@ +//go:build !js + +package netstack + +import ( + "net" + "strconv" + "testing" +) + +func TestListenAddr_DefaultsToLoopback(t *testing.T) { + // No env overrides: must bind loopback, never all interfaces. + got := ListenAddr() + want := net.JoinHostPort("127.0.0.1", strconv.Itoa(DefaultSocks5Port)) + if got != want { + t.Fatalf("ListenAddr() = %q, want %q", got, want) + } +} + +func TestListenAddr_AddressOverride(t *testing.T) { + tests := []struct { + name string + env string + want string + }{ + {name: "valid override honored", env: "0.0.0.0", want: "0.0.0.0"}, + {name: "valid specific ip honored", env: "10.0.0.5", want: "10.0.0.5"}, + {name: "ipv6 loopback bracketed", env: "::1", want: "::1"}, + {name: "invalid falls back to loopback", env: "not-an-ip", want: "127.0.0.1"}, + {name: "empty falls back to loopback", env: "", want: "127.0.0.1"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(EnvSocks5ListenerAddress, tc.env) + want := net.JoinHostPort(tc.want, strconv.Itoa(DefaultSocks5Port)) + if got := ListenAddr(); got != want { + t.Fatalf("ListenAddr() = %q, want %q", got, want) + } + }) + } +} + +func TestListenAddr_PortOverride(t *testing.T) { + tests := []struct { + name string + env string + want int + }{ + {name: "valid port honored", env: "1081", want: 1081}, + {name: "non-numeric falls back", env: "abc", want: DefaultSocks5Port}, + {name: "out of range falls back", env: "70000", want: DefaultSocks5Port}, + {name: "zero falls back", env: "0", want: DefaultSocks5Port}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(EnvSocks5ListenerPort, tc.env) + want := net.JoinHostPort("127.0.0.1", strconv.Itoa(tc.want)) + if got := ListenAddr(); got != want { + t.Fatalf("ListenAddr() = %q, want %q", got, want) + } + }) + } +} diff --git a/client/iface/wgproxy/bind/proxy.go b/client/iface/wgproxy/bind/proxy.go index be6f3806e..fcaee15c7 100644 --- a/client/iface/wgproxy/bind/proxy.go +++ b/client/iface/wgproxy/bind/proxy.go @@ -53,15 +53,15 @@ func NewProxyBind(bind Bind, mtu uint16) *ProxyBind { return p } -// AddTurnConn adds a new connection to the bind. +// AddRelayedConn adds a new connection to the bind. // endpoint is the NetBird address of the remote peer. The SetEndpoint return with the address what will be used in the // WireGuard configuration. // // Parameters: // - ctx: Context is used for proxyToLocal to avoid unnecessary error messages // - nbAddr: The NetBird UDP address of the remote peer, it required to generate fake address -// - remoteConn: The established TURN connection to the remote peer -func (p *ProxyBind) AddTurnConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error { +// - remoteConn: The established relayed connection to the remote peer +func (p *ProxyBind) AddRelayedConn(ctx context.Context, nbAddr *net.UDPAddr, remoteConn net.Conn) error { fakeNetIP, err := fakeAddress(nbAddr) if err != nil { return err @@ -136,6 +136,11 @@ func (p *ProxyBind) CloseConn() error { return p.close() } +// InjectPacket is a no-op for the userspace proxy: first-packet reinjection is kernel-only. +func (p *ProxyBind) InjectPacket(_ []byte) error { + return nil +} + func (p *ProxyBind) close() error { if p.remoteConn == nil { return nil diff --git a/client/iface/wgproxy/ebpf/proxy.go b/client/iface/wgproxy/ebpf/proxy.go index 1b1a8ce1c..91c741c0d 100644 --- a/client/iface/wgproxy/ebpf/proxy.go +++ b/client/iface/wgproxy/ebpf/proxy.go @@ -30,9 +30,9 @@ type WGEBPFProxy struct { proxyPort int mtu uint16 - ebpfManager ebpfMgr.Manager - turnConnStore map[uint16]net.Conn - turnConnMutex sync.Mutex + ebpfManager ebpfMgr.Manager + relayedConnStore map[uint16]net.Conn + relayedConnMutex sync.Mutex lastUsedPort uint16 rawConnIPv4 net.PacketConn @@ -50,7 +50,7 @@ func NewWGEBPFProxy(wgPort int, mtu uint16) *WGEBPFProxy { localWGListenPort: wgPort, mtu: mtu, ebpfManager: ebpf.GetEbpfManagerInstance(), - turnConnStore: make(map[uint16]net.Conn), + relayedConnStore: make(map[uint16]net.Conn), } return wgProxy } @@ -110,14 +110,14 @@ func (p *WGEBPFProxy) Listen() error { return nil } -// AddTurnConn add new turn connection for the proxy -func (p *WGEBPFProxy) AddTurnConn(turnConn net.Conn) (*net.UDPAddr, error) { - wgEndpointPort, err := p.storeTurnConn(turnConn) +// AddRelayedConn add new relayed connection for the proxy +func (p *WGEBPFProxy) AddRelayedConn(relayedConn net.Conn) (*net.UDPAddr, error) { + wgEndpointPort, err := p.storeRelayedConn(relayedConn) if err != nil { return nil, err } - log.Infof("turn conn added to wg proxy store: %s, endpoint port: :%d", turnConn.RemoteAddr(), wgEndpointPort) + log.Infof("relayed conn added to wg proxy store: %s, endpoint port: :%d", relayedConn.RemoteAddr(), wgEndpointPort) wgEndpoint := &net.UDPAddr{ IP: net.ParseIP(loopbackAddr), @@ -186,48 +186,48 @@ func (p *WGEBPFProxy) readAndForwardPacket(buf []byte) error { return fmt.Errorf("failed to read UDP packet from WG: %w", err) } - p.turnConnMutex.Lock() - conn, ok := p.turnConnStore[uint16(addr.Port)] - p.turnConnMutex.Unlock() + p.relayedConnMutex.Lock() + conn, ok := p.relayedConnStore[uint16(addr.Port)] + p.relayedConnMutex.Unlock() if !ok { if p.ctx.Err() == nil { - log.Debugf("turn conn not found by port because conn already has been closed: %d", addr.Port) + log.Debugf("relayed conn not found by port because conn already has been closed: %d", addr.Port) } return nil } if _, err := conn.Write(buf[:n]); err != nil { - return fmt.Errorf("failed to forward local WG packet (%d) to remote turn conn: %w", addr.Port, err) + return fmt.Errorf("forward local WG packet (%d) to remote relayed conn: %w", addr.Port, err) } return nil } -func (p *WGEBPFProxy) storeTurnConn(turnConn net.Conn) (uint16, error) { - p.turnConnMutex.Lock() - defer p.turnConnMutex.Unlock() +func (p *WGEBPFProxy) storeRelayedConn(relayedConn net.Conn) (uint16, error) { + p.relayedConnMutex.Lock() + defer p.relayedConnMutex.Unlock() np, err := p.nextFreePort() if err != nil { return np, err } - p.turnConnStore[np] = turnConn + p.relayedConnStore[np] = relayedConn return np, nil } -func (p *WGEBPFProxy) removeTurnConn(turnConnID uint16) { - p.turnConnMutex.Lock() - defer p.turnConnMutex.Unlock() +func (p *WGEBPFProxy) removeRelayedConn(relayedConnID uint16) { + p.relayedConnMutex.Lock() + defer p.relayedConnMutex.Unlock() - _, ok := p.turnConnStore[turnConnID] + _, ok := p.relayedConnStore[relayedConnID] if ok { - log.Debugf("remove turn conn from store by port: %d", turnConnID) + log.Debugf("remove relayed conn from store by port: %d", relayedConnID) } - delete(p.turnConnStore, turnConnID) + delete(p.relayedConnStore, relayedConnID) } func (p *WGEBPFProxy) nextFreePort() (uint16, error) { - if len(p.turnConnStore) == 65535 { - return 0, fmt.Errorf("reached maximum turn connection numbers") + if len(p.relayedConnStore) == 65535 { + return 0, fmt.Errorf("reached maximum relayed connection numbers") } generatePort: if p.lastUsedPort == 65535 { @@ -236,7 +236,7 @@ generatePort: p.lastUsedPort++ } - if _, ok := p.turnConnStore[p.lastUsedPort]; ok { + if _, ok := p.relayedConnStore[p.lastUsedPort]; ok { goto generatePort } return p.lastUsedPort, nil diff --git a/client/iface/wgproxy/ebpf/proxy_test.go b/client/iface/wgproxy/ebpf/proxy_test.go index 3ec4f0eba..228c06c9b 100644 --- a/client/iface/wgproxy/ebpf/proxy_test.go +++ b/client/iface/wgproxy/ebpf/proxy_test.go @@ -9,32 +9,32 @@ import ( func TestWGEBPFProxy_connStore(t *testing.T) { wgProxy := NewWGEBPFProxy(1, 1280) - p, _ := wgProxy.storeTurnConn(nil) + p, _ := wgProxy.storeRelayedConn(nil) if p != 1 { t.Errorf("invalid initial port: %d", wgProxy.lastUsedPort) } numOfConns := 10 for i := 0; i < numOfConns; i++ { - p, _ = wgProxy.storeTurnConn(nil) + p, _ = wgProxy.storeRelayedConn(nil) } if p != uint16(numOfConns)+1 { t.Errorf("invalid last used port: %d, expected: %d", p, numOfConns+1) } - if len(wgProxy.turnConnStore) != numOfConns+1 { - t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), numOfConns+1) + if len(wgProxy.relayedConnStore) != numOfConns+1 { + t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), numOfConns+1) } } func TestWGEBPFProxy_portCalculation_overflow(t *testing.T) { wgProxy := NewWGEBPFProxy(1, 1280) - _, _ = wgProxy.storeTurnConn(nil) + _, _ = wgProxy.storeRelayedConn(nil) wgProxy.lastUsedPort = 65535 - p, _ := wgProxy.storeTurnConn(nil) + p, _ := wgProxy.storeRelayedConn(nil) - if len(wgProxy.turnConnStore) != 2 { - t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.turnConnStore), 2) + if len(wgProxy.relayedConnStore) != 2 { + t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), 2) } if p != 2 { @@ -46,11 +46,11 @@ func TestWGEBPFProxy_portCalculation_maxConn(t *testing.T) { wgProxy := NewWGEBPFProxy(1, 1280) for i := 0; i < 65535; i++ { - _, _ = wgProxy.storeTurnConn(nil) + _, _ = wgProxy.storeRelayedConn(nil) } - _, err := wgProxy.storeTurnConn(nil) + _, err := wgProxy.storeRelayedConn(nil) if err == nil { - t.Errorf("invalid turn conn store calculation") + t.Errorf("invalid relayed conn store calculation") } } diff --git a/client/iface/wgproxy/ebpf/wrapper.go b/client/iface/wgproxy/ebpf/wrapper.go index 6e80945c4..f75e21aa6 100644 --- a/client/iface/wgproxy/ebpf/wrapper.go +++ b/client/iface/wgproxy/ebpf/wrapper.go @@ -121,10 +121,10 @@ func NewProxyWrapper(proxy *WGEBPFProxy) *ProxyWrapper { } } -func (p *ProxyWrapper) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { - addr, err := p.wgeBPFProxy.AddTurnConn(remoteConn) +func (p *ProxyWrapper) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { + addr, err := p.wgeBPFProxy.AddRelayedConn(remoteConn) if err != nil { - return fmt.Errorf("add turn conn: %w", err) + return fmt.Errorf("add relayed conn: %w", err) } headers, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, addr) @@ -219,6 +219,17 @@ func (p *ProxyWrapper) RedirectAs(endpoint *net.UDPAddr) { p.pausedCond.L.Unlock() } +// InjectPacket writes b to the remote peer over the underlying transport. +func (p *ProxyWrapper) InjectPacket(b []byte) error { + if p.remoteConn == nil { + return errors.New("proxy not started") + } + if _, err := p.remoteConn.Write(b); err != nil { + return err + } + return nil +} + // CloseConn close the remoteConn and automatically remove the conn instance from the map func (p *ProxyWrapper) CloseConn() error { if p.cancel == nil { @@ -241,7 +252,7 @@ func (p *ProxyWrapper) CloseConn() error { } func (p *ProxyWrapper) proxyToLocal(ctx context.Context) { - defer p.wgeBPFProxy.removeTurnConn(uint16(p.wgRelayedEndpointAddr.Port)) + defer p.wgeBPFProxy.removeRelayedConn(uint16(p.wgRelayedEndpointAddr.Port)) buf := make([]byte, p.wgeBPFProxy.mtu+bufsize.WGBufferOverhead) for { @@ -262,7 +273,7 @@ func (p *ProxyWrapper) proxyToLocal(ctx context.Context) { if ctx.Err() != nil { return } - log.Errorf("failed to write out turn pkg to local conn: %v", err) + log.Errorf("failed to write out relayed pkg to local conn: %v", err) } } } @@ -275,7 +286,7 @@ func (p *ProxyWrapper) readFromRemote(ctx context.Context, buf []byte) (int, err } p.closeListener.Notify() if !errors.Is(err, io.EOF) { - log.Errorf("failed to read from turn conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err) + log.Errorf("failed to read from relayed conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err) } return 0, err } diff --git a/client/iface/wgproxy/proxy.go b/client/iface/wgproxy/proxy.go index 3c8dfd30e..b0033bffa 100644 --- a/client/iface/wgproxy/proxy.go +++ b/client/iface/wgproxy/proxy.go @@ -7,7 +7,7 @@ import ( // Proxy is a transfer layer between the relayed connection and the WireGuard type Proxy interface { - AddTurnConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error + AddRelayedConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error EndpointAddr() *net.UDPAddr // EndpointAddr returns the address of the WireGuard peer endpoint Work() // Work start or resume the proxy Pause() // Pause to forward the packages from remote connection to WireGuard. The opposite way still works. @@ -18,4 +18,9 @@ type Proxy interface { RedirectAs(endpoint *net.UDPAddr) CloseConn() error SetDisconnectListener(disconnected func()) + + // InjectPacket writes a raw packet directly to the remote peer over the underlying transport, + // bypassing WireGuard. Used to replay the captured lazyconn handshake initiation. Only the + // kernel-mode proxies act on it; the userspace proxy is a no-op since reinjection is kernel-only. + InjectPacket(b []byte) error } diff --git a/client/iface/wgproxy/proxy_linux_test.go b/client/iface/wgproxy/proxy_linux_test.go index 7f7abcb4a..e34dd3b6b 100644 --- a/client/iface/wgproxy/proxy_linux_test.go +++ b/client/iface/wgproxy/proxy_linux_test.go @@ -1,4 +1,4 @@ -//go:build linux && !android +//go:build linux && !android && privileged package wgproxy diff --git a/client/iface/wgproxy/proxy_seed_test.go b/client/iface/wgproxy/proxy_seed_test.go index 9278029a5..4fb9ed77a 100644 --- a/client/iface/wgproxy/proxy_seed_test.go +++ b/client/iface/wgproxy/proxy_seed_test.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build !linux || !privileged package wgproxy diff --git a/client/iface/wgproxy/proxy_test.go b/client/iface/wgproxy/proxy_test.go index 1aeab66b7..d86cdbe80 100644 --- a/client/iface/wgproxy/proxy_test.go +++ b/client/iface/wgproxy/proxy_test.go @@ -95,7 +95,7 @@ func TestProxyCloseByRemoteConn(t *testing.T) { t.Run(tt.name, func(t *testing.T) { addr, _ := net.ResolveUDPAddr("udp", "100.108.135.221:51892") relayedConn := newMockConn() - err := tt.proxy.AddTurnConn(ctx, addr, relayedConn) + err := tt.proxy.AddRelayedConn(ctx, addr, relayedConn) if err != nil { t.Errorf("error: %v", err) } @@ -157,7 +157,7 @@ func redirectTraffic(t *testing.T, proxy Proxy, wgPort int, endPointAddr *net.UD _ = relayedServer.Close() }() - if err := proxy.AddTurnConn(context.Background(), endPointAddr, relayedConn); err != nil { + if err := proxy.AddRelayedConn(context.Background(), endPointAddr, relayedConn); err != nil { t.Errorf("error: %v", err) } defer func() { diff --git a/client/iface/wgproxy/redirect_test.go b/client/iface/wgproxy/redirect_test.go index b52eead25..f0d59cc64 100644 --- a/client/iface/wgproxy/redirect_test.go +++ b/client/iface/wgproxy/redirect_test.go @@ -1,4 +1,4 @@ -//go:build linux && !android +//go:build linux && !android && privileged package wgproxy @@ -26,64 +26,6 @@ func compareUDPAddr(addr1, addr2 net.Addr) bool { return udpAddr1.IP.Equal(udpAddr2.IP) && udpAddr1.Port == udpAddr2.Port } -// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses -func TestRedirectAs_eBPF_IPv4(t *testing.T) { - wgPort := 51850 - ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) - if err := ebpfProxy.Listen(); err != nil { - t.Fatalf("failed to initialize ebpf proxy: %v", err) - } - defer func() { - if err := ebpfProxy.Free(); err != nil { - t.Errorf("failed to free ebpf proxy: %v", err) - } - }() - - proxy := ebpf.NewProxyWrapper(ebpfProxy) - - // NetBird UDP address of the remote peer - nbAddr := &net.UDPAddr{ - IP: net.ParseIP("100.108.111.177"), - Port: 38746, - } - - p2pEndpoint := &net.UDPAddr{ - IP: net.ParseIP("192.168.0.56"), - Port: 51820, - } - - testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint) -} - -// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses -func TestRedirectAs_eBPF_IPv6(t *testing.T) { - wgPort := 51851 - ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) - if err := ebpfProxy.Listen(); err != nil { - t.Fatalf("failed to initialize ebpf proxy: %v", err) - } - defer func() { - if err := ebpfProxy.Free(); err != nil { - t.Errorf("failed to free ebpf proxy: %v", err) - } - }() - - proxy := ebpf.NewProxyWrapper(ebpfProxy) - - // NetBird UDP address of the remote peer - nbAddr := &net.UDPAddr{ - IP: net.ParseIP("100.108.111.177"), - Port: 38746, - } - - p2pEndpoint := &net.UDPAddr{ - IP: net.ParseIP("fe80::56"), - Port: 51820, - } - - testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint) -} - // TestRedirectAs_UDP_IPv4 tests RedirectAs with UDP proxy using IPv4 addresses func TestRedirectAs_UDP_IPv4(t *testing.T) { wgPort := 51852 @@ -177,9 +119,9 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint * } defer relayConn.Close() - // Add TURN connection to proxy - if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil { - t.Fatalf("failed to add TURN connection: %v", err) + // Add relayed connection to proxy + if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil { + t.Fatalf("failed to add relayed connection: %v", err) } defer func() { if err := proxy.CloseConn(); err != nil { @@ -256,6 +198,64 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint * } } +// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses +func TestRedirectAs_eBPF_IPv4(t *testing.T) { + wgPort := 51850 + ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) + if err := ebpfProxy.Listen(); err != nil { + t.Fatalf("failed to initialize ebpf proxy: %v", err) + } + defer func() { + if err := ebpfProxy.Free(); err != nil { + t.Errorf("failed to free ebpf proxy: %v", err) + } + }() + + proxy := ebpf.NewProxyWrapper(ebpfProxy) + + // NetBird UDP address of the remote peer + nbAddr := &net.UDPAddr{ + IP: net.ParseIP("100.108.111.177"), + Port: 38746, + } + + p2pEndpoint := &net.UDPAddr{ + IP: net.ParseIP("192.168.0.56"), + Port: 51820, + } + + testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint) +} + +// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses +func TestRedirectAs_eBPF_IPv6(t *testing.T) { + wgPort := 51851 + ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) + if err := ebpfProxy.Listen(); err != nil { + t.Fatalf("failed to initialize ebpf proxy: %v", err) + } + defer func() { + if err := ebpfProxy.Free(); err != nil { + t.Errorf("failed to free ebpf proxy: %v", err) + } + }() + + proxy := ebpf.NewProxyWrapper(ebpfProxy) + + // NetBird UDP address of the remote peer + nbAddr := &net.UDPAddr{ + IP: net.ParseIP("100.108.111.177"), + Port: 38746, + } + + p2pEndpoint := &net.UDPAddr{ + IP: net.ParseIP("fe80::56"), + Port: 51820, + } + + testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint) +} + // TestRedirectAs_Multiple_Switches tests switching between multiple endpoints func TestRedirectAs_Multiple_Switches(t *testing.T) { wgPort := 51856 @@ -304,8 +304,8 @@ func TestRedirectAs_Multiple_Switches(t *testing.T) { Port: 38746, } - if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil { - t.Fatalf("failed to add TURN connection: %v", err) + if err := proxy.AddRelayedConn(ctx, nbAddr, relayConn); err != nil { + t.Fatalf("failed to add relayed connection: %v", err) } defer func() { if err := proxy.CloseConn(); err != nil { diff --git a/client/iface/wgproxy/udp/proxy.go b/client/iface/wgproxy/udp/proxy.go index 6069d1960..a0895c8c7 100644 --- a/client/iface/wgproxy/udp/proxy.go +++ b/client/iface/wgproxy/udp/proxy.go @@ -51,12 +51,12 @@ func NewWGUDPProxy(wgPort int, mtu uint16) *WGUDPProxy { return p } -// AddTurnConn +// AddRelayedConn dials the local WireGuard port and stores the relayed connection. // The provided Context must be non-nil. If the context expires before // the connection is complete, an error is returned. Once successfully // connected, any expiration of the context will not affect the // connection. -func (p *WGUDPProxy) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { +func (p *WGUDPProxy) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { dialer := net.Dialer{} localConn, err := dialer.DialContext(ctx, "udp", fmt.Sprintf(":%d", p.localWGListenPort)) if err != nil { @@ -147,6 +147,17 @@ func (p *WGUDPProxy) RedirectAs(endpoint *net.UDPAddr) { p.sendPkg = p.srcFakerConn.SendPkg } +// InjectPacket writes b to the remote peer over the underlying transport. +func (p *WGUDPProxy) InjectPacket(b []byte) error { + if p.remoteConn == nil { + return errors.New("proxy not started") + } + if _, err := p.remoteConn.Write(b); err != nil { + return err + } + return nil +} + // CloseConn close the localConn func (p *WGUDPProxy) CloseConn() error { if p.cancel == nil { diff --git a/client/installer.nsis b/client/installer.nsis index 63bff1c5b..eb2d7d5bd 100644 --- a/client/installer.nsis +++ b/client/installer.nsis @@ -6,7 +6,7 @@ !define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls." !define INSTALLER_NAME "netbird-installer.exe" !define MAIN_APP_EXE "Netbird" -!define ICON "ui\\assets\\netbird.ico" +!define ICON "ui\\build\\windows\\icon.ico" !define BANNER "ui\\build\\banner.bmp" !define LICENSE_DATA "..\\LICENSE" @@ -22,8 +22,6 @@ !define UI_REG_APP_PATH "Software\Microsoft\Windows\CurrentVersion\App Paths\${UI_APP_EXE}" !define UI_UNINSTALL_PATH "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UI_APP_NAME}" -!define AUTOSTART_REG_KEY "Software\Microsoft\Windows\CurrentVersion\Run" - !define NETBIRD_DATA_DIR "$COMMONPROGRAMDATA\Netbird" Unicode True @@ -79,8 +77,6 @@ ShowInstDetails Show !insertmacro MUI_PAGE_DIRECTORY -Page custom AutostartPage AutostartPageLeave - !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_FINISH @@ -97,40 +93,12 @@ UninstPage custom un.DeleteDataPage un.DeleteDataPageLeave !insertmacro MUI_LANGUAGE "English" -; Variables for autostart option -Var AutostartCheckbox -Var AutostartEnabled - ; Variables for uninstall data deletion option Var DeleteDataCheckbox Var DeleteDataEnabled ###################################################################### -; Function to create the autostart options page -Function AutostartPage - !insertmacro MUI_HEADER_TEXT "Startup Options" "Configure how ${APP_NAME} launches with Windows." - - nsDialogs::Create 1018 - Pop $0 - - ${If} $0 == error - Abort - ${EndIf} - - ${NSD_CreateCheckbox} 0 20u 100% 10u "Start ${APP_NAME} UI automatically when Windows starts" - Pop $AutostartCheckbox - ${NSD_Check} $AutostartCheckbox - StrCpy $AutostartEnabled "1" - - nsDialogs::Show -FunctionEnd - -; Function to handle leaving the autostart page -Function AutostartPageLeave - ${NSD_GetState} $AutostartCheckbox $AutostartEnabled -FunctionEnd - ; Function to create the uninstall data deletion page Function un.DeleteDataPage !insertmacro MUI_HEADER_TEXT "Uninstall Options" "Choose whether to delete ${APP_NAME} data." @@ -201,8 +169,6 @@ Pop $0 Function .onInit StrCpy $INSTDIR "${INSTALL_DIR}" -; Default autostart to enabled so silent installs (/S) match the interactive default -StrCpy $AutostartEnabled "1" ; Pre-0.70.1 installers ran without SetRegView, so their uninstall keys live ; in the 32-bit view. Fall back to it so upgrades still find them. @@ -260,18 +226,6 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}" WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}" -; Create autostart registry entry based on checkbox -DetailPrint "Autostart enabled: $AutostartEnabled" -${If} $AutostartEnabled == "1" - WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"' - DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe" -${Else} - DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" - ; Legacy: pre-HKLM installs wrote to HKCU; clean that up too. - DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" - DetailPrint "Autostart not enabled by user" -${EndIf} - EnVar::SetHKLM EnVar::AddValueEx "path" "$INSTDIR" @@ -280,6 +234,43 @@ CreateShortCut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}" CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}" SectionEnd +# Install the Microsoft Edge WebView2 runtime if it isn't already present. +# Macro adapted from Wails3's NSIS template (wails_tools.nsh): a registry +# probe followed by a silent install of the embedded evergreen bootstrapper. +# The MicrosoftEdgeWebview2Setup.exe payload is staged next to this script +# by the sign-pipelines build step (`wails3 generate webview2bootstrapper`). +!macro nb.webview2runtime + SetRegView 64 + # Per-machine install marker — populated when the runtime ships with + # Edge or has been installed by an admin previously. + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto webview2_ok + ${EndIf} + # Per-user fallback for HKCU installs. + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto webview2_ok + ${EndIf} + + SetDetailsPrint both + DetailPrint "Installing: WebView2 Runtime" + SetDetailsPrint listonly + + InitPluginsDir + CreateDirectory "$pluginsdir\webview2bootstrapper" + SetOutPath "$pluginsdir\webview2bootstrapper" + File "MicrosoftEdgeWebview2Setup.exe" + ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' + + SetDetailsPrint both + webview2_ok: +!macroend + +Section -WebView2 + !insertmacro nb.webview2runtime +SectionEnd + Section -Post ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install' ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start' @@ -299,12 +290,6 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall' DetailPrint "Terminating Netbird UI process..." ExecWait `taskkill /im ${UI_APP_EXE}.exe /f` -; Remove autostart registry entry -DetailPrint "Removing autostart registry entry if exists..." -DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" -; Legacy: pre-HKLM installs wrote to HKCU; clean that up too. -DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" - ; Handle data deletion based on checkbox DetailPrint "Checking if user requested data deletion..." ${If} $DeleteDataEnabled == "1" @@ -326,9 +311,9 @@ DetailPrint "Deleting application files..." Delete "$INSTDIR\${UI_APP_EXE}" Delete "$INSTDIR\${MAIN_APP_EXE}" Delete "$INSTDIR\wintun.dll" -!if ${ARCH} == "amd64" +# Legacy: pre-Wails installs shipped opengl32.dll (Mesa3D for Fyne); remove +# any leftover copy on uninstall so old upgrades don't leave it behind. Delete "$INSTDIR\opengl32.dll" -!endif DetailPrint "Removing application directory..." RmDir /r "$INSTDIR" diff --git a/client/internal/acl/manager.go b/client/internal/acl/manager.go index c54a3e897..cbd9c5ab1 100644 --- a/client/internal/acl/manager.go +++ b/client/internal/acl/manager.go @@ -11,6 +11,7 @@ import ( "time" "github.com/hashicorp/go-multierror" + "github.com/mitchellh/hashstructure/v2" log "github.com/sirupsen/logrus" nberrors "github.com/netbirdio/netbird/client/errors" @@ -30,11 +31,13 @@ type Manager interface { // DefaultManager uses firewall manager to handle type DefaultManager struct { - firewall firewall.Manager - ipsetCounter int - peerRulesPairs map[id.RuleID][]firewall.Rule - routeRules map[id.RuleID]struct{} - mutex sync.Mutex + firewall firewall.Manager + ipsetCounter int + peerRulesPairs map[id.RuleID][]firewall.Rule + routeRules map[id.RuleID]struct{} + previousConfigHash uint64 + hasAppliedConfig bool + mutex sync.Mutex } func NewDefaultManager(fm firewall.Manager) *DefaultManager { @@ -57,6 +60,23 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout return } + // Skip the full rebuild + flush when the inputs that drive the firewall + // state are byte-for-byte identical to the last successfully applied + // update. Management re-sends the same network map far more often than it + // actually changes (account-wide updates, peer meta churn), and rebuilding + // every peer/route ACL and flushing the firewall on every such sync is the + // dominant client-side cost when nothing changed. Mirrors the same guard the + // DNS server already uses (previousConfigHash). Only the fields ApplyFiltering + // consumes participate in the hash, so an unrelated map change cannot mask a + // real ACL change. + hash, err := d.firewallConfigHash(networkMap, dnsRouteFeatureFlag) + if err != nil { + log.Errorf("unable to hash firewall configuration, applying unconditionally: %v", err) + } else if d.hasAppliedConfig && d.previousConfigHash == hash { + log.Debugf("not applying the firewall configuration update as there is nothing new (hash: %d)", hash) + return + } + start := time.Now() defer func() { total := 0 @@ -70,13 +90,49 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout d.applyPeerACLs(networkMap) - if err := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag); err != nil { - log.Errorf("Failed to apply route ACLs: %v", err) + routeErr := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag) + if routeErr != nil { + log.Errorf("Failed to apply route ACLs: %v", routeErr) } - if err := d.firewall.Flush(); err != nil { - log.Error("failed to flush firewall rules: ", err) + flushErr := d.firewall.Flush() + if flushErr != nil { + log.Error("failed to flush firewall rules: ", flushErr) } + + // Only remember the hash once the firewall actually reflects this config. + // If applying or flushing failed, leave the previous hash untouched so the + // next (possibly identical) update is not skipped and gets a chance to + // reconcile the firewall state. + if err == nil && routeErr == nil && flushErr == nil { + d.previousConfigHash = hash + d.hasAppliedConfig = true + } else { + d.hasAppliedConfig = false + } +} + +// firewallConfigHash hashes exactly the inputs ApplyFiltering uses to build the +// firewall state, so an identical hash means an identical resulting ruleset. +func (d *DefaultManager) firewallConfigHash(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool) (uint64, error) { + return hashstructure.Hash(struct { + PeerRules []*mgmProto.FirewallRule + PeerRulesIsEmpty bool + RouteRules []*mgmProto.RouteFirewallRule + RouteRulesIsEmpty bool + DNSRouteFeatureFlag bool + }{ + PeerRules: networkMap.GetFirewallRules(), + PeerRulesIsEmpty: networkMap.GetFirewallRulesIsEmpty(), + RouteRules: networkMap.GetRoutesFirewallRules(), + RouteRulesIsEmpty: networkMap.GetRoutesFirewallRulesIsEmpty(), + DNSRouteFeatureFlag: dnsRouteFeatureFlag, + }, hashstructure.FormatV2, &hashstructure.HashOptions{ + ZeroNil: true, + IgnoreZeroValue: true, + SlicesAsSets: true, + UseStringer: true, + }) } func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { @@ -88,13 +144,13 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { log.Warn("this peer is connected to a NetBird Management service with an older version. Allowing all traffic from connected peers") rules = append(rules, &mgmProto.FirewallRule{ - PeerIP: "0.0.0.0", + PeerIP: "0.0.0.0", //nolint:staticcheck Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_ALL, }, &mgmProto.FirewallRule{ - PeerIP: "0.0.0.0", + PeerIP: "0.0.0.0", //nolint:staticcheck Direction: mgmProto.RuleDirection_OUT, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_ALL, @@ -351,7 +407,6 @@ func (d *DefaultManager) getRuleGroupingSelector(rule *mgmProto.FirewallRule) st return fmt.Sprintf("%v:%v:%v:%s:%v", strconv.Itoa(int(rule.Direction)), rule.Action, rule.Protocol, rule.Port, rule.PortInfo) } - // extractRuleIP extracts the peer IP from a firewall rule. // If sourcePrefixes is populated (new management), decode the first entry and use its address. // Otherwise fall back to the deprecated PeerIP string field (old management). diff --git a/client/internal/acl/manager_test.go b/client/internal/acl/manager_test.go index 408ed992f..8f737706e 100644 --- a/client/internal/acl/manager_test.go +++ b/client/internal/acl/manager_test.go @@ -1,12 +1,13 @@ package acl import ( + "fmt" "net/netip" "testing" - "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "github.com/netbirdio/netbird/client/firewall" "github.com/netbirdio/netbird/client/iface" @@ -86,7 +87,7 @@ func TestDefaultManager(t *testing.T) { networkMap.FirewallRules = append( networkMap.FirewallRules, &mgmProto.FirewallRule{ - PeerIP: "10.93.0.3", + PeerIP: "10.93.0.3", //nolint:staticcheck Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_DROP, Protocol: mgmProto.RuleProtocol_ICMP, @@ -485,3 +486,149 @@ func TestPortInfoEmpty(t *testing.T) { }) } } + +// TestApplyFilteringSkipsUnchangedConfig verifies that an identical network map +// re-applied is recognized as a no-op (hash unchanged), while a real change to +// any firewall-relevant input forces a re-apply (hash changes). This is the +// guard that prevents a full ruleset rebuild + flush on every redundant sync. +func TestApplyFilteringSkipsUnchangedConfig(t *testing.T) { + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + t.Setenv(firewall.EnvForceUserspaceFirewall, "true") + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ifaceMock := mocks.NewMockIFaceMapper(ctrl) + ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes() + ifaceMock.EXPECT().SetFilter(gomock.Any()) + network := netip.MustParsePrefix("172.0.0.1/32") + ifaceMock.EXPECT().Name().Return("lo").AnyTimes() + ifaceMock.EXPECT().Address().Return(wgaddr.Address{ + IP: network.Addr(), + Network: network, + }).AnyTimes() + ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes() + + fw, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU) + require.NoError(t, err) + defer func() { + require.NoError(t, fw.Close(nil)) + }() + + acl := NewDefaultManager(fw) + + networkMap := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + { + PeerIP: "10.93.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "22", + }, + }, + FirewallRulesIsEmpty: false, + } + + acl.ApplyFiltering(networkMap, false) + require.True(t, acl.hasAppliedConfig, "config should be marked applied after first apply") + firstHash := acl.previousConfigHash + require.NotZero(t, firstHash) + + // Re-applying the identical map must not change the recorded hash: the + // expensive rebuild path was skipped. + acl.ApplyFiltering(networkMap, false) + assert.Equal(t, firstHash, acl.previousConfigHash, + "identical re-apply must be a no-op (hash unchanged)") + + // A real change must produce a different hash and re-apply. + networkMap.FirewallRules[0].Action = mgmProto.RuleAction_DROP + acl.ApplyFiltering(networkMap, false) + assert.NotEqual(t, firstHash, acl.previousConfigHash, + "changing a rule's action must force a re-apply (hash changed)") + + // The dnsRouteFeatureFlag also participates in the hash. + changedHash := acl.previousConfigHash + acl.ApplyFiltering(networkMap, true) + assert.NotEqual(t, changedHash, acl.previousConfigHash, + "flipping dnsRouteFeatureFlag must force a re-apply (hash changed)") +} + +func buildNetworkMap(peerRules, routeRules int) *mgmProto.NetworkMap { + nm := &mgmProto.NetworkMap{ + FirewallRulesIsEmpty: peerRules == 0, + RoutesFirewallRulesIsEmpty: routeRules == 0, + } + for i := range peerRules { + nm.FirewallRules = append(nm.FirewallRules, &mgmProto.FirewallRule{ + PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff), //nolint:staticcheck + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: fmt.Sprintf("%d", 1024+i%64511), + }) + } + for i := range routeRules { + nm.RoutesFirewallRules = append(nm.RoutesFirewallRules, &mgmProto.RouteFirewallRule{ + Destination: fmt.Sprintf("192.168.%d.0/24", i%256), + SourceRanges: []string{fmt.Sprintf("10.0.%d.0/24", i%256)}, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_ALL, + }) + } + return nm +} + +func BenchmarkFirewallConfigHash_Small(b *testing.B) { + d := &DefaultManager{} + nm := buildNetworkMap(10, 5) + b.ResetTimer() + for b.Loop() { + _, _ = d.firewallConfigHash(nm, false) + } +} + +func BenchmarkFirewallConfigHash_Medium(b *testing.B) { + d := &DefaultManager{} + nm := buildNetworkMap(100, 50) + b.ResetTimer() + for b.Loop() { + _, _ = d.firewallConfigHash(nm, false) + } +} + +func BenchmarkFirewallConfigHash_Large(b *testing.B) { + d := &DefaultManager{} + nm := buildNetworkMap(1000, 200) + b.ResetTimer() + for b.Loop() { + _, _ = d.firewallConfigHash(nm, false) + } +} + +// TestFirewallConfigHashDeterministic verifies the hash is stable for equal +// inputs and order-independent for the rule slices (management does not +// guarantee rule order). +func TestFirewallConfigHashDeterministic(t *testing.T) { + d := &DefaultManager{} + + nm1 := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + {PeerIP: "10.0.0.1", Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_TCP, Port: "22"}, + {PeerIP: "10.0.0.2", Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_DROP, Protocol: mgmProto.RuleProtocol_TCP, Port: "80"}, + }, + } + // Same rules, reversed order. + nm2 := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + nm1.FirewallRules[1], + nm1.FirewallRules[0], + }, + } + + h1, err := d.firewallConfigHash(nm1, false) + require.NoError(t, err) + h2, err := d.firewallConfigHash(nm2, false) + require.NoError(t, err) + assert.Equal(t, h1, h2, "hash must be order-independent for rule slices") +} diff --git a/client/internal/acl/mocks/iface_mapper.go b/client/internal/acl/mocks/iface_mapper.go index 95d5a2c58..f8cca1c2d 100644 --- a/client/internal/acl/mocks/iface_mapper.go +++ b/client/internal/acl/mocks/iface_mapper.go @@ -7,7 +7,7 @@ package mocks import ( reflect "reflect" - gomock "github.com/golang/mock/gomock" + gomock "go.uber.org/mock/gomock" wgdevice "golang.zx2c4.com/wireguard/device" "github.com/netbirdio/netbird/client/iface/device" diff --git a/client/internal/auth/auth.go b/client/internal/auth/auth.go index afc8ee77f..b3a9e1158 100644 --- a/client/internal/auth/auth.go +++ b/client/internal/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( "context" "net/url" + "strings" "sync" "time" @@ -21,6 +22,25 @@ import ( mgmProto "github.com/netbirdio/netbird/shared/management/proto" ) +// peerLoginExpiredMsg is the exact phrase the management server returns +// when a previously SSO-enrolled peer's login has expired. Sourced from +// shared/management/status/error.go (NewPeerLoginExpiredError). Matched +// by substring so a future server-side rewording that keeps the phrase +// still triggers the friendly fallback in Login(). +const peerLoginExpiredMsg = "peer login has expired" + +// errSetupKeyOnSSOExpiredPeer replaces the raw management error when the +// user runs `netbird login -k ` against a peer that was +// originally enrolled via SSO. Wrapped in a PermissionDenied gRPC status +// so callers' existing isPermissionDenied / isAuthError checks still +// classify it correctly (early-exit from retry backoff, StatusNeedsLogin +// in the server state machine). +var errSetupKeyOnSSOExpiredPeer = status.Error( + codes.PermissionDenied, + "this peer was originally enrolled via SSO and its session has expired. "+ + "Setup keys can only enrol new peers — run `netbird up` (interactive SSO) to re-login.", +) + // Auth manages authentication operations with the management server // It maintains a long-lived connection and automatically handles reconnection with backoff type Auth struct { @@ -118,26 +138,37 @@ func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) { // GetOAuthFlow returns an OAuth flow (PKCE or Device) using the existing management connection // This avoids creating a new connection to the management server -func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool) (OAuthFlow, error) { +func (a *Auth) GetOAuthFlow(ctx context.Context, forceDeviceAuth bool, hint string) (OAuthFlow, error) { var flow OAuthFlow - var err error - err = a.withRetry(ctx, func(client *mgm.GrpcClient) error { + err := a.withRetry(ctx, func(client *mgm.GrpcClient) error { if forceDeviceAuth { - flow, err = a.getDeviceFlow(client) - return err + deviceFlow, err := a.getDeviceFlow(client) + if err != nil { + return err + } + deviceFlow.SetLoginHint(hint) + flow = deviceFlow + return nil } // Try PKCE flow first - flow, err = a.getPKCEFlow(client) + pkceFlow, err := a.getPKCEFlow(client) if err != nil { // If PKCE not supported, try Device flow if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) { - flow, err = a.getDeviceFlow(client) - return err + deviceFlow, err := a.getDeviceFlow(client) + if err != nil { + return err + } + deviceFlow.SetLoginHint(hint) + flow = deviceFlow + return nil } return err } + pkceFlow.SetLoginHint(hint) + flow = pkceFlow return nil }) @@ -184,6 +215,15 @@ func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (err log.Debugf("peer registration required") _, err = a.registerPeer(client, ctx, setupKey, jwtToken, pubSSHKey) if err != nil { + // The peer pub-key is already on file with the management + // server (originally enrolled via SSO) and the session has + // expired. The setup-key path can only enrol new peers, so + // retrying with -k will keep failing. Replace the raw mgm + // message with an actionable hint that tells the user to + // re-authenticate via SSO instead. + if setupKey != "" && jwtToken == "" && isPeerLoginExpired(err) { + err = errSetupKeyOnSSOExpiredPeer + } isAuthError = isPermissionDenied(err) return err } @@ -322,7 +362,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) { a.config.BlockLANAccess, a.config.BlockInbound, a.config.DisableIPv6, - a.config.LazyConnectionEnabled, + a.config.SyncMessageVersion, a.config.EnableSSHRoot, a.config.EnableSSHSFTP, a.config.EnableSSHLocalPortForwarding, @@ -474,3 +514,16 @@ func isLoginNeeded(err error) bool { func isRegistrationNeeded(err error) bool { return isPermissionDenied(err) } + +// isPeerLoginExpired reports whether err is the management server's +// "peer login has expired" PermissionDenied response. Used by Login to +// detect the case where the caller passed a setup-key but the peer is +// actually an SSO-enrolled record whose session needs refreshing — the +// setup-key path cannot help there. +func isPeerLoginExpired(err error) bool { + if !isPermissionDenied(err) { + return false + } + s, _ := status.FromError(err) + return strings.Contains(s.Message(), peerLoginExpiredMsg) +} diff --git a/client/internal/auth/auth_test.go b/client/internal/auth/auth_test.go new file mode 100644 index 000000000..e393beccb --- /dev/null +++ b/client/internal/auth/auth_test.go @@ -0,0 +1,80 @@ +package auth + +import ( + "errors" + "strings" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestIsPeerLoginExpired(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + { + name: "nil", + err: nil, + want: false, + }, + { + name: "plain error (not a gRPC status)", + err: errors.New("network read: connection reset"), + want: false, + }, + { + name: "PermissionDenied with different message", + err: status.Error(codes.PermissionDenied, "user is blocked"), + want: false, + }, + { + name: "Unauthenticated with the expected phrase", + // Wrong status code — must still return false. + err: status.Error(codes.Unauthenticated, "peer login has expired, please log in once more"), + want: false, + }, + { + name: "exact server message", + err: status.Error(codes.PermissionDenied, "peer login has expired, please log in once more"), + want: true, + }, + { + name: "phrase as substring", + // Future-proofing: if mgm reworords but keeps the phrase, + // the friendly fallback must still kick in. + err: status.Error(codes.PermissionDenied, "session refused: peer login has expired (account=foo)"), + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isPeerLoginExpired(tc.err); got != tc.want { + t.Fatalf("isPeerLoginExpired(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} + +func TestErrSetupKeyOnSSOExpiredPeer(t *testing.T) { + // Sentinel must surface as PermissionDenied so the upstream + // isPermissionDenied / isAuthError checks classify it correctly + // (short-circuit retry backoff, set StatusNeedsLogin). + if !isPermissionDenied(errSetupKeyOnSSOExpiredPeer) { + t.Fatalf("errSetupKeyOnSSOExpiredPeer must be a PermissionDenied gRPC error") + } + + // Message must actually mention SSO and `netbird up` so it is + // actionable for the end user. Loose substring checks keep the + // test resilient to copy edits. + s, _ := status.FromError(errSetupKeyOnSSOExpiredPeer) + msg := strings.ToLower(s.Message()) + for _, want := range []string{"sso", "netbird up"} { + if !strings.Contains(msg, want) { + t.Errorf("sentinel message should contain %q, got %q", want, s.Message()) + } + } +} diff --git a/client/internal/auth/device_flow.go b/client/internal/auth/device_flow.go index e33765300..9dec7cf53 100644 --- a/client/internal/auth/device_flow.go +++ b/client/internal/auth/device_flow.go @@ -259,12 +259,18 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn ticker := time.NewTicker(interval) defer ticker.Stop() + log.Infof("device flow: waiting for user authorization, polling token endpoint every %s, code expires in %s", interval, timeout) + + start := time.Now() + polls := 0 + for { select { case <-waitCtx.Done(): return TokenInfo{}, waitCtx.Err() case <-ticker.C: + polls++ tokenResponse, err := d.requestToken(info) if err != nil { return TokenInfo{}, fmt.Errorf("parsing token response failed with error: %v", err) @@ -272,10 +278,12 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn if tokenResponse.Error != "" { if tokenResponse.Error == "authorization_pending" { + log.Tracef("device flow: authorization still pending after poll %d", polls) continue } else if tokenResponse.Error == "slow_down" { interval += (3 * time.Second) ticker.Reset(interval) + log.Infof("device flow: IdP requested slow_down, polling interval increased to %s", interval) continue } @@ -291,11 +299,12 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn UseIDToken: d.providerConfig.UseIDToken, } - err = isValidAccessToken(tokenInfo.GetTokenToUse(), d.providerConfig.Audience) + err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience) if err != nil { return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err) } + log.Infof("device flow: user authorization confirmed after %d polls in %s", polls, time.Since(start).Round(time.Second)) return tokenInfo, err } } diff --git a/client/internal/auth/oauth.go b/client/internal/auth/oauth.go index a50a2ce6f..91329c98b 100644 --- a/client/internal/auth/oauth.go +++ b/client/internal/auth/oauth.go @@ -97,9 +97,7 @@ func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err) } - if hint != "" { - pkceFlowInfo.SetLoginHint(hint) - } + pkceFlowInfo.SetLoginHint(hint) return pkceFlowInfo, nil } @@ -127,9 +125,7 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager. } } - if hint != "" { - deviceFlowInfo.SetLoginHint(hint) - } + deviceFlowInfo.SetLoginHint(hint) return deviceFlowInfo, nil } diff --git a/client/internal/auth/pending_flow.go b/client/internal/auth/pending_flow.go new file mode 100644 index 000000000..daeb18bc2 --- /dev/null +++ b/client/internal/auth/pending_flow.go @@ -0,0 +1,89 @@ +package auth + +import ( + "context" + "sync" + "time" +) + +// PendingFlow stores an in-progress OAuth flow between the RPC that +// initiates it (returns the verification URI to the UI) and the RPC +// that waits for the user to complete it. The flow handle, the +// device-code info, and the absolute expiry are kept together so the +// waiting RPC can validate the device code and reuse the same flow. +// +// PendingFlow is safe for concurrent use; callers must not access the +// stored fields directly. +type PendingFlow struct { + mu sync.Mutex + flow OAuthFlow + info AuthFlowInfo + expiresAt time.Time + waitCancel context.CancelFunc +} + +// NewPendingFlow returns an empty PendingFlow ready to be populated by Set. +func NewPendingFlow() *PendingFlow { + return &PendingFlow{} +} + +// Set stores the flow and its authorization info, computing the absolute +// expiry from info.ExpiresIn (seconds, as returned by the IdP). +func (p *PendingFlow) Set(flow OAuthFlow, info AuthFlowInfo) { + p.mu.Lock() + defer p.mu.Unlock() + p.flow = flow + p.info = info + p.expiresAt = time.Now().Add(time.Duration(info.ExpiresIn) * time.Second) +} + +// Get returns the stored flow, info, and whether a flow is currently +// pending. Returns (nil, zero, false) after Clear or before Set. +func (p *PendingFlow) Get() (OAuthFlow, AuthFlowInfo, bool) { + p.mu.Lock() + defer p.mu.Unlock() + if p.flow == nil { + return nil, AuthFlowInfo{}, false + } + return p.flow, p.info, true +} + +// ExpiresAt returns the absolute expiry of the pending flow. Returns +// the zero time when no flow is pending. +func (p *PendingFlow) ExpiresAt() time.Time { + p.mu.Lock() + defer p.mu.Unlock() + return p.expiresAt +} + +// SetWaitCancel records the cancel function for the goroutine currently +// blocked in WaitToken so a new RequestAuth can preempt it. +func (p *PendingFlow) SetWaitCancel(cancel context.CancelFunc) { + p.mu.Lock() + defer p.mu.Unlock() + p.waitCancel = cancel +} + +// CancelWait invokes and clears the stored wait-cancel, if any. Safe to +// call when no wait is in progress. +func (p *PendingFlow) CancelWait() { + p.mu.Lock() + cancel := p.waitCancel + p.waitCancel = nil + p.mu.Unlock() + if cancel != nil { + cancel() + } +} + +// Clear resets the pending flow to empty. Any stored wait-cancel is +// dropped without being invoked — call CancelWait first if the waiting +// goroutine must be stopped. +func (p *PendingFlow) Clear() { + p.mu.Lock() + defer p.mu.Unlock() + p.flow = nil + p.info = AuthFlowInfo{} + p.expiresAt = time.Time{} + p.waitCancel = nil +} diff --git a/client/internal/auth/pkce_flow.go b/client/internal/auth/pkce_flow.go index 84fa8a214..be64cc6a8 100644 --- a/client/internal/auth/pkce_flow.go +++ b/client/internal/auth/pkce_flow.go @@ -188,6 +188,8 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo waitCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + log.Infof("pkce flow: waiting for authorization callback on %s, timeout %s", p.oAuthConfig.RedirectURL, timeout) + tokenChan := make(chan *oauth2.Token, 1) errChan := make(chan error, 1) @@ -221,6 +223,7 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo func (p *PKCEAuthorizationFlow) startServer(server *http.Server, tokenChan chan<- *oauth2.Token, errChan chan<- error) { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + log.Infof("pkce flow: received authorization callback from IdP") cert := p.providerConfig.ClientCertPair if cert != nil { tr := &http.Transport{ @@ -271,11 +274,18 @@ func (p *PKCEAuthorizationFlow) handleRequest(req *http.Request) (*oauth2.Token, return nil, fmt.Errorf("authentication failed: missing code") } - return p.oAuthConfig.Exchange( + exchangeStart := time.Now() + token, err := p.oAuthConfig.Exchange( req.Context(), code, oauth2.SetAuthURLParam("code_verifier", p.codeVerifier), ) + if err != nil { + return nil, err + } + + log.Infof("pkce flow: authorization code exchanged for token in %s", time.Since(exchangeStart).Round(time.Millisecond)) + return token, nil } func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, error) { @@ -296,7 +306,7 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, audience = p.providerConfig.ClientID } - if err := isValidAccessToken(tokenInfo.GetTokenToUse(), audience); err != nil { + if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil { return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err) } @@ -310,6 +320,11 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, return tokenInfo, nil } +// parseEmailFromIDToken extracts the email (or name) claim from an ID token +// without verifying its signature. The value is best-effort and used only as a +// UX convenience (login hint prefill and display); it never drives an +// authorization decision. The authoritative identity is established server-side +// from the signature-verified token. func parseEmailFromIDToken(token string) (string, error) { parts := strings.Split(token, ".") if len(parts) < 2 { diff --git a/client/internal/auth/sessionwatch/event.go b/client/internal/auth/sessionwatch/event.go new file mode 100644 index 000000000..3e55b26dd --- /dev/null +++ b/client/internal/auth/sessionwatch/event.go @@ -0,0 +1,82 @@ +package sessionwatch + +import ( + "strconv" + "time" +) + +// internal event kinds are no longer exposed: the watcher drives the Sink +// directly (NotifyStateChange on deadline change/clear, PublishEvent at +// each warning lead). Tests use a mock Sink to observe what the watcher +// emits. + +// Metadata keys attached by the daemon to session-warning SystemEvents. +// The UI tray reads these to build a locale-aware notification without +// relying on the daemon's locale-less UserMessage string, and to +// disambiguate the T-WarningLead notification from the T-FinalWarningLead +// fallback that auto-opens the SessionAboutToExpire dialog. +const ( + // MetaSessionWarning is set to "true" on both warning events (T-10 and + // T-2) so the UI can detect a session-warning SystemEvent without + // matching on the message text. Use MetaSessionFinal to distinguish + // the two. + MetaSessionWarning = "session_warning" + // MetaSessionFinal is set to "true" on the T-FinalWarningLead event + // only. Consumers that need to auto-open the SessionAboutToExpire + // dialog gate on this; T-WarningLead events leave the field unset. + MetaSessionFinal = "session_final_warning" + // MetaSessionExpiresAt carries the absolute UTC deadline encoded with + // FormatExpiresAt; consumers must decode with ParseExpiresAt so a + // future format change stays a single edit. + MetaSessionExpiresAt = "session_expires_at" + // MetaSessionLeadMinutes carries the lead in whole minutes (WarningLead + // for the T-10 event, FinalWarningLead for the T-2 event) so the UI + // can show "expires in ~N minutes" without hardcoding either constant. + MetaSessionLeadMinutes = "lead_minutes" + // MetaSessionDeadlineRejected is attached to the ERROR/AUTHENTICATION + // SystemEvent the daemon emits when it discards a deadline from the + // management server (pre-epoch, too far in the future, or past the + // clock-skew tolerance). The value is the rejection reason string. + // userMessage is left empty; the UI detects the event via this key + // and builds a localized notification — same pattern as the session + // warnings above. + MetaSessionDeadlineRejected = "session_deadline_rejected" +) + +// expiresAtLayout is the wire format used for MetaSessionExpiresAt. +// Producer and consumers both go through FormatExpiresAt/ParseExpiresAt +// so this layout stays a single source of truth. +const expiresAtLayout = time.RFC3339 + +// FormatExpiresAt encodes a deadline for MetaSessionExpiresAt. Always +// emits UTC so a consumer in another timezone reads the same wall-clock +// deadline. +func FormatExpiresAt(t time.Time) string { + return t.UTC().Format(expiresAtLayout) +} + +// ParseExpiresAt decodes the MetaSessionExpiresAt value back to a UTC +// time. Returns an error when the field is empty or malformed; the +// caller decides whether to fall back (zero value) or propagate. +func ParseExpiresAt(s string) (time.Time, error) { + t, err := time.Parse(expiresAtLayout, s) + if err != nil { + return time.Time{}, err + } + return t.UTC(), nil +} + +// FormatLeadMinutes encodes a lead duration for MetaSessionLeadMinutes +// as the integer count of whole minutes. Sub-minute residuals are +// truncated — the field is informational ("expires in ~N minutes") and +// fractional minutes don't change what the UI displays. +func FormatLeadMinutes(d time.Duration) string { + return strconv.Itoa(int(d / time.Minute)) +} + +// ParseLeadMinutes decodes a MetaSessionLeadMinutes value. Returns 0 +// and the parse error for malformed input; consumers that prefer a +// silent fallback can simply ignore the error. +func ParseLeadMinutes(s string) (int, error) { + return strconv.Atoi(s) +} diff --git a/client/internal/auth/sessionwatch/watcher.go b/client/internal/auth/sessionwatch/watcher.go new file mode 100644 index 000000000..e685c28d0 --- /dev/null +++ b/client/internal/auth/sessionwatch/watcher.go @@ -0,0 +1,382 @@ +// Package sessionwatch tracks the SSO session expiry deadline that the +// management server publishes via LoginResponse / SyncResponse and fires +// two warning events at fixed lead times before expiry: an interactive +// T-WarningLead notification and a dismiss-gated T-FinalWarningLead +// fallback dialog. +// +// The watcher is idempotent: Update may be called as often as the network +// map snapshots arrive. Repeating the same deadline is a no-op; a new +// deadline reschedules the timers and arms a fresh warning cycle. +// +// Warning firing is edge-detected. Each unique deadline value fires each +// warning callback at most once. +package sessionwatch + +import ( + "errors" + "fmt" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + cProto "github.com/netbirdio/netbird/client/proto" +) + +const ( + maxPastHorizon = 30 * 24 * time.Hour + + // maxDeadlineHorizon caps how far in the future an accepted deadline + // can sit. A timestamp beyond this is almost certainly a protocol + // glitch, and silently arming a 100-year timer would hide the bug. + maxDeadlineHorizon = 10 * 365 * 24 * time.Hour + + // WarningLead is how far before expiry the first (interactive) + // warning fires. Drives the T-10 OS notification with + // Extend/Dismiss actions. + WarningLead = 10 * time.Minute + + // FinalWarningLead is how far before expiry the fallback final + // warning fires. Drives the auto-opened SessionAboutToExpire dialog, + // but only when the user has not dismissed the T-WarningLead warning + // for the same deadline. Must be strictly less than WarningLead. + FinalWarningLead = 2 * time.Minute +) + +var ( + // ErrDeadlineBeforeEpoch is returned by Update when the supplied + // deadline pre-dates 1970-01-01. + ErrDeadlineBeforeEpoch = errors.New("session deadline before unix epoch") + + // ErrDeadlineTooFarFuture is returned by Update when the supplied + // deadline is more than maxDeadlineHorizon in the future. + ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future") + + // ErrDeadlineInPast is returned by Update when the supplied deadline + // is more than maxPastHorizon in the past. + ErrDeadlineInPast = errors.New("session deadline in the past") +) + +// StatusRecorder is the side-effect surface the watcher drives on every +// state transition. Production wires this to peer.Status (SetSessionExpiresAt +// for deadline change/clear, PublishEvent for the two warnings); tests pass +// a fake recorder so the same surface is observable without an engine. +// +// While the watcher runs, it owns the deadline propagated to the recorder: +// every set, clear and sanity-check rejection routes the value through +// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can +// never drift from the watcher's timer state. (SetSessionExpiresAt fans +// out its own state-change notification, so no separate notify is needed.) +// The recorder is server-scoped and outlives this engine-scoped watcher; +// Close deliberately leaves the recorder value in place so transient engine +// restarts don't blank it — the client run loop clears it on real teardown. +// +// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher +// composes the metadata internally so the wire format (MetaSession*) is +// owned by sessionwatch, not the caller. +type StatusRecorder interface { + SetSessionExpiresAt(deadline time.Time) + PublishEvent( + severity cProto.SystemEvent_Severity, + category cProto.SystemEvent_Category, + message string, + userMessage string, + metadata map[string]string, + ) +} + +// Watcher observes the latest session deadline and fires two warnings +// before it expires: the interactive T-WarningLead notification, and the +// fallback T-FinalWarningLead dialog (suppressed when the user dismissed +// the first one for the same deadline). Safe for concurrent use. +type Watcher struct { + lead time.Duration + finalLead time.Duration + + mu sync.Mutex + current time.Time + timer *time.Timer + finalTimer *time.Timer + firedAt time.Time // deadline value the T-WarningLead callback last fired against + finalFiredAt time.Time // deadline value the T-FinalWarningLead callback last fired against + dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal + closed bool + recorder StatusRecorder +} + +// New returns a watcher with the package defaults WarningLead and +// FinalWarningLead. Pass nil for recorder to silence side effects (handy +// in unit tests that exercise sanity checks without observing the publish +// path). +func New(recorder StatusRecorder) *Watcher { + return NewWithLeads(WarningLead, FinalWarningLead, recorder) +} + +// NewWithLeads returns a watcher with custom lead times. Useful for tests. +// final must be strictly less than lead; otherwise both timers fire in the +// wrong order or simultaneously and the UI flow breaks. A zero final lead +// disables the final-warning timer entirely (see armTimerLocked) so a +// millisecond-scale deadline doesn't flush both timers in one tick. +func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher { + return &Watcher{ + lead: lead, + finalLead: final, + recorder: recorder, + } +} + +// Update sets the latest deadline. Pass the zero time to clear (e.g. when +// a Sync push from the server omits the field because login expiration +// was disabled). +// +// Same-value updates are no-ops. A different non-zero value cancels any +// pending timer, resets the "already fired" guards, and — when the +// deadline lies in the future — arms fresh warning timers. A deadline +// already in the past (within maxPastHorizon) is recorded as-is with no +// timers: the session has expired and consumers render it that way. +// +// Returns one of the sentinel Err* values when the deadline fails the +// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon). +// In every error case the watcher first clears its state so it stays +// consistent with what the caller will push into its other sinks (e.g. +// applySessionDeadline forces a zero deadline into the status recorder +// after a non-nil error). +func (w *Watcher) Update(deadline time.Time) error { + w.mu.Lock() + if w.closed { + w.mu.Unlock() + return nil + } + + if deadline.IsZero() { + w.clearLocked() + return nil + } + + now := time.Now() + switch { + case deadline.Before(time.Unix(0, 0)): + w.clearLocked() + return fmt.Errorf("%w: %v", ErrDeadlineBeforeEpoch, deadline) + case deadline.After(now.Add(maxDeadlineHorizon)): + w.clearLocked() + return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline) + case deadline.Before(now.Add(-maxPastHorizon)): + w.clearLocked() + return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now) + } + + if deadline.Equal(w.current) { + w.mu.Unlock() + return nil + } + + w.stopTimerLocked() + w.current = deadline + // Reset every per-deadline guard so a refreshed deadline arms a fresh + // warning cycle: both edge triggers and the user Dismiss decision + // (the user agreed to the old deadline expiring; a new deadline + // restarts the contract). + w.firedAt = time.Time{} + w.finalFiredAt = time.Time{} + w.dismissedAt = time.Time{} + + if deadline.After(now) { + w.armTimerLocked(deadline) + } + recorder := w.recorder + w.mu.Unlock() + if recorder != nil { + recorder.SetSessionExpiresAt(deadline) + } + log.Infof("auth session deadline set to: %s (in %s)", deadline.Format(time.RFC3339), time.Until(deadline).Round(time.Second)) + return nil +} + +// Deadline returns the most recently observed deadline. Zero when no +// deadline is currently tracked. +func (w *Watcher) Deadline() time.Time { + w.mu.Lock() + defer w.mu.Unlock() + return w.current +} + +// Dismiss records the user's "Dismiss" action against the current deadline +// and suppresses the upcoming final-warning callback for that deadline. +// Idempotent: repeated calls are no-ops. A subsequent Update with a fresh +// deadline resets the dismissal so the final-warning cycle re-arms. +// +// No-op when the watcher holds no deadline or has been closed. +func (w *Watcher) Dismiss() { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed || w.current.IsZero() { + return + } + if w.dismissedAt.Equal(w.current) { + return + } + w.dismissedAt = w.current + // Cancel the armed final-warning timer eagerly. fireFinal would also + // gate on dismissedAt, but stopping the timer avoids a wakeup with + // nothing to do and makes the intent visible. + if w.finalTimer != nil { + w.finalTimer.Stop() + w.finalTimer = nil + } + log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339)) +} + +// Close stops any pending timer. Update calls after Close are ignored. +// The recorder keeps its deadline: the watcher is engine-scoped and closes +// on every engine restart (network change, sleep/wake, stream errors) +// while the SSO deadline stays valid across those, so clearing here would +// blank the UI's "expires in" row on every transient reconnect. The +// client run loop clears the server-scoped recorder when it exits for +// real (Down, profile switch, permanent login failure). +func (w *Watcher) Close() { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return + } + w.closed = true + w.stopTimerLocked() + w.current = time.Time{} + w.firedAt = time.Time{} + w.finalFiredAt = time.Time{} + w.dismissedAt = time.Time{} +} + +// clearLocked drops the tracked deadline and notifies the recorder so +// downstream consumers (SubscribeStatus stream, UI) drop their anchor. +// The caller must hold w.mu; this helper releases it before invoking +// the recorder. +func (w *Watcher) clearLocked() { + if w.current.IsZero() { + w.mu.Unlock() + return + } + w.stopTimerLocked() + w.current = time.Time{} + w.firedAt = time.Time{} + w.finalFiredAt = time.Time{} + w.dismissedAt = time.Time{} + recorder := w.recorder + w.mu.Unlock() + if recorder != nil { + recorder.SetSessionExpiresAt(time.Time{}) + } + log.Infof("auth session deadline cleared") +} + +func (w *Watcher) stopTimerLocked() { + if w.timer != nil { + w.timer.Stop() + w.timer = nil + } + if w.finalTimer != nil { + w.finalTimer.Stop() + w.finalTimer = nil + } +} + +func (w *Watcher) armTimerLocked(deadline time.Time) { + w.timer = armOneShotLocked(deadline.Add(-w.lead), func() { w.fire(deadline) }) + // finalLead <= 0 disables the final-warning timer entirely. Used by + // tests that predate the final-warning fallback so a millisecond-scale + // deadline does not flush both timers at once. + if w.finalLead > 0 { + w.finalTimer = armOneShotLocked(deadline.Add(-w.finalLead), func() { w.fireFinal(deadline) }) + } +} + +func (w *Watcher) fire(armedFor time.Time) { + w.mu.Lock() + if w.closed || !w.current.Equal(armedFor) { + // Deadline moved while we were waiting (e.g. a successful extend). + // The reschedule path armed a fresh timer; this one is stale. + w.mu.Unlock() + return + } + if !w.firedAt.IsZero() && w.firedAt.Equal(armedFor) { + w.mu.Unlock() + return + } + w.firedAt = armedFor + recorder := w.recorder + w.mu.Unlock() + if recorder == nil { + return + } + log.Infof("auth session expiry soon warning fired") + publishWarning(recorder, armedFor, false) +} + +// fireFinal mirrors fire for the T-FinalWarningLead timer with an extra +// dismiss-gate: if the user dismissed the T-WarningLead notification for +// this deadline, the final warning is suppressed entirely. +func (w *Watcher) fireFinal(armedFor time.Time) { + w.mu.Lock() + if w.closed || !w.current.Equal(armedFor) { + w.mu.Unlock() + return + } + if !w.finalFiredAt.IsZero() && w.finalFiredAt.Equal(armedFor) { + w.mu.Unlock() + return + } + if w.dismissedAt.Equal(armedFor) { + w.mu.Unlock() + log.Infof("auth session final-warning skipped (dismissed by user)") + return + } + w.finalFiredAt = armedFor + recorder := w.recorder + w.mu.Unlock() + if recorder == nil { + return + } + log.Infof("auth session final-warning fired") + publishWarning(recorder, armedFor, true) +} + +// armOneShotLocked schedules cb at fireAt. When fireAt is already in the +// past it dispatches on the next scheduler tick so a state-change recorder +// notification (invoked after w.mu is released) lands first. Caller must +// hold w.mu. +func armOneShotLocked(fireAt time.Time, cb func()) *time.Timer { + delay := time.Until(fireAt) + if delay <= 0 { + return time.AfterFunc(0, cb) + } + return time.AfterFunc(delay, cb) +} + +// publishWarning composes the SystemEvent for a watcher-fired warning and +// pushes it through the recorder. Severity is CRITICAL on both — bypassing +// the user's Notifications toggle is deliberate: missing the warning +// window forces the post-mortem SessionExpired flow (tunnel torn down, +// lock icon, manual re-login), which is the UX we are trying to avoid. +func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) { + lead := WarningLead + message := "session expiry warning" + meta := map[string]string{ + MetaSessionWarning: "true", + MetaSessionExpiresAt: FormatExpiresAt(deadline), + } + if final { + lead = FinalWarningLead + message = "session expiry final warning" + meta[MetaSessionFinal] = "true" + } + meta[MetaSessionLeadMinutes] = FormatLeadMinutes(lead) + + recorder.PublishEvent( + cProto.SystemEvent_CRITICAL, + cProto.SystemEvent_AUTHENTICATION, + message, + "", + meta, + ) +} diff --git a/client/internal/auth/sessionwatch/watcher_test.go b/client/internal/auth/sessionwatch/watcher_test.go new file mode 100644 index 000000000..4b49a94b6 --- /dev/null +++ b/client/internal/auth/sessionwatch/watcher_test.go @@ -0,0 +1,529 @@ +package sessionwatch + +import ( + "errors" + "sync" + "testing" + "time" + + cProto "github.com/netbirdio/netbird/client/proto" +) + +// fakeRecorder satisfies StatusRecorder and records every call so tests +// can observe what the watcher emits. SetSessionExpiresAt and PublishEvent +// land in the same ordered events slice (with the Kind distinguishing +// them) so tests that care about ordering still work. lastDeadline holds +// the most recent value passed to SetSessionExpiresAt so tests can assert +// the recorder ended up cleared/set as expected. +type fakeRecorder struct { + mu sync.Mutex + events []event + lastDeadline time.Time +} + +type eventKind int + +const ( + stateChange eventKind = iota + publish +) + +type event struct { + kind eventKind + // Set only for publish events. + severity cProto.SystemEvent_Severity + category cProto.SystemEvent_Category + message string + meta map[string]string +} + +// SetSessionExpiresAt mirrors peer.Status: a same-value write is a no-op, +// a real change records the new value and fans out a state-change (the +// production recorder calls notifyStateChange internally). The baseline +// is the zero time, so an initial clear before any deadline is set emits +// nothing — matching the real recorder. +func (r *fakeRecorder) SetSessionExpiresAt(deadline time.Time) { + r.mu.Lock() + defer r.mu.Unlock() + if r.lastDeadline.Equal(deadline) { + return + } + r.lastDeadline = deadline + r.events = append(r.events, event{kind: stateChange}) +} + +func (r *fakeRecorder) deadline() time.Time { + r.mu.Lock() + defer r.mu.Unlock() + return r.lastDeadline +} + +func (r *fakeRecorder) PublishEvent( + severity cProto.SystemEvent_Severity, + category cProto.SystemEvent_Category, + message string, + _ string, + metadata map[string]string, +) { + r.mu.Lock() + defer r.mu.Unlock() + r.events = append(r.events, event{ + kind: publish, + severity: severity, + category: category, + message: message, + meta: metadata, + }) +} + +func (r *fakeRecorder) snapshot() []event { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]event, len(r.events)) + copy(out, r.events) + return out +} + +func (e event) isFinalWarning() bool { + return e.kind == publish && e.meta[MetaSessionFinal] == "true" +} + +func (e event) isWarning() bool { + return e.kind == publish && e.meta[MetaSessionWarning] == "true" && e.meta[MetaSessionFinal] != "true" +} + +func countWhere(events []event, pred func(event) bool) int { + n := 0 + for _, e := range events { + if pred(e) { + n++ + } + } + return n +} + +func waitForEvents(t *testing.T, r *fakeRecorder, want int) []event { + t.Helper() + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if got := r.snapshot(); len(got) >= want { + return got + } + time.Sleep(5 * time.Millisecond) + } + got := r.snapshot() + t.Fatalf("timed out waiting for %d events, got %d: %+v", want, len(got), got) + return nil +} + +// newWatcher builds a watcher with the final timer disabled (finalLead=0), +// matching the lead-only behaviour the pre-final-warning tests assume. +func newWatcher(lead time.Duration, r *fakeRecorder) *Watcher { + return NewWithLeads(lead, 0, r) +} + +func TestUpdateZeroBeforeAnythingIsNoop(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + _ = w.Update(time.Time{}) + + if got := r.snapshot(); len(got) != 0 { + t.Fatalf("expected no events on initial zero, got %+v", got) + } +} + +func TestUpdateNonZeroFiresStateChange(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(time.Hour) + _ = w.Update(d) + + events := waitForEvents(t, r, 1) + if events[0].kind != stateChange { + t.Fatalf("expected stateChange, got %+v", events[0]) + } + if !w.Deadline().Equal(d) { + t.Fatalf("deadline mismatch: %v vs %v", w.Deadline(), d) + } +} + +func TestSameDeadlineIsNoop(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(time.Hour) + _ = w.Update(d) + _ = w.Update(d) + _ = w.Update(d) + + events := waitForEvents(t, r, 1) + if len(events) != 1 { + t.Fatalf("expected exactly 1 event for repeated same deadline, got %d: %+v", len(events), events) + } +} + +func TestWarningFiresOnceWithinLeadWindow(t *testing.T) { + r := &fakeRecorder{} + lead := 50 * time.Millisecond + w := newWatcher(lead, r) + defer w.Close() + + // Deadline 80ms out — warning should fire after ~30ms. + d := time.Now().Add(80 * time.Millisecond) + _ = w.Update(d) + + events := waitForEvents(t, r, 2) + if events[0].kind != stateChange { + t.Fatalf("event[0] should be stateChange, got %+v", events[0]) + } + if !events[1].isWarning() { + t.Fatalf("event[1] should be a warning publish, got %+v", events[1]) + } +} + +func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(time.Hour, r) // lead > delta => fire immediately + defer w.Close() + + d := time.Now().Add(10 * time.Millisecond) + _ = w.Update(d) + + events := waitForEvents(t, r, 2) + if !events[1].isWarning() { + t.Fatalf("expected immediate warning publish, got %+v", events[1]) + } +} + +func TestNewDeadlineCancelsPriorTimer(t *testing.T) { + r := &fakeRecorder{} + lead := 50 * time.Millisecond + w := newWatcher(lead, r) + defer w.Close() + + first := time.Now().Add(80 * time.Millisecond) // would fire warning ~30ms in + _ = w.Update(first) + + // Replace with a far-future deadline before the warning fires. + time.Sleep(5 * time.Millisecond) + second := time.Now().Add(time.Hour) + _ = w.Update(second) + + // Wait past when first's warning would have fired. + time.Sleep(80 * time.Millisecond) + + if n := countWhere(r.snapshot(), event.isWarning); n != 0 { + t.Fatalf("warning fired for cancelled deadline: %+v", r.snapshot()) + } +} + +func TestRefreshAfterFireArmsNewWarning(t *testing.T) { + r := &fakeRecorder{} + lead := 150 * time.Millisecond + w := newWatcher(lead, r) + defer w.Close() + + // Warning fires ~20ms in; the deadline itself stays 150ms away so the + // replacement below lands well before it. + first := time.Now().Add(170 * time.Millisecond) + _ = w.Update(first) + + // Wait for stateChange + warning of the first cycle. + waitForEvents(t, r, 2) + + // Simulate a successful extend: brand new deadline. + second := time.Now().Add(60 * time.Millisecond) + _ = w.Update(second) + + // 4 events total: stateChange, warning (first), stateChange, warning (second). + events := waitForEvents(t, r, 4) + if events[2].kind != stateChange { + t.Fatalf("event[2] should be stateChange for the new deadline, got %+v", events[2]) + } + if !events[3].isWarning() { + t.Fatalf("event[3] should be a warning publish for the new deadline, got %+v", events[3]) + } +} + +func TestUpdateZeroAfterNonZeroClearsState(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(time.Hour, r) + defer w.Close() + + d := time.Now().Add(2 * time.Hour) + _ = w.Update(d) + waitForEvents(t, r, 1) + + _ = w.Update(time.Time{}) + + events := waitForEvents(t, r, 2) + if events[1].kind != stateChange { + t.Fatalf("expected stateChange on clear, got %+v", events[1]) + } + if !w.Deadline().IsZero() { + t.Fatalf("Deadline should be zero after clear") + } +} + +func TestUpdateRejectsBeforeEpoch(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + good := time.Now().Add(time.Hour) + if err := w.Update(good); err != nil { + t.Fatalf("seed Update: %v", err) + } + + err := w.Update(time.Unix(-100, 0)) + if !errors.Is(err, ErrDeadlineBeforeEpoch) { + t.Fatalf("want ErrDeadlineBeforeEpoch, got %v", err) + } + if !w.Deadline().IsZero() { + t.Fatalf("rejected pre-epoch update must clear deadline; got %v", w.Deadline()) + } +} + +func TestUpdateRejectsTooFarFuture(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + good := time.Now().Add(time.Hour) + if err := w.Update(good); err != nil { + t.Fatalf("seed Update: %v", err) + } + + err := w.Update(time.Now().Add(50 * 365 * 24 * time.Hour)) + if !errors.Is(err, ErrDeadlineTooFarFuture) { + t.Fatalf("want ErrDeadlineTooFarFuture, got %v", err) + } + if !w.Deadline().IsZero() { + t.Fatalf("rejected far-future update must clear deadline; got %v", w.Deadline()) + } +} + +func TestUpdateRecentPastRecordedAsExpired(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(-1 * time.Hour) + if err := w.Update(d); err != nil { + t.Fatalf("recent-past Update should succeed, got %v", err) + } + if !w.Deadline().Equal(d) { + t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d) + } + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("recorder deadline = %v, want %v", got, d) + } + + time.Sleep(80 * time.Millisecond) + if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 { + t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot()) + } +} + +func TestUpdateAncientPastRejected(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + defer w.Close() + + good := time.Now().Add(time.Hour) + if err := w.Update(good); err != nil { + t.Fatalf("seed Update: %v", err) + } + // Drain the stateChange from the seed. + waitForEvents(t, r, 1) + + err := w.Update(time.Now().Add(-31 * 24 * time.Hour)) + if !errors.Is(err, ErrDeadlineInPast) { + t.Fatalf("want ErrDeadlineInPast, got %v", err) + } + if !w.Deadline().IsZero() { + t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline()) + } + events := waitForEvents(t, r, 2) + if events[1].kind != stateChange { + t.Fatalf("expected stateChange on clear, got %+v", events[1]) + } +} + +func TestCloseSilencesUpdates(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(50*time.Millisecond, r) + w.Close() + + if err := w.Update(time.Now().Add(time.Hour)); err != nil { + t.Fatalf("Update after Close: want nil, got %v", err) + } + if got := r.snapshot(); len(got) != 0 { + t.Fatalf("expected no events after Close, got %+v", got) + } +} + +// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher +// closes on every engine restart (network change, sleep/wake) while the +// SSO deadline stays valid across those, so Close must leave the +// server-scoped recorder's value in place. The client run loop clears the +// recorder when it exits for real. +func TestCloseKeepsRecorderDeadline(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(time.Hour, r) + + d := time.Now().Add(2 * time.Hour) + if err := w.Update(d); err != nil { + t.Fatalf("seed Update: %v", err) + } + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("recorder deadline after Update = %v, want %v", got, d) + } + + w.Close() + + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("recorder deadline after Close = %v, want %v", got, d) + } +} + +// TestCloseWithoutDeadlineLeavesRecorderUntouched guards the symmetric +// case: closing a watcher that never held a deadline must not emit a +// redundant clear (the recorder may legitimately hold a value written by +// some other path; the watcher only owns what it set). +func TestCloseWithoutDeadlineLeavesRecorderUntouched(t *testing.T) { + r := &fakeRecorder{} + w := newWatcher(time.Hour, r) + + w.Close() + + if got := r.snapshot(); len(got) != 0 { + t.Fatalf("expected no events from Close on an empty watcher, got %+v", got) + } +} + +func TestFinalWarningFiresAfterRegularWarning(t *testing.T) { + r := &fakeRecorder{} + // Warning fires at deadline-80ms, final at deadline-30ms. + w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(100 * time.Millisecond) + _ = w.Update(d) + + // Expect stateChange + warning + final-warning. + events := waitForEvents(t, r, 3) + + if countWhere(events, func(e event) bool { return e.kind == stateChange }) != 1 { + t.Fatalf("expected exactly 1 stateChange, got %+v", events) + } + if countWhere(events, event.isWarning) != 1 { + t.Fatalf("expected exactly 1 warning publish, got %+v", events) + } + if countWhere(events, event.isFinalWarning) != 1 { + t.Fatalf("expected exactly 1 final-warning publish, got %+v", events) + } + + // Warning must precede final (same deadline, longer lead fires first). + var wIdx, fIdx int + for i, e := range events { + switch { + case e.isWarning(): + wIdx = i + case e.isFinalWarning(): + fIdx = i + } + } + if wIdx > fIdx { + t.Fatalf("warning must publish before final-warning, got order %+v", events) + } +} + +func TestDismissSuppressesFinalWarning(t *testing.T) { + r := &fakeRecorder{} + w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r) + defer w.Close() + + d := time.Now().Add(100 * time.Millisecond) + _ = w.Update(d) + + // Wait for the warning publish so we know we're inside the warning + // window, then dismiss before the final timer would fire. + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if countWhere(r.snapshot(), event.isWarning) >= 1 { + break + } + time.Sleep(2 * time.Millisecond) + } + if countWhere(r.snapshot(), event.isWarning) < 1 { + t.Fatalf("warning did not publish in time, events=%+v", r.snapshot()) + } + + w.Dismiss() + + // Now wait past when the final would have fired. + time.Sleep(120 * time.Millisecond) + + if n := countWhere(r.snapshot(), event.isFinalWarning); n != 0 { + t.Fatalf("final-warning published after Dismiss(), events=%+v", r.snapshot()) + } +} + +func TestDismissResetByNewDeadline(t *testing.T) { + r := &fakeRecorder{} + w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r) + defer w.Close() + + first := time.Now().Add(100 * time.Millisecond) + _ = w.Update(first) + + // Dismiss against the first deadline. + w.Dismiss() + + // Replace with a fresh deadline before the first's timers complete. + time.Sleep(10 * time.Millisecond) + second := time.Now().Add(100 * time.Millisecond) + _ = w.Update(second) + + // The second cycle must publish a final-warning (the dismiss state + // did not carry over). + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if countWhere(r.snapshot(), event.isFinalWarning) >= 1 { + break + } + time.Sleep(5 * time.Millisecond) + } + if countWhere(r.snapshot(), event.isFinalWarning) < 1 { + t.Fatalf("final-warning did not publish on fresh deadline after Dismiss reset, events=%+v", r.snapshot()) + } +} + +func TestDismissBeforeUpdateIsNoop(t *testing.T) { + r := &fakeRecorder{} + w := NewWithLeads(80*time.Millisecond, 30*time.Millisecond, r) + defer w.Close() + + // No deadline tracked yet; Dismiss must be a no-op (no panic, no state). + w.Dismiss() + + d := time.Now().Add(100 * time.Millisecond) + _ = w.Update(d) + + // Final warning should still publish — Dismiss only acts on the current + // deadline, and there was none at the time of the call. + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if countWhere(r.snapshot(), event.isFinalWarning) >= 1 { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot()) +} diff --git a/client/internal/auth/util.go b/client/internal/auth/util.go index 31c81d701..1800584a2 100644 --- a/client/internal/auth/util.go +++ b/client/internal/auth/util.go @@ -20,14 +20,26 @@ func randomBytesInHex(count int) (string, error) { return hex.EncodeToString(buf), nil } -// isValidAccessToken is a simple validation of the access token -func isValidAccessToken(token string, audience string) error { +// validateTokenAudience checks that the token is a well-formed JWT whose +// audience claim matches the expected audience. +// +// It does NOT verify the token's cryptographic signature and therefore must not +// be treated as an authenticity check. The token is obtained by the client +// directly from the IdP token endpoint over TLS, and its signature is verified +// server-side by the management server against the IdP's JWKS +// (see shared/auth/jwt/validator.go). This function is only a client-side +// sanity check that the returned token targets the expected audience. +func validateTokenAudience(token string, audience string) error { if token == "" { return fmt.Errorf("token received is empty") } - encodedClaims := strings.Split(token, ".")[1] - claimsString, err := base64.RawURLEncoding.DecodeString(encodedClaims) + parts := strings.Split(token, ".") + if len(parts) != 3 { + return fmt.Errorf("token is not a well-formed JWT") + } + + claimsString, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return err } diff --git a/client/internal/auth/util_test.go b/client/internal/auth/util_test.go new file mode 100644 index 000000000..7f225bb86 --- /dev/null +++ b/client/internal/auth/util_test.go @@ -0,0 +1,108 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "testing" +) + +// makeJWT builds an unsigned JWT-shaped string (header.payload.signature) with +// the given claims payload. The signature part is arbitrary because +// validateTokenAudience intentionally does not verify it. +func makeJWT(t *testing.T, claims map[string]interface{}) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + payloadBytes, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + return header + "." + payload + ".unverified-signature" +} + +func TestValidateTokenAudience(t *testing.T) { + tests := []struct { + name string + token string + audience string + wantErr bool + }{ + { + name: "empty token", + token: "", + audience: "netbird", + wantErr: true, + }, + { + name: "not a JWT - no dots", + token: "notajwt", + audience: "netbird", + wantErr: true, + }, + { + name: "not a JWT - two parts only", + token: "header.payload", + audience: "netbird", + wantErr: true, + }, + { + name: "matching string audience", + token: makeJWT(t, map[string]interface{}{"aud": "netbird"}), + audience: "netbird", + wantErr: false, + }, + { + name: "mismatching string audience", + token: makeJWT(t, map[string]interface{}{"aud": "other"}), + audience: "netbird", + wantErr: true, + }, + { + name: "matching audience in array", + token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"other", "netbird"}}), + audience: "netbird", + wantErr: false, + }, + { + name: "mismatching audience array", + token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"a", "b"}}), + audience: "netbird", + wantErr: true, + }, + { + name: "missing audience claim", + token: makeJWT(t, map[string]interface{}{"sub": "user"}), + audience: "netbird", + wantErr: true, + }, + { + name: "invalid base64 payload", + token: "header.!!!not-base64!!!.sig", + audience: "netbird", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateTokenAudience(tc.token, tc.audience) + if tc.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} + +// TestValidateTokenAudienceNoPanic guards the regression where a non-empty +// token without the JWT dot structure caused an index-out-of-range panic. +func TestValidateTokenAudienceNoPanic(t *testing.T) { + inputs := []string{"a", ".", "a.", "aaaa", "no-dots-here"} + for _, in := range inputs { + if err := validateTokenAudience(in, "netbird"); err == nil { + t.Fatalf("expected error for malformed token %q, got nil", in) + } + } +} diff --git a/client/internal/conn_mgr.go b/client/internal/conn_mgr.go index 112559132..ad0f00c5d 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -16,6 +16,16 @@ import ( "github.com/netbirdio/netbird/route" ) +// lazyForce is the resolved local decision for lazy connections, layered above the +// management feature flag. lazyForceNone defers to management. +type lazyForce int + +const ( + lazyForceNone lazyForce = iota + lazyForceOn + lazyForceOff +) + // ConnMgr coordinates both lazy connections (established on-demand) and permanent peer connections. // // The connection manager is responsible for: @@ -24,47 +34,69 @@ import ( // - Handling connection establishment based on peer signaling // // The implementation is not thread-safe; it is protected by engine.syncMsgMux. +// The only exception is ActivatePeer, which is safe for concurrent use so the +// DNS warm-up path can call it without contending on the engine mutex. type ConnMgr struct { peerStore *peerstore.Store statusRecorder *peer.Status iface lazyconn.WGIface - enabledLocally bool + force lazyForce rosenpassEnabled bool lazyConnMgr *manager.Manager + // lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the + // engine loop (ActivatePeer). Writers hold it in addition to + // engine.syncMsgMux; all other reads stay under engine.syncMsgMux only. + lazyConnMgrMu sync.RWMutex + + // reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is + // (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile. + reconcileRoutedIPs func(peerKey string) error wg sync.WaitGroup lazyCtx context.Context lazyCtxCancel context.CancelFunc } +// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when +// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts. +func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) { + e.reconcileRoutedIPs = fn +} + func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr { e := &ConnMgr{ peerStore: peerStore, statusRecorder: statusRecorder, iface: iface, + force: resolveLazyForce(engineConfig.LazyConnection), rosenpassEnabled: engineConfig.RosenpassEnabled, } - if engineConfig.LazyConnectionEnabled || lazyconn.IsLazyConnEnabledByEnv() { - e.enabledLocally = true - } return e } -// Start initializes the connection manager and starts the lazy connection manager if enabled by env var or cmd line option. +// Start initializes the connection manager. It starts the lazy connection manager when a +// local override forces it on; with no local override it waits for the management feature flag. func (e *ConnMgr) Start(ctx context.Context) { if e.lazyConnMgr != nil { log.Errorf("lazy connection manager is already started") return } - if !e.enabledLocally { - log.Infof("lazy connection manager is disabled") + switch e.force { + case lazyForceOff: + log.Infof("lazy connection manager is disabled by local override (%s or MDM policy)", lazyconn.EnvLazyConn) + e.statusRecorder.UpdateLazyConnection(false) + return + case lazyForceNone: + log.Infof("lazy connection manager is managed by the management feature flag") + e.statusRecorder.UpdateLazyConnection(false) return } if e.rosenpassEnabled { log.Warnf("rosenpass connection manager is enabled, lazy connection manager will not be started") + e.statusRecorder.UpdateLazyConnection(false) return } @@ -76,8 +108,8 @@ func (e *ConnMgr) Start(ctx context.Context) { // If enabled, it initializes the lazy connection manager and start it. Do not need to call Start() again. // If disabled, then it closes the lazy connection manager and open the connections to all peers. func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) error { - // do not disable lazy connection manager if it was enabled by env var - if e.enabledLocally { + // a local override (NB_LAZY_CONN or local config) takes precedence over management + if e.force != lazyForceNone { return nil } @@ -89,15 +121,17 @@ func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) er if e.rosenpassEnabled { log.Infof("rosenpass connection manager is enabled, lazy connection manager will not be started") + e.statusRecorder.UpdateLazyConnection(false) return nil } - log.Warnf("lazy connection manager is enabled by management feature flag") + log.Infof("lazy connection manager is enabled by the management feature flag") e.initLazyManager(ctx) e.statusRecorder.UpdateLazyConnection(true) return e.addPeersToLazyConnManager() } else { if e.lazyConnMgr == nil { + e.statusRecorder.UpdateLazyConnection(false) return nil } log.Infof("lazy connection manager is disabled by management feature flag") @@ -220,12 +254,20 @@ func (e *ConnMgr) RemovePeerConn(peerKey string) { conn.Log.Infof("removed peer from lazy conn manager") } +// ActivatePeer wakes an idle lazy connection. Unlike the rest of ConnMgr it is +// safe for concurrent use: the lazy manager pointer is read under lazyConnMgrMu +// and the manager itself is internally synchronized, so callers outside the +// engine loop (DNS warm-up) do not need engine.syncMsgMux. func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) { - if !e.isStartedWithLazyMgr() { + e.lazyConnMgrMu.RLock() + lazyConnMgr := e.lazyConnMgr + started := lazyConnMgr != nil && e.lazyCtxCancel != nil + e.lazyConnMgrMu.RUnlock() + if !started { return } - if found := e.lazyConnMgr.ActivatePeer(conn.GetKey()); found { + if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found { if err := conn.Open(ctx); err != nil { conn.Log.Errorf("failed to open connection: %v", err) } @@ -250,16 +292,22 @@ func (e *ConnMgr) Close() { e.lazyCtxCancel() e.wg.Wait() + + e.lazyConnMgrMu.Lock() e.lazyConnMgr = nil + e.lazyConnMgrMu.Unlock() } func (e *ConnMgr) initLazyManager(engineCtx context.Context) { cfg := manager.Config{ InactivityThreshold: inactivityThresholdEnv(), + ReconcileAllowedIPs: e.reconcileRoutedIPs, } - e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface) + e.lazyConnMgrMu.Lock() + e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface) e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx) + e.lazyConnMgrMu.Unlock() e.wg.Add(1) go func() { @@ -298,7 +346,10 @@ func (e *ConnMgr) closeManager(ctx context.Context) { e.lazyCtxCancel() e.wg.Wait() + + e.lazyConnMgrMu.Lock() e.lazyConnMgr = nil + e.lazyConnMgrMu.Unlock() for _, peerID := range e.peerStore.PeersPubKey() { e.peerStore.PeerConnOpen(ctx, peerID) @@ -309,17 +360,45 @@ func (e *ConnMgr) isStartedWithLazyMgr() bool { return e.lazyConnMgr != nil && e.lazyCtxCancel != nil } +// resolveLazyForce determines the local override. NB_LAZY_CONN takes precedence; when it +// is unset the MDM policy override (mdmState) applies. Either wins in both directions over +// the management feature flag; StateUnset for both defers to management. +func resolveLazyForce(mdmState lazyconn.State) lazyForce { + state := lazyconn.EnvState() + if state == lazyconn.StateUnset { + state = mdmState + } + + switch state { + case lazyconn.StateOn: + return lazyForceOn + case lazyconn.StateOff: + return lazyForceOff + default: + return lazyForceNone + } +} + func inactivityThresholdEnv() *time.Duration { envValue := os.Getenv(lazyconn.EnvInactivityThreshold) if envValue == "" { return nil } - parsedMinutes, err := strconv.Atoi(envValue) - if err != nil || parsedMinutes <= 0 { - return nil + // Documented format: a Go duration such as "30m" or "1h". + if d, err := time.ParseDuration(envValue); err == nil { + if d <= 0 { + return nil + } + return &d } - d := time.Duration(parsedMinutes) * time.Minute - return &d + // Backwards compatibility: a bare integer used to be interpreted as minutes. + if parsedMinutes, err := strconv.Atoi(envValue); err == nil && parsedMinutes > 0 { + d := time.Duration(parsedMinutes) * time.Minute + return &d + } + + log.Warnf("invalid %s value %q: expected a Go duration such as 30m or 1h", lazyconn.EnvInactivityThreshold, envValue) + return nil } diff --git a/client/internal/conn_mgr_test.go b/client/internal/conn_mgr_test.go new file mode 100644 index 000000000..ac5d6f2c8 --- /dev/null +++ b/client/internal/conn_mgr_test.go @@ -0,0 +1,141 @@ +package internal + +import ( + "context" + "net" + "net/netip" + "os" + "sync" + "testing" + "time" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/lazyconn" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" + "github.com/netbirdio/netbird/monotime" +) + +func TestResolveLazyForce(t *testing.T) { + tests := []struct { + name string + env string + envSet bool + mdm lazyconn.State + want lazyForce + }{ + {name: "env unset, mdm unset -> defer to management", mdm: lazyconn.StateUnset, want: lazyForceNone}, + {name: "env on -> force on", env: "on", envSet: true, mdm: lazyconn.StateUnset, want: lazyForceOn}, + {name: "env off -> force off", env: "off", envSet: true, mdm: lazyconn.StateUnset, want: lazyForceOff}, + {name: "env unset, mdm on -> force on", mdm: lazyconn.StateOn, want: lazyForceOn}, + {name: "env unset, mdm off -> force off", mdm: lazyconn.StateOff, want: lazyForceOff}, + {name: "env on beats mdm off", env: "on", envSet: true, mdm: lazyconn.StateOff, want: lazyForceOn}, + {name: "env off beats mdm on", env: "off", envSet: true, mdm: lazyconn.StateOn, want: lazyForceOff}, + {name: "unrecognized env, mdm on -> mdm wins", env: "auto", envSet: true, mdm: lazyconn.StateOn, want: lazyForceOn}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(lazyconn.EnvLazyConn, tt.env) + if !tt.envSet { + os.Unsetenv(lazyconn.EnvLazyConn) + } + + if got := resolveLazyForce(tt.mdm); got != tt.want { + t.Fatalf("resolveLazyForce(%v) = %v, want %v", tt.mdm, got, tt.want) + } + }) + } +} + +type mockLazyWGIface struct{} + +func (mockLazyWGIface) RemovePeer(string) error { return nil } +func (mockLazyWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error { + return nil +} +func (mockLazyWGIface) IsUserspaceBind() bool { return false } +func (mockLazyWGIface) Address() wgaddr.Address { return wgaddr.Address{} } +func (mockLazyWGIface) LastActivities() map[string]monotime.Time { return nil } +func (mockLazyWGIface) MTU() uint16 { return 1280 } + +// TestConnMgr_ActivatePeerConcurrentWithLifecycle exercises ActivatePeer from +// non-engine goroutines (the DNS warm-up path) racing the manager lifecycle, +// which stays on the engine loop. Run with -race: it fails if ActivatePeer +// still requires engine.syncMsgMux for safety. +func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) { + t.Setenv(lazyconn.EnvLazyConn, "on") + + status := peer.NewRecorder("https://mgm") + store := peerstore.NewConnStore() + connMgr := NewConnMgr(&EngineConfig{}, status, store, mockLazyWGIface{}) + + conn := newTestPeerConn(t, "peerA") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + connMgr.Start(ctx) + + done := make(chan struct{}) + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-done: + return + default: + connMgr.ActivatePeer(ctx, conn) + } + } + }() + } + + // Let the activators spin against the started manager, then tear it down + // underneath them and let them spin against the stopped manager. + time.Sleep(100 * time.Millisecond) + connMgr.Close() + time.Sleep(50 * time.Millisecond) + + close(done) + wg.Wait() +} + +func TestInactivityThresholdEnv(t *testing.T) { + tests := []struct { + name string + val string + want *time.Duration + }{ + {name: "unset", val: "", want: nil}, + {name: "go duration minutes", val: "30m", want: durPtr(30 * time.Minute)}, + {name: "go duration hours", val: "1h", want: durPtr(time.Hour)}, + {name: "go duration seconds", val: "90s", want: durPtr(90 * time.Second)}, + {name: "bare integer is minutes (backwards compat)", val: "5", want: durPtr(5 * time.Minute)}, + {name: "zero duration", val: "0s", want: nil}, + {name: "zero integer", val: "0", want: nil}, + {name: "negative duration", val: "-5m", want: nil}, + {name: "garbage", val: "abc", want: nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(lazyconn.EnvInactivityThreshold, tc.val) + got := inactivityThresholdEnv() + switch { + case tc.want == nil && got != nil: + t.Fatalf("want nil, got %v", *got) + case tc.want != nil && got == nil: + t.Fatalf("want %v, got nil", *tc.want) + case tc.want != nil && *got != *tc.want: + t.Fatalf("want %v, got %v", *tc.want, *got) + } + }) + } +} + +func durPtr(d time.Duration) *time.Duration { return &d } diff --git a/client/internal/connect.go b/client/internal/connect.go index d93b62bb5..e45ecca44 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" @@ -26,15 +27,19 @@ import ( "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/lazyconn" "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/metrics" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/client/internal/stdnet" + "github.com/netbirdio/netbird/client/internal/tunnelnotifier" "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/internal/updater/installer" nbnet "github.com/netbirdio/netbird/client/net" + "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netsweep" cProto "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/ssh" sshconfig "github.com/netbirdio/netbird/client/ssh/config" @@ -54,6 +59,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 @@ -63,19 +72,53 @@ type ConnectClient struct { updateManager *updater.Manager persistSyncResponse bool + + // netState gates every reconnection loop on OS-reported network + // availability. Nil (the default) disables gating; mobile platforms + // inject it via WithNetworkState. + netState *netstate.State + + // sweeper cuts the management, signal and relay connections on network + // change; nil disables it. + sweeper *netsweep.Sweeper +} + +// ConnectClientOption configures optional ConnectClient behavior. +type ConnectClientOption func(*ConnectClient) + +// WithNetworkState injects the OS network availability state that gates every +// reconnection loop; without it gating is disabled. +func WithNetworkState(netState *netstate.State) ConnectClientOption { + return func(c *ConnectClient) { c.netState = netState } +} + +// WithSweeper injects the network change sweeper. +func WithSweeper(sweeper *netsweep.Sweeper) ConnectClientOption { + return func(c *ConnectClient) { c.sweeper = sweeper } } func NewConnectClient( ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, + opts ...ConnectClientOption, ) *ConnectClient { - return &ConnectClient{ - ctx: ctx, + // 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) + c := &ConnectClient{ + ctx: runCtx, + runCancel: runCancel, + runExited: make(chan struct{}), config: config, statusRecorder: statusRecorder, engineMutex: sync.Mutex{}, } + for _, opt := range opts { + opt(c) + } + return c } func (c *ConnectClient) SetUpdateManager(um *updater.Manager) { @@ -100,11 +143,14 @@ func (c *ConnectClient) RunOnAndroid( stateFilePath string, cacheDir string, ) error { + notifier := tunnelnotifier.New(networkChangeListener, nil) + defer notifier.Close() + // in case of non Android os these variables will be nil mobileDependency := MobileDependency{ TunAdapter: tunAdapter, IFaceDiscover: iFaceDiscover, - NetworkChangeListener: networkChangeListener, + NetworkChangeListener: notifier, HostDNSAddresses: dnsAddresses, DnsReadyListener: dnsReadyListener, StateFilePath: stateFilePath, @@ -124,10 +170,13 @@ func (c *ConnectClient) RunOniOS( // Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension. debug.SetGCPercent(5) + notifier := tunnelnotifier.New(networkChangeListener, dnsManager) + defer notifier.Close() + mobileDependency := MobileDependency{ FileDescriptor: fileDescriptor, - NetworkChangeListener: networkChangeListener, - DnsManager: dnsManager, + NetworkChangeListener: notifier, + DnsManager: notifier, StateFilePath: stateFilePath, TempDir: cacheDir, } @@ -135,6 +184,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 @@ -240,13 +294,23 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan log.Errorf("failed to clean up temporary installer file: %v", err) } - defer c.statusRecorder.ClientStop() + defer func() { + c.statusRecorder.SetSessionExpiresAt(time.Time{}) + c.statusRecorder.ClientStop() + }() operation := func() error { // if context cancelled we not start new backoff cycle if c.ctx.Err() != nil { return nil } + // suspend connection attempts while the OS reports no usable network + if waited, err := c.netState.Wait(c.ctx); err != nil { + return nil + } else if waited { + backOff.Reset() + } + state.Set(StatusConnecting) engineCtx, cancel := context.WithCancel(c.ctx) @@ -258,8 +322,18 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan }() log.Debugf("connecting to the Management service %s", c.config.ManagementURL.Host) - mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled) + mgmClient, err := mgm.NewClient(engineCtx, c.config.ManagementURL.Host, myPrivateKey, mgmTlsEnabled, + mgm.WithNetworkState(c.netState), mgm.WithSweeper(c.sweeper)) if err != nil { + // On daemon shutdown / Down() the parent context is cancelled + // and the dial fails with "context canceled". Wrapping that + // into state would leave the snapshot stuck at Connecting+err + // until the backoff loop wakes up — instead let the operation + // return cleanly so the deferred state.Set(StatusIdle) takes + // effect on the next iteration. + if c.ctx.Err() != nil { + return nil + } return wrapErr(gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Management Service : %s", err)) } mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder) @@ -290,7 +364,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) @@ -298,6 +372,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan c.clientMetrics.RecordLoginDuration(engineCtx, time.Since(loginStarted), true) c.statusRecorder.MarkManagementConnected() + if metricsConfig := loginResp.GetNetbirdConfig().GetMetrics(); metricsConfig != nil { + c.clientMetrics.UpdatePushFromMgm(c.ctx, metricsConfig.GetEnabled()) + } + localPeerState := peer.LocalPeerState{ IP: loginResp.GetPeerConfig().GetAddress(), PubKey: myPrivateKey.PublicKey().String(), @@ -320,7 +398,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan }() // with the global Netbird config in hand connect (just a connection, no stream yet) Signal - signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey) + signalClient, err := connectToSignal(engineCtx, loginResp.GetNetbirdConfig(), myPrivateKey, c.netState, c.sweeper) if err != nil { log.Error(err) return wrapErr(err) @@ -356,7 +434,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan engineConfig.StateDir = filepath.Dir(path) } - relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU) + relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU, + relayClient.WithNetworkState(c.netState), relayClient.WithSweeper(c.sweeper)) c.statusRecorder.SetRelayMgr(relayManager) if len(relayURLs) > 0 { if token != nil { @@ -383,6 +462,8 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan StateManager: stateManager, UpdateManager: c.updateManager, ClientMetrics: c.clientMetrics, + MetricsCtx: c.ctx, + NetState: c.netState, }, mobileDependency) engine.SetSyncResponsePersistence(c.persistSyncResponse) c.engine = engine @@ -393,6 +474,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan return wrapErr(err) } + // Seed the session-expiry deadline from the LoginResponse. Subsequent + // changes flow in through SyncResponse and are applied in handleSync. + engine.ApplySessionDeadline(loginResp.GetSessionExpiresAt()) + log.Infof("Netbird engine started, the IP is: %s", peerConfig.GetAddress()) state.Set(StatusConnected) @@ -410,14 +495,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 +514,26 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan } c.statusRecorder.ClientStart() - err = backoff.Retry(operation, backOff) + // Wrap the backoff with c.ctx so Down()/actCancel propagates into the + // inter-attempt sleep — otherwise a 15s MaxInterval can keep the retry + // loop alive long after the caller asked to give up, leaving the + // status stream stuck at Connecting. + err = backoff.Retry(operation, backoff.WithContext(backOff, c.ctx)) if err != nil { + // Once the client context is cancelled backoff.WithContext surfaces the + // bare context error, and any attempt torn down mid-flight reports the + // same. That cancellation is the caller asking us to stop (Stop, Down or + // an engine restart), so exit cleanly instead of handing back a failure + // the caller would have to distinguish from a real one. + if c.ctx.Err() != nil && errors.Is(err, context.Canceled) { + log.Info("exiting client retry loop, context cancelled") + return 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 +611,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 } @@ -585,8 +678,9 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf BlockLANAccess: config.BlockLANAccess, BlockInbound: config.BlockInbound, DisableIPv6: config.DisableIPv6, + SyncMessageVersion: config.SyncMessageVersion, - LazyConnectionEnabled: config.LazyConnectionEnabled, + LazyConnection: lazyconn.ParseState(config.LazyConnection), MTU: selectMTU(config.MTU, peerConfig.Mtu), LogPath: logPath, @@ -629,7 +723,7 @@ func selectMTU(localMTU uint16, peerMTU int32) uint16 { } // connectToSignal creates Signal Service client and established a connection -func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key) (*signal.GrpcClient, error) { +func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourPrivateKey wgtypes.Key, netState *netstate.State, sweeper *netsweep.Sweeper) (*signal.GrpcClient, error) { var sigTLSEnabled bool if wtConfig.Signal.Protocol == mgmProto.HostConfig_HTTPS { sigTLSEnabled = true @@ -637,7 +731,8 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP sigTLSEnabled = false } - signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled) + signalClient, err := signal.NewClient(ctx, wtConfig.Signal.Uri, ourPrivateKey, sigTLSEnabled, + signal.WithNetworkState(netState), signal.WithSweeper(sweeper)) if err != nil { log.Errorf("error while connecting to the Signal Exchange Service %s: %s", wtConfig.Signal.Uri, err) return nil, gstatus.Errorf(codes.FailedPrecondition, "failed connecting to Signal Service : %s", err) @@ -660,7 +755,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte, config.BlockLANAccess, config.BlockInbound, config.DisableIPv6, - config.LazyConnectionEnabled, + config.SyncMessageVersion, config.EnableSSHRoot, config.EnableSSHSFTP, config.EnableSSHLocalPortForwarding, diff --git a/client/internal/daemonaddr/owner.go b/client/internal/daemonaddr/owner.go new file mode 100644 index 000000000..c476f9ae6 --- /dev/null +++ b/client/internal/daemonaddr/owner.go @@ -0,0 +1,15 @@ +package daemonaddr + +// DaemonRunsAsSelf reports whether the daemon listening at addr runs as this very +// user. That is what makes an unprivileged daemon authorize this process for the +// changes it otherwise restricts to root or an administrator, so a client can tell +// up front whether those controls are usable instead of letting a save fail. +// +// It is answered from the ownership of the socket or pipe the daemon created, so it +// costs no round trip and needs no cooperation from the daemon. Ownership that +// cannot be read is reported as false, including for a TCP address, so a caller +// reading this as "the daemon would allow it" fails closed. The daemon remains the +// only thing that authorizes anything: this only decides what a client offers. +func DaemonRunsAsSelf(addr string) bool { + return daemonRunsAsSelf(addr) +} diff --git a/client/internal/daemonaddr/owner_unix.go b/client/internal/daemonaddr/owner_unix.go new file mode 100644 index 000000000..493e6528d --- /dev/null +++ b/client/internal/daemonaddr/owner_unix.go @@ -0,0 +1,40 @@ +//go:build !windows + +package daemonaddr + +import ( + "os" + "strings" + "syscall" + + log "github.com/sirupsen/logrus" +) + +// daemonRunsAsSelf compares the owner of the daemon's Unix socket with this +// process's uid. Root is not treated specially here: a root caller is privileged +// on its own merits, and a root-owned socket says nothing about the caller. +func daemonRunsAsSelf(addr string) bool { + path, ok := strings.CutPrefix(addr, "unix://") + if !ok { + return false + } + + info, err := os.Stat(path) + if err != nil { + log.Debugf("stat daemon socket %s: %v", path, err) + return false + } + + // Only a socket says anything about a daemon. A directory or a leftover + // regular file at that path is not one, and reading it as "the daemon runs as + // us" would offer controls the daemon then refuses. + if info.Mode()&os.ModeSocket == 0 { + return false + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false + } + return stat.Uid == uint32(os.Getuid()) +} diff --git a/client/internal/daemonaddr/owner_unix_test.go b/client/internal/daemonaddr/owner_unix_test.go new file mode 100644 index 000000000..363c7d95d --- /dev/null +++ b/client/internal/daemonaddr/owner_unix_test.go @@ -0,0 +1,62 @@ +//go:build !windows + +package daemonaddr + +import ( + "net" + "os" + "path/filepath" + "testing" +) + +// A socket this user created means the daemon runs as this user, which is the +// rootless case where the daemon delegates its authority to its own identity. +func TestDaemonRunsAsSelf_OwnSocket(t *testing.T) { + path := filepath.Join(t.TempDir(), "netbird.sock") + ln, err := net.Listen("unix", path) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { + if err := ln.Close(); err != nil { + t.Logf("close listener: %v", err) + } + }) + + if !DaemonRunsAsSelf("unix://" + path) { + t.Error("a socket owned by this user must count as the daemon running as us") + } +} + +// Everything that is not a readable socket of ours has to answer false, because +// the caller reads a true as "the daemon would authorize me". +func TestDaemonRunsAsSelf_FailsClosed(t *testing.T) { + dir := t.TempDir() + + // A socket owned by another user, which is what a root-run daemon looks like + // to an unprivileged client. Only assertable when we are not root ourselves. + rootOwned := "unix:///var/run/netbird.sock" + if _, err := os.Stat("/var/run/netbird.sock"); err == nil && os.Getuid() != 0 { + if DaemonRunsAsSelf(rootOwned) { + t.Error("a socket owned by another user must not count as ours") + } + } + + for name, addr := range map[string]string{ + "missing socket": "unix://" + filepath.Join(dir, "absent.sock"), + "tcp address": "tcp://127.0.0.1:41731", + "named pipe": "npipe://netbird", + "empty": "", + "no scheme": filepath.Join(dir, "absent.sock"), + "directory": "unix://" + dir, + "unknown scheme": "http://localhost:8080", + "scheme only": "unix://", + "relative socket": "unix://netbird.sock", + } { + t.Run(name, func(t *testing.T) { + if DaemonRunsAsSelf(addr) { + t.Errorf("%q must not count as a daemon running as us", addr) + } + }) + } +} diff --git a/client/internal/daemonaddr/owner_windows.go b/client/internal/daemonaddr/owner_windows.go new file mode 100644 index 000000000..1cd2bba15 --- /dev/null +++ b/client/internal/daemonaddr/owner_windows.go @@ -0,0 +1,42 @@ +//go:build windows + +package daemonaddr + +import ( + "context" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// daemonRunsAsSelf reads the owner of the daemon's pipe. A daemon running as the +// service account owns its pipe as LocalSystem, and an elevated one as +// BUILTIN\Administrators, so only a daemon the user started themselves matches. +func daemonRunsAsSelf(addr string) bool { + name, ok := strings.CutPrefix(addr, pipeScheme) + if !ok { + return false + } + + for _, path := range PipePaths(name) { + // Bounded: this runs on the UI's path for deciding which controls to + // offer, so a pipe that does not answer promptly must not stall it. A + // timeout leaves the caller unprivileged, which only disables controls. + ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) + conn, err := dialPipe(ctx, path) + cancel() + if err != nil { + continue + } + + owned := ipcauth.PipeOwnedBySelf(conn) + if cerr := conn.Close(); cerr != nil { + log.Debugf("close daemon pipe %s after ownership check: %v", path, cerr) + } + return owned + } + + return false +} diff --git a/client/internal/daemonaddr/pipe.go b/client/internal/daemonaddr/pipe.go new file mode 100644 index 000000000..51815ef5e --- /dev/null +++ b/client/internal/daemonaddr/pipe.go @@ -0,0 +1,103 @@ +package daemonaddr + +import ( + "context" + "net" + "runtime" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +const ( + // WindowsPipeAddr is the default daemon address on Windows. A named pipe + // carries the connecting process's token, which loopback TCP does not, so + // it is the only Windows transport on which the daemon can tell who is + // calling it. + WindowsPipeAddr = "npipe://netbird" + + // legacyWindowsAddr is the loopback-TCP address the Windows daemon used + // before named-pipe support. + legacyWindowsAddr = "tcp://127.0.0.1:41731" + + pipeScheme = "npipe://" + + // protectedPrefix is the NPFS namespace in which only LocalSystem and + // members of BUILTIN\Administrators may create a pipe. A daemon running as + // the service account creates its pipe there so that an unprivileged process + // cannot pre-create the name, which would keep the daemon from starting and + // leave callers talking to the squatter. Opening such a pipe needs no + // privilege, so unprivileged clients still reach the daemon. + protectedPrefix = `ProtectedPrefix\Administrators\` +) + +// DialTarget returns the gRPC dial target and transport options for a daemon +// address. The npipe scheme needs a context dialer because gRPC has no +// named-pipe resolver; unix and tcp are handled by gRPC itself. +func DialTarget(addr string) (string, []grpc.DialOption) { + opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} + + if name, ok := strings.CutPrefix(addr, pipeScheme); ok { + paths := PipePaths(name) + opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return dialPipePaths(ctx, paths) + })) + return "passthrough:///netbird-daemon-pipe", opts + } + + return strings.TrimPrefix(addr, "tcp://"), opts +} + +// PipePath maps an npipe address name ("netbird", from "npipe://netbird") to a +// Windows named-pipe path (\\.\pipe\netbird). A fully qualified path is left as +// is. +func PipePath(name string) string { + if strings.HasPrefix(name, `\\`) { + return name + } + return `\\.\pipe\` + name +} + +// PipePaths returns the paths a daemon control pipe may live at for an npipe +// address name, in the order both sides must try them: the protected name first, +// then the plain one. +// +// The daemon serves the first it can create, which is the protected name when it +// runs as the service account and the plain one when it runs as an ordinary user, +// as it does in netstack mode. Clients therefore have to try both, and because a +// client cannot tell from the name alone who created the pipe, the plain name is +// only usable once the server's identity has been checked: see +// verifyPipeServer. +// +// A fully qualified path is what the operator asked for and is used as is. +func PipePaths(name string) []string { + if strings.HasPrefix(name, `\\`) { + return []string{name} + } + return []string{PipePath(protectedPrefix + name), PipePath(name)} +} + +// IsProtectedPipePath reports whether a pipe path is in the namespace only an +// administrator or LocalSystem can create in, which is what lets a client trust +// such a pipe from its name alone. +func IsProtectedPipePath(path string) bool { + return strings.HasPrefix(path, `\\.\pipe\`+protectedPrefix) +} + +// MigrateLegacy upgrades the pre-named-pipe Windows daemon address to the named +// pipe, reporting whether it rewrote the address. Existing installs persist the +// daemon address, so without this an upgraded daemon would keep listening on +// loopback TCP, where callers carry no identity and privileged operations would +// have to be refused for everyone. Only the exact legacy default is rewritten: +// a deliberately chosen custom address is left alone. +func MigrateLegacy(addr string) (string, bool) { + return migrateLegacyForOS(runtime.GOOS, addr) +} + +func migrateLegacyForOS(goos, addr string) (string, bool) { + if goos == "windows" && addr == legacyWindowsAddr { + return WindowsPipeAddr, true + } + return addr, false +} diff --git a/client/internal/daemonaddr/pipe_other.go b/client/internal/daemonaddr/pipe_other.go new file mode 100644 index 000000000..04e8e7331 --- /dev/null +++ b/client/internal/daemonaddr/pipe_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package daemonaddr + +import ( + "context" + "fmt" + "net" +) + +// dialPipePaths is Windows-only: no other platform serves the daemon on a named +// pipe. +func dialPipePaths(context.Context, []string) (net.Conn, error) { + return nil, fmt.Errorf("named pipes are only supported on Windows") +} diff --git a/client/internal/daemonaddr/pipe_test.go b/client/internal/daemonaddr/pipe_test.go new file mode 100644 index 000000000..b9dfd90f1 --- /dev/null +++ b/client/internal/daemonaddr/pipe_test.go @@ -0,0 +1,30 @@ +package daemonaddr + +import ( + "slices" + "testing" +) + +// The protected name must be tried before the plain one on both sides: it is the +// one an unprivileged process cannot create, so preferring it is what keeps a +// squatter from owning the name the service daemon would otherwise use. +func TestPipePaths_PrefersTheProtectedName(t *testing.T) { + got := PipePaths("netbird") + want := []string{ + `\\.\pipe\ProtectedPrefix\Administrators\netbird`, + `\\.\pipe\netbird`, + } + if !slices.Equal(got, want) { + t.Errorf("PipePaths = %q, want %q", got, want) + } +} + +// An operator who passes a full path chose exactly one pipe, so neither side may +// look anywhere else. +func TestPipePaths_QualifiedPathIsUsedAsIs(t *testing.T) { + path := `\\.\pipe\custom-netbird` + got := PipePaths(path) + if !slices.Equal(got, []string{path}) { + t.Errorf("PipePaths = %q, want just %q", got, path) + } +} diff --git a/client/internal/daemonaddr/pipe_windows.go b/client/internal/daemonaddr/pipe_windows.go new file mode 100644 index 000000000..3cd10a6c3 --- /dev/null +++ b/client/internal/daemonaddr/pipe_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package daemonaddr + +import ( + "context" + "errors" + "fmt" + "net" + + "github.com/Microsoft/go-winio" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +// dialPipePaths connects to the first path that answers with a pipe server this +// client may trust, and returns the last error when none does. +func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) { + var lastErr error + for _, path := range paths { + conn, err := dialPipe(ctx, path) + if err != nil { + log.Debugf("dial daemon pipe %s: %v", path, err) + lastErr = err + continue + } + + // A pipe in the protected namespace could only have been created by an + // administrator or LocalSystem, so its name is the guarantee. Any other + // name has to be checked, because any local user can create one. + if !IsProtectedPipePath(path) { + if err := ipcauth.PipeServerTrusted(conn); err != nil { + if closeErr := conn.Close(); closeErr != nil { + log.Debugf("close untrusted pipe %s: %v", path, closeErr) + } + lastErr = fmt.Errorf("%s: %w", path, err) + continue + } + } + + return conn, nil + } + + if lastErr == nil { + lastErr = errors.New("no daemon pipe to connect to") + } + return nil, lastErr +} + +// dialPipe connects to the daemon control pipe at SECURITY_IDENTIFICATION. +// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the daemon +// cannot read the caller's token at all. Identification lets the daemon read the +// caller's SID and groups without granting it the ability to act as the caller. +func dialPipe(ctx context.Context, path string) (net.Conn, error) { + access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE) + return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification) +} diff --git a/client/internal/daemonaddr/resolve_pipe_other.go b/client/internal/daemonaddr/resolve_pipe_other.go new file mode 100644 index 000000000..1aede8453 --- /dev/null +++ b/client/internal/daemonaddr/resolve_pipe_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package daemonaddr + +// ResolveDaemonAddr is a no-op off Windows, where there is no named-pipe +// default to fall back from. +func ResolveDaemonAddr(addr string) string { + return addr +} diff --git a/client/internal/daemonaddr/resolve_pipe_windows.go b/client/internal/daemonaddr/resolve_pipe_windows.go new file mode 100644 index 000000000..d12ddb15d --- /dev/null +++ b/client/internal/daemonaddr/resolve_pipe_windows.go @@ -0,0 +1,82 @@ +//go:build windows + +package daemonaddr + +import ( + "net" + "strings" + "time" + + "github.com/Microsoft/go-winio" + log "github.com/sirupsen/logrus" +) + +// probeTimeout bounds each transport probe. Both are local, so a daemon that is +// listening answers immediately and one that is not fails immediately. +const probeTimeout = 300 * time.Millisecond + +// ResolveDaemonAddr keeps a client on the named pipe and never silently moves it +// off. When the pipe does not answer it checks the legacy loopback TCP address, so +// a client meeting a daemon that has not restarted since the upgrade can say what +// is wrong, but it does not connect there. +// +// Using that address automatically would be a downgrade the user never asked for: +// any local process can bind 127.0.0.1 while the daemon is not listening, and the +// transport carries no caller identity, so a client that accepted whatever answered +// would hand a setup key, a pre-shared key or an SSO prompt to a local impostor. An +// operator who needs the legacy address during the upgrade window can still pass +// --daemon-addr explicitly, which is a deliberate choice and still refuses the +// privileged operations. +// +// Only the pipe address is resolved. A custom address is left alone, though passing +// --daemon-addr npipe://netbird explicitly is indistinguishable from the default +// here, so it is treated the same way. +func ResolveDaemonAddr(addr string) string { + if addr != WindowsPipeAddr { + return addr + } + + for _, path := range PipePaths("netbird") { + if pipeAvailable(path) { + return addr + } + } + + if tcpAvailable(legacyWindowsAddr) { + log.Warnf("the daemon is not serving %s, but something is listening on the legacy %s. "+ + "Restart the NetBird service so it serves the pipe. That address is not used automatically: "+ + "any local user can bind it and it carries no caller identity, so pass --daemon-addr %s "+ + "explicitly if you accept that", + WindowsPipeAddr, legacyWindowsAddr, legacyWindowsAddr) + } + + return addr +} + +func pipeAvailable(path string) bool { + timeout := probeTimeout + conn, err := winio.DialPipe(path, &timeout) + if err != nil { + return false + } + if err := conn.Close(); err != nil { + log.Debugf("close daemon pipe probe: %v", err) + } + return true +} + +func tcpAvailable(addr string) bool { + host := addr + if _, after, ok := strings.Cut(addr, "://"); ok { + host = after + } + + conn, err := net.DialTimeout("tcp", host, probeTimeout) + if err != nil { + return false + } + if err := conn.Close(); err != nil { + log.Debugf("close daemon TCP probe: %v", err) + } + return true +} diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index a65d8bd05..1d31c75ca 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -34,9 +34,8 @@ import ( "github.com/netbirdio/netbird/shared/netiputil" ) -const readmeContent = `Netbird debug bundle -This debug bundle contains the following files. -If the --anonymize flag is set, the files are anonymized to protect sensitive information. +const readmeContent = `This debug bundle contains the following files. +If anonymization is enabled (--anonymize / --anonymize-level), the files are anonymized to protect sensitive information. status.txt: Anonymized status information of the NetBird client. client.log: Most recent, anonymized client log file of the NetBird client. @@ -52,6 +51,7 @@ nftables.txt: Anonymized nftables rules with packet counters across all families sysctls.txt: Forwarding, reverse-path filter, source-validation, and conntrack accounting sysctl values that the NetBird client may read or modify, if --system-info flag was provided (Linux only). resolv.conf: DNS resolver configuration from /etc/resolv.conf (Unix systems only), if --system-info flag was provided. scutil_dns.txt: DNS configuration from scutil --dns (macOS only), if --system-info flag was provided. +dns_windows.txt: Anonymized NRPT rules and policy table in effect, DNS client policy, and per-interface and per-adapter DNS configuration (Windows only), if --system-info flag was provided. resolved_domains.txt: Anonymized resolved domain IP addresses from the status recorder. config.txt: Anonymized configuration information of the NetBird client. network_map.json: Anonymized sync response containing peer configurations, routes, DNS settings, and firewall rules. @@ -70,21 +70,34 @@ capture.pcap: Packet capture in pcap format. Only present when capture was runni Anonymization Process -The files in this bundle have been anonymized to protect sensitive information. Here's how the anonymization was applied: +The files in this bundle have been anonymized to protect sensitive information. The level applied to this bundle is recorded at the top of this file. Here's how the anonymization was applied: IP Addresses -IPv4 addresses are replaced with addresses starting from 198.51.100.0 -IPv6 addresses are replaced with addresses starting from 100:: +Default level: +- Public IPv4 addresses are replaced with addresses starting from 198.51.100.0 +- Public IPv6 addresses are replaced with addresses starting from 2001:db8:ffff:: +- IPv6 unique local addresses (fc00::/7) are anonymized as well: their random global ID uniquely identifies the network. +- IP addresses from internal IPv4 ranges and well-known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., 169.254., fe80::). + +Strict level (--anonymize-level strict), in addition to the default level: +- Private (RFC 1918), CGNAT (100.64.0.0/10), and link-local (169.254.0.0/16, fe80::/10) addresses are anonymized too. +- Internal IPv4 addresses are replaced with addresses starting from 198.18.0.0 and internal IPv6 addresses with addresses starting from 2001:db8:1::, so internal addresses remain distinguishable from public ones. +- Addresses are mapped in order of first appearance: subnet structure, allocation scheme, and gateway conventions are not preserved. Prefix lengths of networks are preserved. +- Peer names in front of NetBird domains are replaced with numbered placeholders (e.g. peer-1.netbird.cloud), and subdomain labels of other domains with host-N placeholders. +- WireGuard public keys are replaced with consistent placeholder keys. -IP addresses from non public ranges and well known addresses are not anonymized (e.g. 8.8.8.8, 100.64.0.0/10, addresses starting with 192.168., 172.16., 10., etc.). Reoccuring IP addresses are replaced with the same anonymized address. Note: The anonymized IP addresses in the status file do not match those in the log and routes files. However, the anonymized IP addresses are consistent within the status file and across the routes and log files. +MAC Addresses +MAC addresses are replaced at every anonymization level with consistent placeholders counting up from 02:00:00:00:00:01. Broadcast, multicast, and all-zero addresses are kept. At the default level a preserved IPv6 link-local address may still embed a MAC address (EUI-64); the strict level anonymizes those addresses. + Domains All domain names (except for the netbird domains) are replaced with randomly generated strings ending in ".domain". Anonymized domains are consistent across all files in the bundle. Reoccuring domain names are replaced with the same anonymized domain. +At the strict level, the peer name labels in front of netbird domains are anonymized as well. Sync Response The network_map.json file contains the following anonymized information: @@ -225,6 +238,13 @@ scutil_dns.txt (macOS only): - Shows DNS configuration for all network interfaces - Includes search domains, nameservers, and DNS resolver settings - All IP addresses and domain names are anonymized + +dns_windows.txt (Windows only): +- Lists the NRPT rules of both policy stores, the local one and the group policy one, marking the rules the client created +- Follows them with the policy table the resolver has loaded, which differs from the rules while a change has not been picked up yet +- Includes the DNS client group policy, the global TCP/IP and Dnscache parameters, and the DNS values of every interface that has any +- Ends with the resolver configuration in effect per adapter, from GetAdaptersAddresses +- All IP addresses and domain names are anonymized ` const ( @@ -232,6 +252,12 @@ const ( errorLogFile = "netbird.err" stdoutLogFile = "netbird.out" + // Rotated-log glob prefixes (base log name without extension) passed to + // addRotatedLogFiles. The daemon's own log and the GUI log live in the same + // dir, so the prefixes must be disjoint to keep their rotated siblings apart. + clientLogPrefix = "client" + uiLogPrefix = "gui-client" + darwinErrorLogPath = "/var/log/netbird.out.log" darwinStdoutLogPath = "/var/log/netbird.err.log" ) @@ -241,6 +267,20 @@ type MetricsExporter interface { Export(w io.Writer) error } +// LogOpener opens a log file for inclusion in the bundle. It exists so that log +// files whose path was supplied by an IPC caller can be opened under a check +// the daemon defines, instead of being opened with the daemon's privileges +// unconditionally. +type LogOpener func(path string) (*os.File, error) + +func openLogFile(path string) (*os.File, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + return f, nil +} + type BundleGenerator struct { anonymizer *anonymize.Anonymizer @@ -249,6 +289,8 @@ type BundleGenerator struct { statusRecorder *peer.Status syncResponse *mgmProto.SyncResponse logPath string + uiLogPath string + uiLogOpener LogOpener tempDir string statePath string cpuProfile []byte @@ -259,6 +301,7 @@ type BundleGenerator struct { cliVersion string anonymize bool + anonymizeLevel anonymize.Level includeSystemInfo bool logFileCount uint32 @@ -266,7 +309,10 @@ type BundleGenerator struct { } type BundleConfig struct { - Anonymize bool + Anonymize bool + // AnonymizeLevel selects how much the anonymizer redacts. + // anonymize.LevelStrict implies Anonymize. + AnonymizeLevel anonymize.Level IncludeSystemInfo bool LogFileCount uint32 } @@ -276,14 +322,21 @@ type GeneratorDependencies struct { StatusRecorder *peer.Status SyncResponse *mgmProto.SyncResponse LogPath string - TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used. - StatePath string // Path to the state file. If empty, the ServiceManager default path is used. - CPUProfile []byte - CapturePath string - RefreshStatus func() - ClientMetrics MetricsExporter - DaemonVersion string - CliVersion string + UILogPath string // Absolute path to the desktop UI's gui-client.log, reported via RegisterUILog. Empty if no UI registered one. + // UILogOpener opens the UI log and its rotated siblings. The path comes from + // a local IPC caller, so the daemon must not open it with plain os.Open: the + // opener is where the caller's right to that file is enforced. Defaults to + // os.Open, which is only correct where the path is not caller-supplied + // (mobile). + UILogOpener LogOpener + TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used. + StatePath string // Path to the state file. If empty, the ServiceManager default path is used. + CPUProfile []byte + CapturePath string + RefreshStatus func() + ClientMetrics MetricsExporter + DaemonVersion string + CliVersion string } func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator { @@ -293,13 +346,23 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen logFileCount = 1 } + uiLogOpener := deps.UILogOpener + if uiLogOpener == nil { + uiLogOpener = openLogFile + } + + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(cfg.AnonymizeLevel) + return &BundleGenerator{ - anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()), + anonymizer: anonymizer, internalConfig: deps.InternalConfig, statusRecorder: deps.StatusRecorder, syncResponse: deps.SyncResponse, logPath: deps.LogPath, + uiLogPath: deps.UILogPath, + uiLogOpener: uiLogOpener, tempDir: deps.TempDir, statePath: deps.StatePath, cpuProfile: deps.CPUProfile, @@ -309,7 +372,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen daemonVersion: deps.DaemonVersion, cliVersion: deps.CliVersion, - anonymize: cfg.Anonymize, + anonymize: cfg.Anonymize || cfg.AnonymizeLevel >= anonymize.LevelStrict, + anonymizeLevel: cfg.AnonymizeLevel, includeSystemInfo: cfg.IncludeSystemInfo, logFileCount: logFileCount, } @@ -411,6 +475,10 @@ func (g *BundleGenerator) createArchive() error { log.Errorf("failed to add logs to debug bundle: %v", err) } + if err := g.addUILog(); err != nil { + log.Errorf("failed to add UI log to debug bundle: %v", err) + } + if err := g.addUpdateLogs(); err != nil { log.Errorf("failed to add updater logs: %v", err) } @@ -445,7 +513,13 @@ func (g *BundleGenerator) addSystemInfo() { } func (g *BundleGenerator) addReadme() error { - readmeReader := strings.NewReader(readmeContent) + level := "none (anonymization disabled)" + if g.anonymize { + level = g.anonymizeLevel.String() + } + header := fmt.Sprintf("Netbird debug bundle\nAnonymization level applied to this bundle: %s\n", level) + + readmeReader := strings.NewReader(header + readmeContent) if err := g.addFileToZip(readmeReader, "README.txt"); err != nil { return fmt.Errorf("add README file to zip: %w", err) } @@ -466,11 +540,11 @@ func (g *BundleGenerator) addStatus() error { fullStatus := g.statusRecorder.GetFullStatus() protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus) - protoFullStatus.Events = g.statusRecorder.GetEventHistory() overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{ - Anonymize: g.anonymize, - ProfileName: profName, - DaemonVersion: g.daemonVersion, + Anonymize: g.anonymize, + AnonymizeLevel: g.anonymizeLevel, + ProfileName: profName, + DaemonVersion: g.daemonVersion, }) overview.CliVersion = g.cliVersion statusOutput := overview.FullDetailSummary() @@ -623,7 +697,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) configContent.WriteString("NetBird Client Configuration:\n\n") if key, err := wgtypes.ParseKey(g.internalConfig.PrivateKey); err == nil { - configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", key.PublicKey().String())) + configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", g.anonymizer.AnonymizeWGKey(key.PublicKey().String()))) } configContent.WriteString(fmt.Sprintf("WgIface: %s\n", g.internalConfig.WgIface)) configContent.WriteString(fmt.Sprintf("WgPort: %d\n", g.internalConfig.WgPort)) @@ -663,6 +737,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess)) configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound)) configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6)) + configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion)) if g.internalConfig.DisableNotifications != nil { configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications)) @@ -681,7 +756,7 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) configContent.WriteString(fmt.Sprintf("ClientCertKeyPath: %s\n", g.internalConfig.ClientCertKeyPath)) } - configContent.WriteString(fmt.Sprintf("LazyConnectionEnabled: %v\n", g.internalConfig.LazyConnectionEnabled)) + configContent.WriteString(fmt.Sprintf("LazyConnection: %q\n", g.internalConfig.LazyConnection)) configContent.WriteString(fmt.Sprintf("MTU: %d\n", g.internalConfig.MTU)) } @@ -912,6 +987,11 @@ func (g *BundleGenerator) addUpdateLogs() error { } baseName := filepath.Base(logFile) + data, err = g.anonymizeBytes(data) + if err != nil { + log.Warnf("skipping update log file %s: %v", baseName, err) + continue + } if err := g.addFileToZip(bytes.NewReader(data), filepath.Join("update-logs", baseName)); err != nil { return fmt.Errorf("add update log file %s to zip: %w", baseName, err) } @@ -939,6 +1019,13 @@ func (g *BundleGenerator) addCorruptedStateFiles() error { } fileName := filepath.Base(match) + // Corrupted state files usually fail structured JSON anonymization, + // so run them through the string anonymizer instead. + data, err = g.anonymizeBytes(data) + if err != nil { + log.Warnf("skipping corrupted state file %s: %v", fileName, err) + continue + } if err := g.addFileToZip(bytes.NewReader(data), "corrupted_states/"+fileName); err != nil { log.Warnf("Failed to add corrupted state file %s to zip: %v", fileName, err) continue @@ -950,6 +1037,27 @@ func (g *BundleGenerator) addCorruptedStateFiles() error { return nil } +// anonymizeBytes runs raw file content through the string anonymizer line by +// line when anonymization is enabled. It errors instead of returning partial +// content, so a caller never adds an unanonymized fallback to the bundle. +func (g *BundleGenerator) anonymizeBytes(data []byte) ([]byte, error) { + if !g.anonymize { + return data, nil + } + + var buf bytes.Buffer + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + for scanner.Scan() { + buf.WriteString(g.anonymizer.AnonymizeString(scanner.Text())) + buf.WriteByte('\n') + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("anonymize content: %w", err) + } + return buf.Bytes(), nil +} + func (g *BundleGenerator) addMetrics() error { if g.clientMetrics == nil { log.Debugf("skipping metrics in debug bundle: no metrics collector") @@ -982,11 +1090,11 @@ func (g *BundleGenerator) addLogfile() error { logDir := filepath.Dir(g.logPath) - if err := g.addSingleLogfile(g.logPath, clientLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, g.logPath, clientLogFile); err != nil { return fmt.Errorf("add client log file to zip: %w", err) } - g.addRotatedLogFiles(logDir) + g.addRotatedLogFiles(openLogFile, logDir, clientLogPrefix) stdErrLogPath := filepath.Join(logDir, errorLogFile) stdoutLogPath := filepath.Join(logDir, stdoutLogFile) @@ -995,20 +1103,39 @@ func (g *BundleGenerator) addLogfile() error { stdoutLogPath = darwinStdoutLogPath } - if err := g.addSingleLogfile(stdErrLogPath, errorLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, stdErrLogPath, errorLogFile); err != nil { log.Warnf("Failed to add %s to zip: %v", errorLogFile, err) } - if err := g.addSingleLogfile(stdoutLogPath, stdoutLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, stdoutLogPath, stdoutLogFile); err != nil { log.Warnf("Failed to add %s to zip: %v", stdoutLogFile, err) } return nil } +// addUILog adds the desktop UI's gui-client.log (and its rotated siblings) to +// the bundle. The path is reported by the UI via RegisterUILog; empty when no +// UI registered one (e.g. headless / server). Missing file is non-fatal — the +// UI only writes it while the daemon is in debug, so it's often absent. +func (g *BundleGenerator) addUILog() error { + if g.uiLogPath == "" { + log.Debugf("no UI log path registered, skipping in debug bundle") + return nil + } + + if err := g.addSingleLogfile(g.uiLogOpener, g.uiLogPath, configs.UILogFile); err != nil { + return fmt.Errorf("add UI log file to zip: %w", err) + } + + g.addRotatedLogFiles(g.uiLogOpener, filepath.Dir(g.uiLogPath), uiLogPrefix) + + return nil +} + // addSingleLogfile adds a single log file to the archive -func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error { - logFile, err := os.Open(logPath) +func (g *BundleGenerator) addSingleLogfile(open LogOpener, logPath, targetName string) error { + logFile, err := open(logPath) if err != nil { return fmt.Errorf("open log file %s: %w", targetName, err) } @@ -1033,8 +1160,8 @@ func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error { } // addSingleLogFileGz adds a single gzipped log file to the archive -func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error { - f, err := os.Open(logPath) +func (g *BundleGenerator) addSingleLogFileGz(open LogOpener, logPath, targetName string) error { + f, err := open(logPath) if err != nil { return fmt.Errorf("open gz log file %s: %w", targetName, err) } @@ -1078,14 +1205,16 @@ func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error { return nil } -// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount -func (g *BundleGenerator) addRotatedLogFiles(logDir string) { +// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount. +// prefix is the base log name without extension (e.g. "client", "gui-client"); +// the glob matches both files rotated by us and by logrotate on linux. +func (g *BundleGenerator) addRotatedLogFiles(open LogOpener, logDir, prefix string) { if g.logFileCount == 0 { return } - // This regex will match both logs rotated by us and logrotate on linux - pattern := filepath.Join(logDir, "client*.log.*") + // This pattern matches both logs rotated by us and logrotate on linux + pattern := filepath.Join(logDir, prefix+"*.log.*") files, err := filepath.Glob(pattern) if err != nil { log.Warnf("failed to glob rotated logs: %v", err) @@ -1119,9 +1248,9 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) { for i := 0; i < maxFiles; i++ { name := filepath.Base(files[i]) if strings.HasSuffix(name, ".gz") { - err = g.addSingleLogFileGz(files[i], name) + err = g.addSingleLogFileGz(open, files[i], name) } else { - err = g.addSingleLogfile(files[i], name) + err = g.addSingleLogfile(open, files[i], name) } if err != nil { log.Warnf("failed to add rotated log %s: %v", name, err) @@ -1401,6 +1530,7 @@ func anonymizeRemotePeer(peer *mgmProto.RemotePeerConfig, anonymizer *anonymize. } peer.Fqdn = anonymizer.AnonymizeDomain(peer.Fqdn) + peer.WgPubKey = anonymizer.AnonymizeWGKey(peer.WgPubKey) anonymizeSSHConfig(peer.SshConfig) } diff --git a/client/internal/debug/debug_ios.go b/client/internal/debug/debug_ios.go index a07c23dbd..001d64241 100644 --- a/client/internal/debug/debug_ios.go +++ b/client/internal/debug/debug_ios.go @@ -27,7 +27,7 @@ func (g *BundleGenerator) addPlatformLog() error { } swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile) - if err := g.addSingleLogfile(swiftLogPath, swiftLogFile); err != nil { + if err := g.addSingleLogfile(openLogFile, swiftLogPath, swiftLogFile); err != nil { // The Swift log is best-effort: the app may not have written it yet. log.Warnf("failed to add %s to debug bundle: %v", swiftLogFile, err) } diff --git a/client/internal/debug/debug_linux.go b/client/internal/debug/debug_linux.go index 40d864eda..a36c0c0e7 100644 --- a/client/internal/debug/debug_linux.go +++ b/client/internal/debug/debug_linux.go @@ -844,6 +844,10 @@ func collectSysctls() string { []string{"net.ipv4.conf.all.src_valid_mark", "net.ipv4.conf.default.src_valid_mark"}, listInterfaceSysctls("ipv4", "src_valid_mark")..., )) + writeSysctlGroup(&builder, "accept_ra", append( + []string{"net.ipv6.conf.all.accept_ra", "net.ipv6.conf.default.accept_ra"}, + listInterfaceSysctls("ipv6", "accept_ra")..., + )) writeSysctlGroup(&builder, "conntrack", []string{ "net.netfilter.nf_conntrack_acct", "net.netfilter.nf_conntrack_tcp_loose", diff --git a/client/internal/debug/debug_logfiles_test.go b/client/internal/debug/debug_logfiles_test.go index f6473f979..31420711f 100644 --- a/client/internal/debug/debug_logfiles_test.go +++ b/client/internal/debug/debug_logfiles_test.go @@ -40,6 +40,25 @@ func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) { require.NotContains(t, names, "other.log", "unrelated files should not be in bundle") } +// TestAddRotatedLogFiles_GUIPrefix asserts the prefix parameter scopes the glob +// to the GUI log: gui-client.log.* rotated siblings are picked up and the +// daemon's own client.log.* are not (and vice versa, covered above). This is +// the load-bearing check for the gui-client.log bundle collection — the old +// "client*.log.*" glob would have missed gui-client rotations. +func TestAddRotatedLogFiles_GUIPrefix(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "gui-client.log.1"), "gui rotated\n") + writeGzFile(t, filepath.Join(dir, "gui-client.log.2.gz"), "gui rotated gz\n") + writeFile(t, filepath.Join(dir, "client.log.1"), "daemon rotated\n") + + names := runAddRotatedLogFilesPrefix(t, dir, "gui-client", 10) + + require.Contains(t, names, "gui-client.log.1", "gui-client rotated file should be in bundle") + require.Contains(t, names, "gui-client.log.2.gz", "gui-client gz rotated file should be in bundle") + require.NotContains(t, names, "client.log.1", "daemon rotated file must not match the gui-client prefix") +} + // TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest // logFileCount rotated files are bundled, ordered by mtime. func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) { @@ -67,6 +86,10 @@ func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) { // runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory // zip writer and returns the set of entry names that ended up in the archive. func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} { + return runAddRotatedLogFilesPrefix(t, dir, "client", logFileCount) +} + +func runAddRotatedLogFilesPrefix(t *testing.T, dir, prefix string, logFileCount uint32) map[string]struct{} { t.Helper() var buf bytes.Buffer @@ -74,7 +97,7 @@ func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[st archive: zip.NewWriter(&buf), logFileCount: logFileCount, } - g.addRotatedLogFiles(dir) + g.addRotatedLogFiles(openLogFile, dir, prefix) require.NoError(t, g.archive.Close()) zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) diff --git a/client/internal/debug/debug_nonunix.go b/client/internal/debug/debug_nonunix.go index 18d017050..adc9b9649 100644 --- a/client/internal/debug/debug_nonunix.go +++ b/client/internal/debug/debug_nonunix.go @@ -1,4 +1,4 @@ -//go:build !unix +//go:build !unix && !windows package debug diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index ca7785d35..7fe93a5c1 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -885,8 +885,10 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { DNSRouteInterval: 5 * time.Second, ClientCertPath: "/tmp/cert", ClientCertKeyPath: "/tmp/key", - LazyConnectionEnabled: true, + LazyConnection: "on", MTU: 1280, + DisableIPv6: true, + SyncMessageVersion: func(v int) *int { return &v }(1), } for _, anonymize := range []bool{false, true} { diff --git a/client/internal/debug/debug_windows.go b/client/internal/debug/debug_windows.go new file mode 100644 index 000000000..e88940fd3 --- /dev/null +++ b/client/internal/debug/debug_windows.go @@ -0,0 +1,443 @@ +//go:build windows + +package debug + +import ( + "encoding/hex" + "errors" + "fmt" + "net/netip" + "strings" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + nbdns "github.com/netbirdio/netbird/client/internal/dns" +) + +const dnsInfoFileName = "dns_windows.txt" + +const ( + gpoDNSClientRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient` + tcpipParamsPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters` + dnscacheParams = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters` +) + +// interfaceDNSValues are the per-interface values that decide how a name is +// resolved and registered. Everything the DNS host manager writes is in here, +// so a bundle shows both what we set and what it replaced. +var interfaceDNSValues = []string{ + "NameServer", + "DhcpNameServer", + "Domain", + "DhcpDomain", + "SearchList", + "RegistrationEnabled", + "DisableDynamicUpdate", + "MaxNumberOfAddressesToRegister", + "EnableDHCP", +} + +// addDNSInfo collects and adds DNS configuration information to the archive +func (g *BundleGenerator) addDNSInfo() error { + if err := g.addFileToZip(strings.NewReader(g.collectDNSInfo()), dnsInfoFileName); err != nil { + return fmt.Errorf("add DNS info to zip: %w", err) + } + + return nil +} + +// collectDNSInfo renders the report. Everything below it reaches the platform +// through COM and through lazily resolved procedures, which panic when a +// procedure is missing rather than returning an error, and a debug bundle is not +// allowed to take the daemon down. The panic is contained here, and whatever was +// collected before it is kept and reported with it. +func (g *BundleGenerator) collectDNSInfo() (content string) { + var sb strings.Builder + + defer func() { + if r := recover(); r != nil { + log.Errorf("collecting Windows DNS configuration panicked: %v", r) + fmt.Fprintf(&sb, "\nerror: collection stopped: %v\n", r) + } + content = sb.String() + }() + + sb.WriteString("Windows DNS configuration\n") + sb.WriteString("=========================\n") + + adapters, adaptersErr := adapterAddresses() + + g.writeNRPTRules(&sb, "NRPT rules, local policy store", nbdns.DNSPolicyConfigRoot) + g.writeNRPTRules(&sb, "NRPT rules, group policy store", nbdns.GPODNSPolicyConfigRoot) + g.writeEffectiveNRPTPolicies(&sb) + g.writeRegistryKey(&sb, "DNS client group policy", gpoDNSClientRoot) + g.writeRegistryKey(&sb, "Global TCP/IP parameters", tcpipParamsPath) + g.writeRegistryKey(&sb, "Dnscache parameters", dnscacheParams) + g.writeInterfaceDNS(&sb, "Per-interface DNS, IPv4", nbdns.InterfaceConfigPath, adapterNames(adapters)) + g.writeInterfaceDNS(&sb, "Per-interface DNS, IPv6", nbdns.InterfaceConfigPathV6, adapterNames(adapters)) + g.writeAdapterDNS(&sb, adapters, adaptersErr) + + return sb.String() +} + +// writeNRPTRules lists every rule in a policy store, ours and any other +// product's, since a foreign rule for the same namespace decides resolution +// just as ours does. Rules the client wrote are marked. +func (g *BundleGenerator) writeNRPTRules(sb *strings.Builder, title, root string) { + writeSection(sb, title, root) + + names, err := subKeyNames(root) + if err != nil { + fmt.Fprintf(sb, "error: %v\n", err) + return + } + + if len(names) == 0 { + sb.WriteString("no rules\n") + return + } + + for _, name := range names { + owner := "" + if strings.HasPrefix(strings.ToLower(name), strings.ToLower(nbdns.NRPTKeyPrefix)) { + owner = " (netbird)" + } + fmt.Fprintf(sb, "%s%s\n", name, owner) + g.writeValues(sb, root+`\`+name, nil, " ") + } +} + +// writeEffectiveNRPTPolicies reports the table the resolver answers from, which +// the registry cannot show: a rule is written before it is loaded, and it keeps +// being enforced after its key is gone until the resolver reloads its policy. +func (g *BundleGenerator) writeEffectiveNRPTPolicies(sb *strings.Builder) { + writeSection(sb, "NRPT policy table in effect", nrptPolicyClass+"."+nrptPolicyMethod+" in "+nrptPolicyNamespace) + + entries, err := effectiveNRPTPolicies() + if err != nil { + fmt.Fprintf(sb, "error: %v\n", err) + return + } + + if len(entries) == 0 { + sb.WriteString("no policies\n") + return + } + + for _, entry := range entries { + fmt.Fprintf(sb, "%s\n", g.anonymizeValue("Namespace", entry.namespace)) + for _, value := range entry.values { + fmt.Fprintf(sb, " %s: %s\n", value.name, g.anonymizeValue(value.name, value.value)) + } + } +} + +// writeInterfaceDNS reports the DNS values of every interface that has any, so +// the netbird interface can be compared against the physical ones. The registry +// keys the values by GUID, so each is named from the adapter list; a GUID with +// no adapter is a leftover key of an interface that no longer exists. +func (g *BundleGenerator) writeInterfaceDNS(sb *strings.Builder, title, root string, names map[string]string) { + writeSection(sb, title, root) + + guids, err := subKeyNames(root) + if err != nil { + fmt.Fprintf(sb, "error: %v\n", err) + return + } + + var reported int + for _, guid := range guids { + var iface strings.Builder + g.writeValues(&iface, root+`\`+guid, interfaceDNSValues, " ") + if iface.Len() == 0 { + continue + } + + name, ok := names[strings.ToLower(guid)] + if !ok { + name = "no adapter with this GUID" + } + + reported++ + fmt.Fprintf(sb, "%s (%s)\n%s", guid, name, iface.String()) + } + + if reported == 0 { + sb.WriteString("no interface holds DNS values\n") + } +} + +// writeRegistryKey reports the values of a single key, without its subkeys. +func (g *BundleGenerator) writeRegistryKey(sb *strings.Builder, title, path string) { + writeSection(sb, title, path) + + var values strings.Builder + g.writeValues(&values, path, nil, "") + if values.Len() == 0 { + sb.WriteString("no values\n") + return + } + + sb.WriteString(values.String()) +} + +// writeValues renders the values of a key. A nil names list reports every +// value, otherwise only those named and present. +func (g *BundleGenerator) writeValues(sb *strings.Builder, path string, names []string, indent string) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE) + switch { + case errors.Is(err, registry.ErrNotExist), errors.Is(err, windows.ERROR_PATH_NOT_FOUND): + // an absent key is the normal state for the GPO store and for + // interfaces without DNS settings + log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", path) + return + case err != nil: + fmt.Fprintf(sb, "%serror: open HKEY_LOCAL_MACHINE\\%s: %v\n", indent, path, err) + return + } + defer closeKey(k) + + if names == nil { + names, err = k.ReadValueNames(-1) + if err != nil { + fmt.Fprintf(sb, "%serror: read value names: %v\n", indent, err) + return + } + } + + for _, name := range names { + value, err := readRegistryValue(k, name) + switch { + case errors.Is(err, registry.ErrNotExist): + // the caller asks for a fixed set of values, most of which a + // given interface does not carry + continue + case err != nil: + // report rather than omit: a value that is there but cannot be + // read reads as unset otherwise + fmt.Fprintf(sb, "%s%s: error: %v\n", indent, name, err) + continue + } + + fmt.Fprintf(sb, "%s%s: %s\n", indent, name, g.anonymizeValue(name, value)) + } +} + +// anonymizeValue redacts a registry value according to what its name says it +// holds. Domains and addresses are handled per entry rather than by the string +// pass: the pass only replaces domains something else in the bundle already +// seeded, and its address regex would eat the digit labels of a reverse zone. +func (g *BundleGenerator) anonymizeValue(name, value string) string { + if !g.anonymize || value == "" { + return value + } + + switch { + case holdsDomains(name): + return joinValueEntries(splitValueEntries(value), g.anonymizeDomain) + case holdsAddresses(name): + return joinValueEntries(splitValueEntries(value), g.anonymizer.AnonymizeIPString) + default: + return g.anonymizer.AnonymizeString(value) + } +} + +// holdsDomains reports whether a value name holds domains: the domain list of +// an NRPT rule (Name) or of the policy table (Namespace), a search list, the +// DNS suffix values of the TCP/IP and policy keys, which all end in "Domain" +// (Domain, DhcpDomain, NV Domain, ICSDomain), and a proxy host name. +func holdsDomains(name string) bool { + lower := strings.ToLower(name) + return lower == "name" || lower == "namespace" || lower == "searchlist" || + strings.HasSuffix(lower, "domain") || strings.HasSuffix(lower, "proxyname") +} + +// holdsAddresses reports whether a value name holds DNS server addresses +// (NameServer, DhcpNameServer, GenericDNSServers, NameServers). +func holdsAddresses(name string) bool { + lower := strings.ToLower(name) + return strings.Contains(lower, "nameserver") || strings.Contains(lower, "dnsserver") +} + +// adapterNames maps adapter GUIDs, as the registry keys the interfaces, to the +// names an operator sees. +func adapterNames(adapters []*windows.IpAdapterAddresses) map[string]string { + names := make(map[string]string, len(adapters)) + for _, adapter := range adapters { + guid := windows.BytePtrToString(adapter.AdapterName) + names[strings.ToLower(guid)] = windows.UTF16PtrToString(adapter.FriendlyName) + } + return names +} + +// writeAdapterDNS reports the resolver configuration in effect per adapter, +// which is what the resolver uses for a name no NRPT rule matches. +func (g *BundleGenerator) writeAdapterDNS(sb *strings.Builder, adapters []*windows.IpAdapterAddresses, err error) { + writeSection(sb, "Adapter DNS configuration", "GetAdaptersAddresses") + + if err != nil { + fmt.Fprintf(sb, "error: %v\n", err) + return + } + + for _, adapter := range adapters { + name := windows.UTF16PtrToString(adapter.FriendlyName) + suffix := g.anonymizeDomain(windows.UTF16PtrToString(adapter.DnsSuffix)) + + fmt.Fprintf(sb, "%s (index %d, oper status %d)\n", name, adapter.IfIndex, adapter.OperStatus) + fmt.Fprintf(sb, " DNS suffix: %s\n", suffix) + + var servers []string + for server := adapter.FirstDnsServerAddress; server != nil; server = server.Next { + addr, ok := netip.AddrFromSlice(server.Address.IP()) + if !ok { + continue + } + + addr = addr.Unmap() + if g.anonymize { + addr = g.anonymizer.AnonymizeIP(addr) + } + servers = append(servers, addr.String()) + } + + fmt.Fprintf(sb, " DNS servers: %s\n", strings.Join(servers, ", ")) + } +} + +// anonymizeDomain anonymizes a single domain, keeping the leading dot an NRPT +// match domain carries. +func (g *BundleGenerator) anonymizeDomain(entry string) string { + if !g.anonymize { + return entry + } + + domain, dot := strings.CutPrefix(entry, ".") + if domain == "" { + return entry + } + + anonymized := g.anonymizer.AnonymizeDomain(domain) + if dot { + anonymized = "." + anonymized + } + return anonymized +} + +// splitValueEntries splits a registry value that holds a list. The separator +// differs per value: a REG_MULTI_SZ arrives joined with ", ", a SearchList is +// comma separated and a NameServer may use commas or spaces. +func splitValueEntries(value string) []string { + return strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ';' || r == ' ' || r == '\t' + }) +} + +func joinValueEntries(entries []string, anonymize func(string) string) string { + for i, entry := range entries { + entries[i] = anonymize(entry) + } + return strings.Join(entries, ", ") +} + +func writeSection(sb *strings.Builder, title, source string) { + fmt.Fprintf(sb, "\n%s\n%s\n%s\n", title, strings.Repeat("-", len(title)), source) +} + +func subKeyNames(root string) ([]string, error) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS) + if err != nil { + return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err) + } + defer closeKey(k) + + names, err := k.ReadSubKeyNames(-1) + if err != nil { + return nil, fmt.Errorf("read subkey names: %w", err) + } + + return names, nil +} + +// readRegistryValue renders a value as text regardless of its type, so an +// unexpected type in a policy key still shows up instead of being dropped. +func readRegistryValue(k registry.Key, name string) (string, error) { + _, valueType, err := k.GetValue(name, nil) + if err != nil { + return "", fmt.Errorf("get value %s: %w", name, err) + } + + switch valueType { + case registry.SZ, registry.EXPAND_SZ: + value, _, err := k.GetStringValue(name) + if err != nil { + return "", fmt.Errorf("get string value %s: %w", name, err) + } + return value, nil + case registry.MULTI_SZ: + values, _, err := k.GetStringsValue(name) + if err != nil { + return "", fmt.Errorf("get strings value %s: %w", name, err) + } + return strings.Join(values, ", "), nil + case registry.DWORD, registry.QWORD: + value, _, err := k.GetIntegerValue(name) + if err != nil { + return "", fmt.Errorf("get integer value %s: %w", name, err) + } + return fmt.Sprintf("%d (0x%x)", value, value), nil + case registry.BINARY: + value, _, err := k.GetBinaryValue(name) + if err != nil { + return "", fmt.Errorf("get binary value %s: %w", name, err) + } + return hex.EncodeToString(value), nil + default: + return fmt.Sprintf("", valueType), nil + } +} + +// adapterAddresses returns the adapter list including DNS servers. The call +// reports the size it needs, so grow the buffer and retry until it fits. +func adapterAddresses() (adapters []*windows.IpAdapterAddresses, err error) { + // GetAdaptersAddresses is resolved on first use and panics when it is + // missing, so this reports it as an error and leaves the rest of the + // report intact. + defer func() { + if r := recover(); r != nil { + adapters, err = nil, fmt.Errorf("GetAdaptersAddresses: %v", r) + } + }() + + const flags = windows.GAA_FLAG_SKIP_ANYCAST | windows.GAA_FLAG_SKIP_MULTICAST + + size := uint32(15000) + for range 3 { + buf := make([]byte, size) + first := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0])) + + err := windows.GetAdaptersAddresses(windows.AF_UNSPEC, flags, 0, first, &size) + if errors.Is(err, windows.ERROR_BUFFER_OVERFLOW) { + continue + } + if err != nil { + return nil, fmt.Errorf("GetAdaptersAddresses: %w", err) + } + + for adapter := first; adapter != nil; adapter = adapter.Next { + adapters = append(adapters, adapter) + } + return adapters, nil + } + + return nil, fmt.Errorf("GetAdaptersAddresses: buffer kept growing") +} + +func closeKey(k registry.Key) { + if err := k.Close(); err != nil { + log.Debugf("close registry key: %v", err) + } +} diff --git a/client/internal/debug/debug_windows_test.go b/client/internal/debug/debug_windows_test.go new file mode 100644 index 000000000..47df3f6f9 --- /dev/null +++ b/client/internal/debug/debug_windows_test.go @@ -0,0 +1,146 @@ +//go:build windows + +package debug + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/anonymize" +) + +func newDNSValueGenerator(level anonymize.Level) *BundleGenerator { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(level) + + return &BundleGenerator{ + anonymize: true, + anonymizeLevel: level, + anonymizer: anonymizer, + } +} + +// TestAnonymizeValueByName covers the value kinds of the DNS registry keys. The +// names decide the treatment, because the string pass alone replaces only +// domains another part of the bundle already seeded. +func TestAnonymizeValueByName(t *testing.T) { + tests := []struct { + name string + valueName string + value string + assert func(t *testing.T, got string) + }{ + { + name: "NRPT match domains keep the leading dot", + valueName: "Name", + value: ".internal.example.com, .corp.example.org", + assert: func(t *testing.T, got string) { + t.Helper() + for _, entry := range strings.Split(got, ", ") { + assert.True(t, strings.HasPrefix(entry, "."), "entry %q should keep its leading dot", entry) + assert.NotContains(t, entry, "example", "entry %q should not keep the original domain", entry) + } + }, + }, + { + name: "any value name ending in Domain is treated as a domain", + valueName: "ICSDomain", + value: "mshome.net", + assert: func(t *testing.T, got string) { + t.Helper() + assert.NotContains(t, got, "mshome", "should anonymize a domain suffix value") + }, + }, + { + name: "search list is a comma separated domain list", + valueName: "SearchList", + value: "corp.example.com,branch.example.com", + assert: func(t *testing.T, got string) { + t.Helper() + assert.NotContains(t, got, "example", "should anonymize every search domain") + assert.Len(t, strings.Split(got, ", "), 2, "should keep both search domains") + }, + }, + { + name: "name servers are anonymized as addresses", + valueName: "DhcpNameServer", + value: "203.0.113.10 8.8.8.8", + assert: func(t *testing.T, got string) { + t.Helper() + assert.NotContains(t, got, "203.0.113.10", "should anonymize a public resolver address") + // well-known resolvers stay readable at every level + assert.Contains(t, got, "8.8.8.8", "should keep a well-known resolver address") + }, + }, + { + name: "opaque values are left to the string pass", + valueName: "DataBasePath", + value: `%SystemRoot%\System32\drivers\etc`, + assert: func(t *testing.T, got string) { + t.Helper() + assert.Equal(t, `%SystemRoot%\System32\drivers\etc`, got, "should not alter a path") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := newDNSValueGenerator(anonymize.LevelDefault) + tc.assert(t, g.anonymizeValue(tc.valueName, tc.value)) + }) + } +} + +// TestParseNRPTPolicyTable parses the MOF text of the policy table out +// parameters, as the provider on a client with one NRPT rule renders it. +func TestParseNRPTPolicyTable(t *testing.T) { + const text = `[abstract] +class __PARAMETERS +{ + [Out, EmbeddedInstance("DnsClientPolicyConfiguration"): ToSubClass, ID(2): DisableOverride ToInstance] DnsClientPolicyConfiguration cmdletOutput[] = { +instance of DnsClientPolicyConfiguration +{ + DirectAccessProxyType = "NoProxy"; + DirectAccessQueryIPsecRequired = FALSE; + NameEncoding = "Utf8WithoutMapping"; + Namespace = ".0.100.in-addr.arpa"; +}, +instance of DnsClientPolicyConfiguration +{ + DirectAccessProxyType = "NoProxy"; + NameEncoding = "Utf8WithoutMapping"; + NameServers = {"100.0.255.254", "100.0.255.253"}; + Namespace = ".nb.internal"; +}}; + [in] boolean Effective; + [out] uint32 ReturnValue = 0; +}; +` + + entries := parseNRPTPolicyTable(text) + require.Len(t, entries, 2, "should parse both embedded instances") + + assert.Equal(t, ".0.100.in-addr.arpa", entries[0].namespace, "should read the namespace of the first instance") + assert.Equal(t, ".nb.internal", entries[1].namespace, "should read the namespace of the second instance") + + assert.Equal(t, []registryValue{ + {name: "DirectAccessProxyType", value: "NoProxy"}, + {name: "DirectAccessQueryIPsecRequired", value: "FALSE"}, + {name: "NameEncoding", value: "Utf8WithoutMapping"}, + }, entries[0].values, "should keep the remaining values in order") + + assert.Contains(t, entries[1].values, registryValue{name: "NameServers", value: "100.0.255.254, 100.0.255.253"}, + "should flatten a MOF array") + + for _, value := range entries[1].values { + assert.NotContains(t, value.name, "ReturnValue", "should not read the class level parameters as values") + } +} + +func TestParseNRPTPolicyTableEmpty(t *testing.T) { + assert.Empty(t, parseNRPTPolicyTable(""), "should parse no entries from empty text") + assert.Empty(t, parseNRPTPolicyTable("class __PARAMETERS\n{\n};\n"), "should parse no entries from a table with no instances") +} diff --git a/client/internal/debug/nrpt_windows.go b/client/internal/debug/nrpt_windows.go new file mode 100644 index 000000000..6b6e0e29a --- /dev/null +++ b/client/internal/debug/nrpt_windows.go @@ -0,0 +1,317 @@ +//go:build windows + +package debug + +import ( + "errors" + "fmt" + "runtime" + "strings" + "time" + + "github.com/go-ole/go-ole" + "github.com/go-ole/go-ole/oleutil" + log "github.com/sirupsen/logrus" +) + +const ( + // The NRPT policy table is reachable through the CIM class that backs + // Get-DnsClientNrptPolicy. Unlike the rules in the registry, the table is + // what the resolver currently has loaded, which is the only way to tell an + // applied rule from one that is merely written, in either direction. + nrptPolicyNamespace = `root\Microsoft\Windows\DNS` + nrptPolicyClass = "PS_DnsClientNrptPolicy" + nrptPolicyMethod = "Get" + + // The class has no instances, so the table comes from the out parameters + // of a static method call, rendered as MOF text: the embedded instances + // arrive as a safe array of objects, which cannot be read back through the + // COM bindings, and the text form carries all of them. + nrptPolicyInstanceKeyword = "instance of DnsClientPolicyConfiguration" + + nrptPolicyTimeout = 15 * time.Second +) + +// COM initialization results that leave the calling thread usable: S_FALSE for +// a thread this process already initialized, RPC_E_CHANGED_MODE for one that +// belongs to another apartment. +const ( + sFalse = 0x00000001 + rpcEChangedMode = 0x80010106 +) + +// nrptQueryInFlight admits one read of the policy table at a time. A provider +// that stops answering keeps its goroutine and the OS thread that goroutine +// pinned, so a later bundle reports that instead of pinning another one. +var nrptQueryInFlight = make(chan struct{}, 1) + +// nrptPolicyEntry is one namespace of the effective policy table, holding the +// values of an embedded DnsClientPolicyConfiguration instance in the order the +// provider reported them. +type nrptPolicyEntry struct { + namespace string + values []registryValue +} + +// registryValue is a name and its rendered value, shared by the registry and +// policy table readers so both anonymize by value name the same way. +type registryValue struct { + name string + value string +} + +// effectiveNRPTPolicies reads the effective NRPT table. The call is bounded +// because a WMI provider can block indefinitely and a debug bundle must not. +func effectiveNRPTPolicies() ([]nrptPolicyEntry, error) { + type result struct { + text string + err error + } + + select { + case nrptQueryInFlight <- struct{}{}: + default: + return nil, errors.New("an earlier read of the policy table has not returned") + } + + done := make(chan result, 1) + go func() { + // the slot is released here rather than by the caller, so a read that + // outlives the timeout holds it until the provider answers + defer func() { <-nrptQueryInFlight }() + + text, err := nrptPolicyTableText() + done <- result{text: text, err: err} + }() + + select { + case res := <-done: + if res.err != nil { + return nil, res.err + } + return parseNRPTPolicyTable(res.text), nil + case <-time.After(nrptPolicyTimeout): + return nil, errors.New("read of the policy table timed out") + } +} + +// nrptPolicyTableText calls the policy table method and returns the MOF text of +// its out parameters. +func nrptPolicyTableText() (text string, err error) { + // COM is per thread, and the collection is short lived, so the thread is + // pinned for the duration rather than initialized for the process. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + defer func() { + // The COM call chain is dynamically typed, so a provider that answers + // with an unexpected shape must not take the daemon down with it. + if r := recover(); r != nil { + err = fmt.Errorf("read NRPT policy table: %v", r) + } + }() + + owns, err := coInitialize() + if err != nil { + return "", err + } + if owns { + defer ole.CoUninitialize() + } + + locator, err := oleutil.CreateObject("WbemScripting.SWbemLocator") + if err != nil { + return "", fmt.Errorf("create WMI locator: %w", err) + } + defer locator.Release() + + dispatch, err := locator.QueryInterface(ole.IID_IDispatch) + if err != nil { + return "", fmt.Errorf("query WMI locator interface: %w", err) + } + defer dispatch.Release() + + service, err := dispatchCall(dispatch, "ConnectServer", nil, nrptPolicyNamespace) + if err != nil { + return "", fmt.Errorf("connect to %s: %w", nrptPolicyNamespace, err) + } + defer service.Release() + + inParams, err := spawnMethodInParams(service) + if err != nil { + return "", err + } + defer inParams.Release() + + // The effective table is the merge of the local and the group policy + // store, which is what the resolver answers from. + if _, err := oleutil.PutProperty(inParams, "Effective", true); err != nil { + return "", fmt.Errorf("set Effective parameter: %w", err) + } + + outParams, err := dispatchCall(service, "ExecMethod", nrptPolicyClass, nrptPolicyMethod, inParams) + if err != nil { + return "", fmt.Errorf("call %s.%s: %w", nrptPolicyClass, nrptPolicyMethod, err) + } + defer outParams.Release() + + textVariant, err := oleutil.CallMethod(outParams, "GetObjectText_") + if err != nil { + return "", fmt.Errorf("render policy table: %w", err) + } + defer func() { + if err := textVariant.Clear(); err != nil { + log.Debugf("clear policy table variant: %v", err) + } + }() + + return textVariant.ToString(), nil +} + +// spawnMethodInParams builds the in parameters instance the method needs. The +// provider rejects the call without one, even when every parameter is optional. +func spawnMethodInParams(service *ole.IDispatch) (*ole.IDispatch, error) { + class, err := dispatchCall(service, "Get", nrptPolicyClass) + if err != nil { + return nil, fmt.Errorf("get class %s: %w", nrptPolicyClass, err) + } + defer class.Release() + + methods, err := dispatchProperty(class, "Methods_") + if err != nil { + return nil, fmt.Errorf("get class methods: %w", err) + } + defer methods.Release() + + method, err := dispatchCall(methods, "Item", nrptPolicyMethod) + if err != nil { + return nil, fmt.Errorf("get method %s: %w", nrptPolicyMethod, err) + } + defer method.Release() + + params, err := dispatchProperty(method, "InParameters") + if err != nil { + return nil, fmt.Errorf("get method parameters: %w", err) + } + defer params.Release() + + inParams, err := dispatchCall(params, "SpawnInstance_") + if err != nil { + return nil, fmt.Errorf("spawn parameter instance: %w", err) + } + + return inParams, nil +} + +// parseNRPTPolicyTable pulls the embedded instances out of the MOF text. Each +// instance is a namespace of the table, with one name and value per line. +func parseNRPTPolicyTable(text string) []nrptPolicyEntry { + var entries []nrptPolicyEntry + var current *nrptPolicyEntry + + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(line), ";")) + + switch { + case strings.HasPrefix(line, nrptPolicyInstanceKeyword): + entries = append(entries, nrptPolicyEntry{}) + current = &entries[len(entries)-1] + continue + case strings.HasPrefix(line, "}"): + // closes an instance, and the array with the last one, so the + // class level parameters that follow are not read as values + current = nil + continue + case current == nil, line == "{": + continue + } + + name, value, ok := strings.Cut(line, " = ") + if !ok { + continue + } + + value = unquoteMOFValue(value) + if name == "Namespace" { + current.namespace = value + continue + } + + current.values = append(current.values, registryValue{name: name, value: value}) + } + + return entries +} + +// unquoteMOFValue renders a MOF scalar or array as plain text: "a" becomes a, +// and {"a", "b"} becomes a, b. +func unquoteMOFValue(value string) string { + value = strings.TrimSpace(value) + + if inner, ok := strings.CutPrefix(value, "{"); ok { + value = strings.TrimSuffix(inner, "}") + + entries := strings.Split(value, ",") + for i, entry := range entries { + entries[i] = strings.Trim(strings.TrimSpace(entry), `"`) + } + return strings.Join(entries, ", ") + } + + return strings.Trim(value, `"`) +} + +// coInitialize prepares the calling thread for COM and reports whether this +// call owns the initialization, which decides whether it may be balanced with +// CoUninitialize. S_FALSE took a reference on a thread this process had already +// initialized and so has to be released, while RPC_E_CHANGED_MODE took none: +// the thread belongs to another apartment, which is usable but is not ours to +// uninitialize. +func coInitialize() (bool, error) { + err := ole.CoInitializeEx(0, ole.COINIT_MULTITHREADED) + if err == nil { + return true, nil + } + + var oleErr *ole.OleError + if errors.As(err, &oleErr) { + switch oleErr.Code() { + case sFalse: + return true, nil + case rpcEChangedMode: + return false, nil + } + } + + return false, fmt.Errorf("initialize COM: %w", err) +} + +// dispatchCall calls a COM method that returns an object. +func dispatchCall(dispatch *ole.IDispatch, method string, params ...any) (*ole.IDispatch, error) { + variant, err := oleutil.CallMethod(dispatch, method, params...) + if err != nil { + return nil, err + } + + object := variant.ToIDispatch() + if object == nil { + return nil, fmt.Errorf("%s returned no object", method) + } + + return object, nil +} + +// dispatchProperty reads a COM property that holds an object. +func dispatchProperty(dispatch *ole.IDispatch, property string) (*ole.IDispatch, error) { + variant, err := oleutil.GetProperty(dispatch, property) + if err != nil { + return nil, err + } + + object := variant.ToIDispatch() + if object == nil { + return nil, fmt.Errorf("property %s holds no object", property) + } + + return object, nil +} diff --git a/client/internal/debug/uilog_test.go b/client/internal/debug/uilog_test.go new file mode 100644 index 000000000..103e98c6f --- /dev/null +++ b/client/internal/debug/uilog_test.go @@ -0,0 +1,64 @@ +package debug + +import ( + "archive/zip" + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/configs" +) + +// bundleEntries generates a bundle with the given generator and returns the +// set of entry names in the resulting archive. +func bundleEntries(t *testing.T, g *BundleGenerator) map[string]struct{} { + t.Helper() + + path, err := g.Generate() + require.NoError(t, err) + t.Cleanup(func() { _ = os.Remove(path) }) + + data, err := os.ReadFile(path) + require.NoError(t, err) + + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + require.NoError(t, err) + + names := make(map[string]struct{}, len(zr.File)) + for _, f := range zr.File { + names[f.Name] = struct{}{} + } + return names +} + +func TestBundleIncludesUILogWhenOpenerAllows(t *testing.T) { + path := filepath.Join(t.TempDir(), configs.UILogFile) + require.NoError(t, os.WriteFile(path, []byte("gui log"), 0600)) + + g := NewBundleGenerator(GeneratorDependencies{ + UILogPath: path, + UILogOpener: openLogFile, + }, BundleConfig{}) + + require.Contains(t, bundleEntries(t, g), configs.UILogFile) +} + +// A UILogOpener that refuses (as the ownership check does for a foreign file) +// keeps the UI log out of the bundle without failing bundle generation. +func TestBundleExcludesUILogWhenOpenerRefuses(t *testing.T) { + path := filepath.Join(t.TempDir(), configs.UILogFile) + require.NoError(t, os.WriteFile(path, []byte("secret"), 0600)) + + g := NewBundleGenerator(GeneratorDependencies{ + UILogPath: path, + UILogOpener: func(string) (*os.File, error) { + return nil, fmt.Errorf("not owned by the caller") + }, + }, BundleConfig{}) + + require.NotContains(t, bundleEntries(t, g), configs.UILogFile) +} diff --git a/client/internal/debug/upload.go b/client/internal/debug/upload.go index cdf52409d..88fde6d6f 100644 --- a/client/internal/debug/upload.go +++ b/client/internal/debug/upload.go @@ -3,10 +3,12 @@ package debug import ( "context" "crypto/sha256" + "crypto/tls" "encoding/json" "fmt" "io" "net/http" + neturl "net/url" "os" "github.com/netbirdio/netbird/upload-server/types" @@ -14,20 +16,80 @@ import ( const maxBundleUploadSize = 50 * 1024 * 1024 -func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string) (key string, err error) { - response, err := getUploadURL(ctx, url, managementURL) +// requireHTTPS refuses any URL the daemon would fetch or upload to that is not +// https. The daemon runs as root and the bundle carries its logs and state, so a +// plaintext hop is a place to intercept the bundle or the presigned redirect. +// The server-side gate already enforces this for the desktop path; this also +// covers the mobile and job-runner callers that reach this package directly. +// Skipped when the caller opted into an insecure upload (self-hosted server). +func requireHTTPS(what, rawURL string) error { + parsed, err := neturl.Parse(rawURL) + if err != nil { + return fmt.Errorf("parse %s: %w", what, err) + } + if parsed.Scheme != "https" { + return fmt.Errorf("%s must use https, got scheme %q", what, parsed.Scheme) + } + return nil +} + +// uploadClient returns the HTTP client for the upload requests. The default +// client verifies TLS and refuses a redirect that would downgrade to a non-https +// hop, so a bundle can never leave over http after an https start. The insecure +// variant accepts http and untrusted certificates, and is only reachable for a +// privileged caller that passed --upload-bundle-insecure (see +// requirePrivilegeForUploadURL). +func uploadClient(insecure bool) *http.Client { + if !insecure { + return &http.Client{CheckRedirect: rejectInsecureRedirect} + } + return &http.Client{ + Transport: &http.Transport{ + //nolint:gosec // opt-in, privileged, self-hosted upload servers + TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}, + }, + } +} + +// rejectInsecureRedirect refuses a redirect to a non-https target and keeps the +// standard library's 10-hop limit that a custom CheckRedirect would otherwise +// disable. +func rejectInsecureRedirect(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + return fmt.Errorf("refusing redirect to non-https URL %s", req.URL.Redacted()) + } + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + return nil +} + +func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string, insecure bool) (key string, err error) { + if !insecure { + if err := requireHTTPS("upload service URL", url); err != nil { + return "", err + } + } + + response, err := getUploadURL(ctx, url, managementURL, insecure) if err != nil { return "", err } - err = upload(ctx, filePath, response) + if !insecure { + if err := requireHTTPS("upload URL from service", response.URL); err != nil { + return "", err + } + } + + err = upload(ctx, filePath, response, insecure) if err != nil { return "", err } return response.Key, nil } -func upload(ctx context.Context, filePath string, response *types.GetURLResponse) error { +func upload(ctx context.Context, filePath string, response *types.GetURLResponse, insecure bool) error { fileData, err := os.Open(filePath) if err != nil { return fmt.Errorf("open file: %w", err) @@ -52,7 +114,7 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse req.ContentLength = stat.Size() req.Header.Set("Content-Type", "application/octet-stream") - putResp, err := http.DefaultClient.Do(req) + putResp, err := uploadClient(insecure).Do(req) if err != nil { return fmt.Errorf("upload failed: %v", err) } @@ -65,16 +127,23 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse return nil } -func getUploadURL(ctx context.Context, url string, managementURL string) (*types.GetURLResponse, error) { - id := getURLHash(managementURL) - getReq, err := http.NewRequestWithContext(ctx, "GET", url+"?id="+id, nil) +func getUploadURL(ctx context.Context, serviceURL string, managementURL string, insecure bool) (*types.GetURLResponse, error) { + parsed, err := neturl.Parse(serviceURL) + if err != nil { + return nil, fmt.Errorf("parse upload service URL: %w", err) + } + q := parsed.Query() + q.Set("id", getURLHash(managementURL)) + parsed.RawQuery = q.Encode() + + getReq, err := http.NewRequestWithContext(ctx, "GET", parsed.String(), nil) if err != nil { return nil, fmt.Errorf("create GET request: %w", err) } getReq.Header.Set(types.ClientHeader, types.ClientHeaderValue) - resp, err := http.DefaultClient.Do(getReq) + resp, err := uploadClient(insecure).Do(getReq) if err != nil { return nil, fmt.Errorf("get presigned URL: %w", err) } diff --git a/client/internal/debug/upload_test.go b/client/internal/debug/upload_test.go index f224b8d3f..f3927cb81 100644 --- a/client/internal/debug/upload_test.go +++ b/client/internal/debug/upload_test.go @@ -5,6 +5,7 @@ import ( "errors" "net" "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -43,7 +44,7 @@ func TestUpload(t *testing.T) { fileContent := []byte("test file content") err := os.WriteFile(file, fileContent, 0640) require.NoError(t, err) - key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file) + key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file, true) require.NoError(t, err) id := getURLHash(testURL) require.Contains(t, key, id+"/") @@ -79,3 +80,47 @@ func waitForServer(t *testing.T, addr string) { } t.Fatalf("server did not start listening on %s in time", addr) } + +func TestRequireHTTPS(t *testing.T) { + require.NoError(t, requireHTTPS("upload URL", "https://upload.example/path")) + require.Error(t, requireHTTPS("upload URL", "http://upload.example/path")) + require.Error(t, requireHTTPS("upload URL", "ftp://upload.example/path")) + require.Error(t, requireHTTPS("upload URL", "://malformed")) +} + +func TestRejectInsecureRedirect(t *testing.T) { + httpsReq, err := http.NewRequest(http.MethodGet, "https://a.example/", nil) + require.NoError(t, err) + require.NoError(t, rejectInsecureRedirect(httpsReq, nil), "https redirect target must be allowed") + + httpReq, err := http.NewRequest(http.MethodGet, "http://a.example/", nil) + require.NoError(t, err) + require.Error(t, rejectInsecureRedirect(httpReq, nil), "http redirect target must be refused") + + require.Error(t, rejectInsecureRedirect(httpsReq, make([]*http.Request, 10)), "the 10-redirect limit must be enforced") +} + +// The secure client refuses to follow an https response that redirects to http, +// so a bundle can't be downgraded onto plaintext mid-flight. +func TestUploadClientRefusesHTTPSToHTTPRedirect(t *testing.T) { + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(plain.Close) + + secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, plain.URL, http.StatusFound) + })) + t.Cleanup(secure.Close) + + client := uploadClient(false) + // Trust the test server's cert without disabling verification globally. + client.Transport = secure.Client().Transport + + resp, err := client.Get(secure.URL) + if resp != nil { + _ = resp.Body.Close() + } + require.Error(t, err, "redirect from https to http must be refused") + require.Contains(t, err.Error(), "non-https") +} diff --git a/client/internal/debug/wgshow.go b/client/internal/debug/wgshow.go index 1e8a8a6cc..ee24902e6 100644 --- a/client/internal/debug/wgshow.go +++ b/client/internal/debug/wgshow.go @@ -35,14 +35,14 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string { var sb strings.Builder sb.WriteString(fmt.Sprintf("interface: %s\n", s.DeviceName)) - sb.WriteString(fmt.Sprintf(" public key: %s\n", s.PublicKey)) + sb.WriteString(fmt.Sprintf(" public key: %s\n", g.anonymizer.AnonymizeWGKey(s.PublicKey))) sb.WriteString(fmt.Sprintf(" listen port: %d\n", s.ListenPort)) if s.FWMark != 0 { sb.WriteString(fmt.Sprintf(" fwmark: %#x\n", s.FWMark)) } for _, peer := range s.Peers { - sb.WriteString(fmt.Sprintf("\npeer: %s\n", peer.PublicKey)) + sb.WriteString(fmt.Sprintf("\npeer: %s\n", g.anonymizer.AnonymizeWGKey(peer.PublicKey))) if peer.Endpoint.IP != nil { if g.anonymize { anonEndpoint := g.anonymizer.AnonymizeUDPAddr(peer.Endpoint) @@ -54,7 +54,11 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string { if len(peer.AllowedIPs) > 0 { var ipStrings []string for _, ipnet := range peer.AllowedIPs { - ipStrings = append(ipStrings, ipnet.String()) + ipStr := ipnet.String() + if g.anonymize { + ipStr = g.anonymizer.AnonymizeIPString(ipStr) + } + ipStrings = append(ipStrings, ipStr) } sb.WriteString(fmt.Sprintf(" allowed ips: %s\n", strings.Join(ipStrings, ", "))) } diff --git a/client/internal/dns/host_darwin.go b/client/internal/dns/host_darwin.go index 0f4eb6bf8..81029752e 100644 --- a/client/internal/dns/host_darwin.go +++ b/client/internal/dns/host_darwin.go @@ -267,18 +267,38 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) { return SystemDNSSettings{}, fmt.Errorf("sending the command: %w", err) } - var dnsSettings SystemDNSSettings + dnsSettings, serverAddresses, err := parseSystemDNSSettings(b) + if err != nil { + return dnsSettings, err + } + + s.mu.Lock() + s.origNameservers = serverAddresses + s.mu.Unlock() + + return dnsSettings, nil +} + +// parseSystemDNSSettings parses the output of `scutil show State:/Network/Service//DNS`. +// Lines that don't match the expected "index : value" shape are skipped: hosts with unusual +// network services (e.g. orphaned hardware ports) can produce entries without a value. +func parseSystemDNSSettings(out []byte) (SystemDNSSettings, []netip.Addr, error) { + // port is not exposed by scutil, default to 53 + dnsSettings := SystemDNSSettings{ServerPort: DefaultPort} var serverAddresses []netip.Addr inSearchDomainsArray := false inServerAddressesArray := false - scanner := bufio.NewScanner(bytes.NewReader(b)) + scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) switch { case strings.HasPrefix(line, "DomainName :"): - domainName := strings.TrimSpace(strings.Split(line, ":")[1]) - dnsSettings.Domains = append(dnsSettings.Domains, domainName) + domainName := strings.TrimSpace(strings.TrimPrefix(line, "DomainName :")) + if domainName != "" { + dnsSettings.Domains = append(dnsSettings.Domains, domainName) + } + continue case line == "SearchDomains : {": inSearchDomainsArray = true continue @@ -288,36 +308,45 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) { case line == "}": inSearchDomainsArray = false inServerAddressesArray = false + continue + } + + if !inSearchDomainsArray && !inServerAddressesArray { + continue + } + + parts := strings.SplitN(line, " : ", 2) + if len(parts) != 2 { + log.Debugf("skipping unexpected scutil DNS line %q", line) + continue + } + value := strings.TrimSpace(parts[1]) + if value == "" { + continue } if inSearchDomainsArray { - searchDomain := strings.Split(line, " : ")[1] - dnsSettings.Domains = append(dnsSettings.Domains, searchDomain) - } else if inServerAddressesArray { - address := strings.Split(line, " : ")[1] - if ip, err := netip.ParseAddr(address); err == nil && !ip.IsUnspecified() { - ip = ip.Unmap() - serverAddresses = append(serverAddresses, ip) - // Prefer the first IPv4 server as ServerIP since our DNS listener is IPv4. - if !dnsSettings.ServerIP.IsValid() && ip.Is4() { - dnsSettings.ServerIP = ip - } - } + dnsSettings.Domains = append(dnsSettings.Domains, value) + continue + } + + ip, err := netip.ParseAddr(value) + if err != nil || ip.IsUnspecified() { + continue + } + ip = ip.Unmap() + serverAddresses = append(serverAddresses, ip) + // Prefer the first IPv4 server as ServerIP since our DNS listener is IPv4. + if !dnsSettings.ServerIP.IsValid() && ip.Is4() { + dnsSettings.ServerIP = ip } } if err := scanner.Err(); err != nil { - return dnsSettings, err + return dnsSettings, serverAddresses, err } - // default to 53 port - dnsSettings.ServerPort = DefaultPort - - s.mu.Lock() - s.origNameservers = serverAddresses - s.mu.Unlock() - - return dnsSettings, nil + return dnsSettings, serverAddresses, nil } func (s *systemConfigurator) getOriginalNameservers() []netip.Addr { @@ -435,11 +464,15 @@ func (s *systemConfigurator) getPrimaryService() (string, string, error) { router := "" for scanner.Scan() { text := scanner.Text() + parts := strings.SplitN(text, ":", 2) + if len(parts) != 2 { + continue + } if strings.Contains(text, "PrimaryService") { - primaryService = strings.TrimSpace(strings.Split(text, ":")[1]) + primaryService = strings.TrimSpace(parts[1]) } if strings.Contains(text, "Router") { - router = strings.TrimSpace(strings.Split(text, ":")[1]) + router = strings.TrimSpace(parts[1]) } } if err := scanner.Err(); err != nil && err != io.EOF { diff --git a/client/internal/dns/host_darwin_test.go b/client/internal/dns/host_darwin_test.go index 94d020c39..bee691c71 100644 --- a/client/internal/dns/host_darwin_test.go +++ b/client/internal/dns/host_darwin_test.go @@ -328,6 +328,120 @@ func removeTestDNSKey(key string) error { return err } +func TestParseSystemDNSSettings(t *testing.T) { + tests := []struct { + name string + output string + expectedDomains []string + expectedServers []netip.Addr + expectedIP netip.Addr + }{ + { + name: "well_formed", + output: ` { + DomainName : example.com + SearchDomains : { + 0 : example.com + 1 : corp.example.com + } + ServerAddresses : { + 0 : 192.168.1.1 + 1 : fd00::53 + } +} +`, + expectedDomains: []string{"example.com", "example.com", "corp.example.com"}, + expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1"), netip.MustParseAddr("fd00::53")}, + expectedIP: netip.MustParseAddr("192.168.1.1"), + }, + { + // entries without a value after the separator used to panic with + // "index out of range [1] with length 1" + name: "malformed_array_entries_skipped", + output: ` { + SearchDomains : { + 0 : + (null) + + 1 : corp.example.com + } + ServerAddresses : { + 0 : + 1 : 192.168.1.1 + } +} +`, + expectedDomains: []string{"corp.example.com"}, + expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")}, + expectedIP: netip.MustParseAddr("192.168.1.1"), + }, + { + name: "domain_name_without_value_skipped", + output: ` { + DomainName : + ServerAddresses : { + 0 : 192.168.1.1 + } +} +`, + expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")}, + expectedIP: netip.MustParseAddr("192.168.1.1"), + }, + { + name: "ipv6_first_prefers_ipv4_server_ip", + output: ` { + ServerAddresses : { + 0 : fd00::53 + 1 : 192.168.1.1 + } +} +`, + expectedServers: []netip.Addr{netip.MustParseAddr("fd00::53"), netip.MustParseAddr("192.168.1.1")}, + expectedIP: netip.MustParseAddr("192.168.1.1"), + }, + { + name: "invalid_and_unspecified_addresses_skipped", + output: ` { + ServerAddresses : { + 0 : (null) + 1 : 0.0.0.0 + 2 : 192.168.1.1 + } +} +`, + expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")}, + expectedIP: netip.MustParseAddr("192.168.1.1"), + }, + { + name: "v4_mapped_address_unmapped", + output: ` { + ServerAddresses : { + 0 : ::ffff:192.168.1.1 + } +} +`, + expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")}, + expectedIP: netip.MustParseAddr("192.168.1.1"), + }, + { + name: "empty_output", + output: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + settings, servers, err := parseSystemDNSSettings([]byte(tc.output)) + require.NoError(t, err, "parsing should not fail") + + assert.Equal(t, tc.expectedDomains, settings.Domains, "domains should match") + assert.Equal(t, tc.expectedServers, servers, "server addresses should match") + assert.Equal(t, tc.expectedIP, settings.ServerIP, "server IP should match") + assert.Equal(t, DefaultPort, settings.ServerPort, "server port should default to 53") + }) + } +} + func TestGetOriginalNameservers(t *testing.T) { configurator := &systemConfigurator{ createdKeys: make(map[string]struct{}), diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go index 4f6ece532..53380b2aa 100644 --- a/client/internal/dns/host_windows.go +++ b/client/internal/dns/host_windows.go @@ -31,10 +31,30 @@ var ( dnsFlushResolverCacheFn = dnsapi.NewProc("DnsFlushResolverCache") ) +// Registry locations of the host DNS configuration this package programs, +// exported so a diagnostic reader reports the same locations that are written. const ( - dnsPolicyConfigMatchPath = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig\NetBird-Match` - gpoDnsPolicyRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig` - gpoDnsPolicyConfigMatchPath = gpoDnsPolicyRoot + `\NetBird-Match` + // NRPTKeyPrefix starts the name of every NRPT rule key this client creates. + // Older versions used different layouts under the same prefix: a single + // unsuffixed key, then one key per domain, now one key per batch of domains. + NRPTKeyPrefix = "NetBird-Match" + + // DNSPolicyConfigRoot holds the NRPT rules of the local policy store. + DNSPolicyConfigRoot = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig` + + // GPODNSPolicyConfigRoot holds the NRPT rules of the group policy store, + // which takes precedence over the local one when it is present. + GPODNSPolicyConfigRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig` + + // InterfaceConfigPath and InterfaceConfigPathV6 hold the per-interface DNS + // settings, keyed by interface GUID, in separate hives per address family. + InterfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces` + InterfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces` +) + +const ( + dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix + gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix dnsPolicyConfigVersionKey = "Version" dnsPolicyConfigVersionValue = 2 @@ -45,8 +65,6 @@ const ( nrptMaxDomainsPerRule = 50 - interfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces` - interfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces` interfaceConfigNameServerKey = "NameServer" interfaceConfigDhcpNameSrvKey = "DhcpNameServer" interfaceConfigSearchListKey = "SearchList" @@ -73,7 +91,6 @@ type registryConfigurator struct { guid string routingAll bool gpo bool - nrptEntryCount int origNameservers []netip.Addr } @@ -84,7 +101,7 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) { } var useGPO bool - k, err := registry.OpenKey(registry.LOCAL_MACHINE, gpoDnsPolicyRoot, registry.QUERY_VALUE) + k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE) if err != nil { log.Debugf("failed to open GPO DNS policy root: %v", err) } else { @@ -123,7 +140,7 @@ func (r *registryConfigurator) captureOriginalNameservers() ([]netip.Addr, error seen := make(map[netip.Addr]struct{}) var out []netip.Addr var merr *multierror.Error - for _, root := range []string{interfaceConfigPath, interfaceConfigPathV6} { + for _, root := range []string{InterfaceConfigPath, InterfaceConfigPathV6} { addrs, err := r.captureFromTcpipRoot(root) if err != nil { merr = multierror.Append(merr, fmt.Errorf("%s: %w", root, err)) @@ -306,14 +323,9 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager } if len(matchDomains) != 0 { - count, err := r.addDNSMatchPolicy(matchDomains, config.ServerIP) - // Update count even on error to ensure cleanup covers partially created rules - r.nrptEntryCount = count - if err != nil { + if err := r.addDNSMatchPolicy(matchDomains, config.ServerIP); err != nil { return fmt.Errorf("add dns match policy: %w", err) } - } else { - r.nrptEntryCount = 0 } r.updateState(stateManager) @@ -329,9 +341,8 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager func (r *registryConfigurator) updateState(stateManager *statemanager.Manager) { if err := stateManager.UpdateState(&ShutdownState{ - Guid: r.guid, - GPO: r.gpo, - NRPTEntryCount: r.nrptEntryCount, + Guid: r.guid, + GPO: r.gpo, }); err != nil { log.Errorf("failed to update shutdown state: %s", err) } @@ -346,7 +357,7 @@ func (r *registryConfigurator) addDNSSetupForAll(ip netip.Addr) error { return nil } -func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) (int, error) { +func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) error { // if the gpo key is present, we need to put our DNS settings there, otherwise our config might be ignored // see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/8cc31cb9-20cb-4140-9e85-3e08703b4745 @@ -363,19 +374,17 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, ruleIndex) if err := r.configureDNSPolicy(localPath, batchDomains, ip); err != nil { - return ruleIndex, fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err) + return fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err) } - // Increment immediately so the caller's cleanup path knows about this rule - ruleIndex++ - if r.gpo { if err := r.configureDNSPolicy(gpoPath, batchDomains, ip); err != nil { - return ruleIndex, fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex-1, err) + return fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex, err) } } - log.Debugf("added NRPT rule %d with %d domains", ruleIndex-1, len(batchDomains)) + log.Debugf("added NRPT rule %d with %d domains", ruleIndex, len(batchDomains)) + ruleIndex++ } if r.gpo { @@ -385,7 +394,7 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr } log.Infof("added %d NRPT rules for %d domains", ruleIndex, len(domains)) - return ruleIndex, nil + return nil } func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip netip.Addr) error { @@ -450,7 +459,7 @@ func (r *registryConfigurator) flushDNSCache() { ret, _, err := dnsFlushResolverCacheFn.Call() if ret == 0 { - if err != nil && !errors.Is(err, syscall.Errno(0)) { + if !errors.Is(err, syscall.Errno(0)) { log.Errorf("DnsFlushResolverCache failed: %v", err) return } @@ -496,7 +505,7 @@ func (r *registryConfigurator) deleteInterfaceRegistryKeyProperty(propertyKey st } func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, error) { - regKeyPath := interfaceConfigPath + "\\" + r.guid + regKeyPath := InterfaceConfigPath + "\\" + r.guid regKey, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.SET_VALUE) if err != nil { return regKey, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err) @@ -518,28 +527,28 @@ func (r *registryConfigurator) restoreHostDNS() error { return nil } +// removeDNSMatchPolicies deletes every NRPT rule this client may have created, +// from the local and the GPO policy store. The rules are found by enumerating +// the registry, the only authoritative record of what was written. Cleanup must +// not depend on a rule count: the in-memory one is scoped to a single +// registryConfigurator and the persisted one is deleted on every clean +// disconnect, and a rule left behind keeps resolving names over an interface +// that is gone, until reboot discards the volatile key. func (r *registryConfigurator) removeDNSMatchPolicies() error { var merr *multierror.Error - // Try to remove the base entries (for backward compatibility) - if err := removeRegistryKeyFromDNSPolicyConfig(dnsPolicyConfigMatchPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove local base entry: %w", err)) - } - - if err := removeRegistryKeyFromDNSPolicyConfig(gpoDnsPolicyConfigMatchPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove GPO base entry: %w", err)) - } - - for i := 0; i < r.nrptEntryCount; i++ { - localPath := fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i) - gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, i) - - if err := removeRegistryKeyFromDNSPolicyConfig(localPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove local entry %d: %w", i, err)) + for _, root := range []string{DNSPolicyConfigRoot, GPODNSPolicyConfigRoot} { + names, err := listNRPTRuleKeys(root) + if err != nil { + merr = multierror.Append(merr, fmt.Errorf("list rule keys under %s: %w", root, err)) + continue } - if err := removeRegistryKeyFromDNSPolicyConfig(gpoPath); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove GPO entry %d: %w", i, err)) + for _, name := range names { + path := root + `\` + name + if err := removeRegistryKeyFromDNSPolicyConfig(path); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove entry %s: %w", path, err)) + } } } @@ -554,6 +563,39 @@ func (r *registryConfigurator) restoreUncleanShutdownDNS() error { return r.restoreHostDNS() } +// listNRPTRuleKeys returns the names of our NRPT rule keys under a policy store +// root. An absent root holds nothing to clean up, which is the normal state of +// the GPO store on a machine without DNS Client policy. +func listNRPTRuleKeys(root string) ([]string, error) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS) + switch { + case errors.Is(err, registry.ErrNotExist), errors.Is(err, syscall.ERROR_PATH_NOT_FOUND): + // the GPO store is absent on a machine without DNS client policy + log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", root) + return nil, nil + case err != nil: + // any other failure has to reach the caller: reporting no rules would + // report a successful cleanup while leaving the rules in place + return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err) + } + defer closer(k) + + names, err := k.ReadSubKeyNames(-1) + if err != nil { + return nil, fmt.Errorf("read subkey names: %w", err) + } + + var ruleKeys []string + for _, name := range names { + // registry key names are case insensitive + if strings.HasPrefix(strings.ToLower(name), strings.ToLower(NRPTKeyPrefix)) { + ruleKeys = append(ruleKeys, name) + } + } + + return ruleKeys, nil +} + func removeRegistryKeyFromDNSPolicyConfig(regKeyPath string) error { k, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.QUERY_VALUE) if err != nil { @@ -585,7 +627,7 @@ func refreshGroupPolicy() error { ) if ret == 0 { - if err != nil && !errors.Is(err, syscall.Errno(0)) { + if !errors.Is(err, syscall.Errno(0)) { return fmt.Errorf("RefreshPolicyEx failed: %w", err) } return fmt.Errorf("RefreshPolicyEx failed") diff --git a/client/internal/dns/host_windows_test.go b/client/internal/dns/host_windows_test.go index 3cd2b1bd5..861613c95 100644 --- a/client/internal/dns/host_windows_test.go +++ b/client/internal/dns/host_windows_test.go @@ -25,7 +25,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { // Create a test interface registry key so updateSearchDomains doesn't fail testGUID := "{12345678-1234-1234-1234-123456789ABC}" - interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID + interfacePath := InterfaceConfigPath + `\` + testGUID testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) require.NoError(t, err, "Should create test interface registry key") testKey.Close() @@ -56,7 +56,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { require.NoError(t, err) // Verify 3 NRPT rules exist - assert.Equal(t, 3, cfg.nrptEntryCount, "Should create 3 NRPT rules for 125 domains") + assert.Equal(t, 3, countNRPTRuleKeys(t), "Should create 3 NRPT rules for 125 domains") for i := 0; i < 3; i++ { exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)) require.NoError(t, err) @@ -81,7 +81,7 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { require.NoError(t, err) // Verify first 2 NRPT rules exist - assert.Equal(t, 2, cfg.nrptEntryCount, "Should create 2 NRPT rules for 75 domains") + assert.Equal(t, 2, countNRPTRuleKeys(t), "Should create 2 NRPT rules for 75 domains") for i := 0; i < 2; i++ { exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)) require.NoError(t, err) @@ -106,9 +106,65 @@ func registryKeyExists(path string) (bool, error) { return true, nil } +// TestNRPTCleanupWithoutRuleCount verifies that rules written by a previous run +// are removed by a configurator that has no record of how many there are: an +// unclean exit loses the in-memory count and a clean disconnect deletes the +// persisted one, so cleanup cannot depend on either. +func TestNRPTCleanupWithoutRuleCount(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + defer cleanupRegistryKeys(t) + cleanupRegistryKeys(t) + + testIP := netip.MustParseAddr("100.64.0.1") + + // 75 domains produce two indexed rules, as the current layout does + domains := make([]string, 75) + for i := range domains { + domains[i] = fmt.Sprintf(".domain%d.com", i+1) + } + + previousRun := ®istryConfigurator{} + require.NoError(t, previousRun.addDNSMatchPolicy(domains, testIP)) + + // the unsuffixed key an older version would have written + require.NoError(t, previousRun.configureDNSPolicy(dnsPolicyConfigMatchPath, []string{".legacy.example.com"}, testIP)) + + // a policy owned by someone else, which cleanup must not touch + foreignPath := DNSPolicyConfigRoot + `\DnsPolicyConfigTestForeign` + foreignKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, foreignPath, registry.SET_VALUE) + require.NoError(t, err, "Should create foreign policy key") + foreignKey.Close() + defer func() { + _ = registry.DeleteKey(registry.LOCAL_MACHINE, foreignPath) + }() + + require.Equal(t, 3, countNRPTRuleKeys(t), "Should have two indexed rules and the legacy one") + + // a configurator that never applied a DNS config, as one built after a + // restart or from a shutdown state without a count is + freshRun := ®istryConfigurator{} + require.NoError(t, freshRun.removeDNSMatchPolicies()) + + assert.Equal(t, 0, countNRPTRuleKeys(t), "Should remove every rule left by the previous run") + + exists, err := registryKeyExists(foreignPath) + require.NoError(t, err) + assert.True(t, exists, "Should not remove a policy that is not ours") +} + +func countNRPTRuleKeys(t *testing.T) int { + t.Helper() + + names, err := listNRPTRuleKeys(DNSPolicyConfigRoot) + require.NoError(t, err, "Should list NRPT rule keys") + return len(names) +} + func cleanupRegistryKeys(*testing.T) { - // Clean up more entries to account for batching tests with many domains - cfg := ®istryConfigurator{nrptEntryCount: 20} + cfg := ®istryConfigurator{} _ = cfg.removeDNSMatchPolicies() } @@ -125,7 +181,7 @@ func TestNRPTDomainBatching(t *testing.T) { // Create a test interface registry key so updateSearchDomains doesn't fail testGUID := "{12345678-1234-1234-1234-123456789ABC}" - interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID + interfacePath := InterfaceConfigPath + `\` + testGUID testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) require.NoError(t, err, "Should create test interface registry key") testKey.Close() @@ -193,7 +249,7 @@ func TestNRPTDomainBatching(t *testing.T) { require.NoError(t, err) // Verify that exactly expectedRuleCount rules were created - assert.Equal(t, tc.expectedRuleCount, cfg.nrptEntryCount, + assert.Equal(t, tc.expectedRuleCount, countNRPTRuleKeys(t), "Should create %d NRPT rules for %d domains", tc.expectedRuleCount, tc.domainCount) // Verify all expected rules exist diff --git a/client/internal/dns/interface_index.go b/client/internal/dns/interface_index.go new file mode 100644 index 000000000..9e7dca080 --- /dev/null +++ b/client/internal/dns/interface_index.go @@ -0,0 +1,15 @@ +package dns + +import ( + "fmt" + "net" +) + +func getInterfaceIndex(interfaceName string) (int, error) { + iface, err := net.InterfaceByName(interfaceName) + if err != nil { + return 0, fmt.Errorf("lookup interface %q: %w", interfaceName, err) + } + + return iface.Index, nil +} diff --git a/client/internal/dns/interface_index_test.go b/client/internal/dns/interface_index_test.go new file mode 100644 index 000000000..9b146398a --- /dev/null +++ b/client/internal/dns/interface_index_test.go @@ -0,0 +1,35 @@ +package dns + +import ( + "net" + "testing" +) + +func TestGetInterfaceIndexExisting(t *testing.T) { + interfaces, err := net.Interfaces() + if err != nil { + t.Fatalf("list network interfaces: %v", err) + } + if len(interfaces) == 0 { + t.Fatal("expected at least one network interface") + } + + iface := interfaces[0] + index, err := getInterfaceIndex(iface.Name) + if err != nil { + t.Fatalf("look up existing interface %q: %v", iface.Name, err) + } + if index != iface.Index { + t.Fatalf("expected interface index %d, got %d", iface.Index, index) + } +} + +func TestGetInterfaceIndexMissing(t *testing.T) { + index, err := getInterfaceIndex("netbird-interface-that-does-not-exist") + if index != 0 { + t.Fatalf("expected missing interface index to be 0, got %d", index) + } + if err == nil { + t.Fatal("expected missing interface lookup to return an error") + } +} diff --git a/client/internal/dns/local/local.go b/client/internal/dns/local/local.go index d0268186c..fef35fd41 100644 --- a/client/internal/dns/local/local.go +++ b/client/internal/dns/local/local.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/netip" + "os" "slices" "strings" "sync" @@ -36,7 +37,43 @@ type resolver interface { // record is left alone (it points at something outside our mesh, e.g. // a non-peer upstream). type PeerConnectivity interface { - IsConnectedByIP(ip string) (known, connected bool) + IsConnectedByIP(ip netip.Addr) (known, connected bool) +} + +// PeerActivator wakes lazy-connection peers on demand. The local resolver calls +// it with the tunnel IPs an answer points at, so a peer that is idle (lazily +// disconnected) starts connecting at DNS-resolution time rather than racing the +// client's first request packet. nil disables warm-up. +type PeerActivator interface { + // ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and blocks + // until one is connected or ctx (a short per-query budget) expires. It is a + // fast no-op for unknown or already-connected addresses. + ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) +} + +const ( + defaultLazyWarmupTimeout = 2 * time.Second + envLazyWarmupTimeout = "NB_DNS_LAZY_WARMUP_TIMEOUT" +) + +// lazyWarmupTimeoutFromEnv returns the per-query budget for waking a +// lazy-connection peer a DNS answer points at. Tunable via +// NB_DNS_LAZY_WARMUP_TIMEOUT (a Go duration). Parsed once at construction time. +func lazyWarmupTimeoutFromEnv() time.Duration { + v := os.Getenv(envLazyWarmupTimeout) + if v == "" { + return defaultLazyWarmupTimeout + } + d, err := time.ParseDuration(v) + if err != nil { + log.Warnf("invalid %s value %q, using default %s: %v", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout, err) + return defaultLazyWarmupTimeout + } + if d <= 0 { + log.Warnf("non-positive %s value %q, using default %s", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout) + return defaultLazyWarmupTimeout + } + return d } type Resolver struct { @@ -51,6 +88,12 @@ type Resolver struct { // filter and preserves the legacy "return whatever is registered" // behaviour for callers that never wire a status source. peerConn PeerConnectivity + // peerActivator, when non-nil, is called at resolution time to warm the + // lazy connection to the peer(s) an answer points at. nil disables warm-up. + peerActivator PeerActivator + // warmupTimeout is the per-query budget for the lazy-connection warm-up + // wait, resolved from the environment once at construction time. + warmupTimeout time.Duration ctx context.Context cancel context.CancelFunc @@ -59,11 +102,12 @@ type Resolver struct { func NewResolver() *Resolver { ctx, cancel := context.WithCancel(context.Background()) return &Resolver{ - records: make(map[dns.Question][]dns.RR), - domains: make(map[domain.Domain]struct{}), - zones: make(map[domain.Domain]bool), - ctx: ctx, - cancel: cancel, + records: make(map[dns.Question][]dns.RR), + domains: make(map[domain.Domain]struct{}), + zones: make(map[domain.Domain]bool), + warmupTimeout: lazyWarmupTimeoutFromEnv(), + ctx: ctx, + cancel: cancel, } } @@ -76,6 +120,14 @@ func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) { d.peerConn = p } +// SetPeerActivator wires the DNS-time lazy-connection warm-up. Pass nil to +// disable. Safe to call multiple times; the latest value wins. +func (d *Resolver) SetPeerActivator(a PeerActivator) { + d.mu.Lock() + defer d.mu.Unlock() + d.peerActivator = a +} + func (d *Resolver) MatchSubdomains() bool { return true } @@ -122,6 +174,9 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { replyMessage.RecursionAvailable = true result := d.lookupRecords(logger, question) + // Warm before filtering: activation flips a lazily-idle target to connected, + // which then lets it survive the disconnected-peer filter below. + d.warmLazyPeers(question, result.records) result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records) replyMessage.Authoritative = !result.hasExternalData replyMessage.Answer = result.records @@ -495,8 +550,8 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns kept := make([]dns.RR, 0, len(records)) var dropped int for _, rr := range records { - ip := extractRecordIP(rr) - if ip == "" { + ip, ok := extractRecordAddr(rr) + if !ok { kept = append(kept, rr) continue } @@ -518,22 +573,57 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns return kept } -// extractRecordIP returns the dotted-decimal / colon-hex IP carried by -// an A or AAAA record, or "" for any other record type. -func extractRecordIP(rr dns.RR) string { +// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved +// answer points at and waits briefly for one to connect, so the caller's first +// request doesn't race the connection establishment. Warm-up is scoped to +// match-only (non-authoritative) zones — the synthesized private-service zones +// and user-created zones whose records point at specific peers. The account's +// peer zone is authoritative, so plain peer-name lookups never trigger warm-up; +// otherwise resolving any peer's name would wake its idle connection, defeating +// laziness mesh-wide. No-op when no activator is wired (lazy connections +// disabled) or the answer carries no peer IPs. +func (d *Resolver) warmLazyPeers(question dns.Question, records []dns.RR) { + if len(records) < 2 { + return + } + d.mu.RLock() + activator := d.peerActivator + var nonAuth, found bool + if activator != nil { + nonAuth, found = d.findZone(question.Name) + } + d.mu.RUnlock() + if activator == nil || !found || !nonAuth { + return + } + + var addrs []netip.Addr + for _, rr := range records { + if addr, ok := extractRecordAddr(rr); ok { + addrs = append(addrs, addr) + } + } + if len(addrs) == 0 { + return + } + + ctx, cancel := context.WithTimeout(d.ctx, d.warmupTimeout) + defer cancel() + activator.ActivatePeersByIP(ctx, addrs) +} + +// extractRecordAddr returns the IP address carried by an A or AAAA record. +// ok is false for any other record type or a record with no address. +func extractRecordAddr(rr dns.RR) (netip.Addr, bool) { switch r := rr.(type) { case *dns.A: - if r.A == nil { - return "" - } - return r.A.String() + addr, ok := netip.AddrFromSlice(r.A) + return addr.Unmap(), ok case *dns.AAAA: - if r.AAAA == nil { - return "" - } - return r.AAAA.String() + addr, ok := netip.AddrFromSlice(r.AAAA) + return addr.Unmap(), ok } - return "" + return netip.Addr{}, false } // Update replaces all zones and their records diff --git a/client/internal/dns/local/local_test.go b/client/internal/dns/local/local_test.go index 9b7dac231..89e896c0a 100644 --- a/client/internal/dns/local/local_test.go +++ b/client/internal/dns/local/local_test.go @@ -37,8 +37,8 @@ type mockPeerConnectivity struct { byIP map[string]struct{ known, connected bool } } -func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) { - v, ok := m.byIP[ip] +func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) { + v, ok := m.byIP[ip.String()] if !ok { return false, false } diff --git a/client/internal/dns/local/warmup_test.go b/client/internal/dns/local/warmup_test.go new file mode 100644 index 000000000..0e77aa963 --- /dev/null +++ b/client/internal/dns/local/warmup_test.go @@ -0,0 +1,204 @@ +package local + +import ( + "context" + "net" + "net/netip" + "sync" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/dns/test" + nbdns "github.com/netbirdio/netbird/dns" +) + +// recordingActivator records the addresses it was asked to warm and returns +// immediately, so ServeDNS is not blocked by the test. +type recordingActivator struct { + mu sync.Mutex + called bool + addrs []netip.Addr +} + +func (r *recordingActivator) ActivatePeersByIP(_ context.Context, addrs []netip.Addr) { + r.mu.Lock() + defer r.mu.Unlock() + r.called = true + r.addrs = append(r.addrs, addrs...) +} + +func serveA(t *testing.T, resolver *Resolver, name string) *dns.Msg { + t.Helper() + var resp *dns.Msg + w := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }} + resolver.ServeDNS(w, new(dns.Msg).SetQuestion(name, dns.TypeA)) + return resp +} + +// serviceZone registers rec in a match-only (non-authoritative) zone, the shape +// the synthesized private-service zones arrive in. +func serviceZone(t *testing.T, resolver *Resolver, zone string, records ...nbdns.SimpleRecord) { + t.Helper() + resolver.Update([]nbdns.CustomZone{{ + Domain: zone, + Records: records, + NonAuthoritative: true, + }}) +} + +func TestLocalResolver_WarmsLazyPeerOnResolve(t *testing.T) { + // Warm-up fires only for multi-record answers (the HA / round-robin shape of + // the synthesized private-service zones), so register two peer targets. + const name = "svc.proxy.netbird.cloud." + recs := []nbdns.SimpleRecord{ + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}, + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.8"}, + } + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", recs...) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A records") + + act.mu.Lock() + defer act.mu.Unlock() + assert.True(t, act.called, "activator must be invoked for a multi-record service-zone answer") + assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.7"), "activator must receive the first peer IP") + assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.8"), "activator must receive the second peer IP") +} + +func TestLocalResolver_NoWarmupForSingleRecord(t *testing.T) { + // A single-record answer does not trigger warm-up; the resolver only warms + // multi-record answers. + rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"} + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", rec) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, rec.Name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A record") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked for a single-record answer") +} + +func TestLocalResolver_NoActivatorNoWarmup(t *testing.T) { + // With no activator wired the resolver behaves exactly as before. + rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"} + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", rec) + + resp := serveA(t, resolver, rec.Name) + require.NotNil(t, resp, "resolver must still answer without an activator") + require.NotEmpty(t, resp.Answer, "answer must carry the A record") +} + +func TestLocalResolver_NoWarmupForMissingRecord(t *testing.T) { + // A query that resolves to nothing must not invoke the activator (no IPs). + resolver := NewResolver() + serviceZone(t, resolver, "proxy.netbird.cloud", + nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + serveA(t, resolver, "absent.proxy.netbird.cloud.") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked when there is no answer") +} + +func TestLocalResolver_NoWarmupInAuthoritativeZone(t *testing.T) { + // The account's peer zone is authoritative; resolving a peer's name there + // must not wake its lazy connection — warm-up is scoped to match-only + // (non-authoritative) zones such as the synthesized private-service zones. + // Use a multi-record answer so the authoritative-zone scoping is the only + // reason warm-up is skipped, not the single-record guard. + const name = "peer.netbird.cloud." + recs := []nbdns.SimpleRecord{ + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.9"}, + {Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.10"}, + } + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{ + Domain: "netbird.cloud", + Records: recs, + }}) + + act := &recordingActivator{} + resolver.SetPeerActivator(act) + + resp := serveA(t, resolver, name) + require.NotNil(t, resp, "resolver must answer") + require.NotEmpty(t, resp.Answer, "answer must carry the A records") + + act.mu.Lock() + defer act.mu.Unlock() + assert.False(t, act.called, "activator must not be invoked for authoritative-zone answers") +} + +func TestLazyWarmupTimeoutFromEnv(t *testing.T) { + tests := []struct { + name string + value string + envSet bool + want time.Duration + }{ + {name: "unset uses default", want: defaultLazyWarmupTimeout}, + {name: "valid overrides", value: "5s", envSet: true, want: 5 * time.Second}, + {name: "invalid falls back", value: "not-a-duration", envSet: true, want: defaultLazyWarmupTimeout}, + {name: "negative falls back", value: "-1s", envSet: true, want: defaultLazyWarmupTimeout}, + {name: "zero falls back", value: "0s", envSet: true, want: defaultLazyWarmupTimeout}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envSet { + t.Setenv(envLazyWarmupTimeout, tt.value) + } + assert.Equal(t, tt.want, lazyWarmupTimeoutFromEnv()) + assert.Equal(t, tt.want, NewResolver().warmupTimeout, "constructor must resolve the timeout once") + }) + } +} + +func TestExtractRecordAddr(t *testing.T) { + t.Run("A record yields unmapped v4", func(t *testing.T) { + // net.ParseIP returns the 16-byte v4-in-v6 form, the same shape + // miekg/dns stores after parsing an A record; the extracted address + // must compare equal to a plain v4 netip.Addr. + addr, ok := extractRecordAddr(&dns.A{A: net.ParseIP("100.64.0.7")}) + require.True(t, ok) + assert.True(t, addr.Is4()) + assert.Equal(t, netip.MustParseAddr("100.64.0.7"), addr) + }) + + t.Run("AAAA record yields v6", func(t *testing.T) { + addr, ok := extractRecordAddr(&dns.AAAA{AAAA: net.ParseIP("fd00::1")}) + require.True(t, ok) + assert.Equal(t, netip.MustParseAddr("fd00::1"), addr) + }) + + t.Run("A record without address", func(t *testing.T) { + _, ok := extractRecordAddr(&dns.A{}) + assert.False(t, ok) + }) + + t.Run("non-address record", func(t *testing.T) { + _, ok := extractRecordAddr(&dns.CNAME{Target: "target.netbird.cloud."}) + assert.False(t, ok) + }) +} 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/mock_server.go b/client/internal/dns/mock_server.go index 31fedd9e5..b19862c2f 100644 --- a/client/internal/dns/mock_server.go +++ b/client/internal/dns/mock_server.go @@ -8,6 +8,7 @@ import ( "github.com/miekg/dns" dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" + "github.com/netbirdio/netbird/client/internal/dns/local" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" @@ -92,6 +93,11 @@ func (m *MockServer) SetFirewall(Firewall) { // Mock implementation - no-op } +// SetPeerActivator mock implementation of SetPeerActivator from Server interface +func (m *MockServer) SetPeerActivator(local.PeerActivator) { + // Mock implementation - no-op +} + // BeginBatch mock implementation of BeginBatch from Server interface func (m *MockServer) BeginBatch() { // Mock implementation - no-op diff --git a/client/internal/dns/notifier.go b/client/internal/dns/notifier.go index 35cb6ff82..79d924a78 100644 --- a/client/internal/dns/notifier.go +++ b/client/internal/dns/notifier.go @@ -51,7 +51,5 @@ func (n *notifier) notify() { return } - go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged("") - }(n.listener) + n.listener.OnNetworkChanged("") } diff --git a/client/internal/dns/response_writer_test.go b/client/internal/dns/response_writer_test.go index 857964406..bc8416029 100644 --- a/client/internal/dns/response_writer_test.go +++ b/client/internal/dns/response_writer_test.go @@ -4,7 +4,7 @@ import ( "net" "testing" - "github.com/golang/mock/gomock" + "go.uber.org/mock/gomock" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/miekg/dns" diff --git a/client/internal/dns/resutil/resolve.go b/client/internal/dns/resutil/resolve.go index 07a70d6d1..931938755 100644 --- a/client/internal/dns/resutil/resolve.go +++ b/client/internal/dns/resutil/resolve.go @@ -8,6 +8,7 @@ import ( "errors" "net" "net/netip" + "slices" "strings" "github.com/miekg/dns" @@ -167,7 +168,10 @@ func getRcodeForNotFound(ctx context.Context, r resolver, domain string, origina case dns.TypeA: alternativeNetwork = "ip6" default: - return dns.RcodeNameError + // Non-address types reach LookupIP only unexpectedly; without an + // address pair to probe we cannot prove the name is absent, so answer + // NODATA rather than a poisoning NXDOMAIN. + return dns.RcodeSuccess } if _, err := r.LookupNetIP(ctx, alternativeNetwork, domain); err != nil { @@ -184,6 +188,230 @@ func getRcodeForNotFound(ctx context.Context, r resolver, domain string, origina return dns.RcodeSuccess } +// RecordResolver is the host resolver surface used to forward non-address +// record queries. net.DefaultResolver satisfies it. +type RecordResolver interface { + LookupMX(ctx context.Context, name string) ([]*net.MX, error) + LookupTXT(ctx context.Context, name string) ([]string, error) + LookupNS(ctx context.Context, name string) ([]*net.NS, error) + LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) + LookupCNAME(ctx context.Context, host string) (string, error) + LookupAddr(ctx context.Context, addr string) ([]string, error) +} + +// LookupRecords resolves a non-address DNS record type through the host +// resolver and returns the resource records and the DNS rcode. Types the host +// resolver cannot answer (anything not covered by the net.Resolver Lookup* +// methods) yield NODATA so that a routed name is never poisoned with NXDOMAIN +// for an unsupported type. +func LookupRecords(ctx context.Context, r RecordResolver, name string, qtype uint16, ttl uint32) ([]dns.RR, int) { + fqdn := dns.Fqdn(name) + + switch qtype { + case dns.TypeMX: + return lookupMX(ctx, r, name, fqdn, ttl) + case dns.TypeTXT: + return lookupTXT(ctx, r, name, fqdn, ttl) + case dns.TypeNS: + return lookupNS(ctx, r, name, fqdn, ttl) + case dns.TypeSRV: + return lookupSRV(ctx, r, name, fqdn, ttl) + case dns.TypeCNAME: + return lookupCNAME(ctx, r, name, fqdn, ttl) + case dns.TypePTR: + return lookupPTR(ctx, r, name, fqdn, ttl) + default: + return nil, dns.RcodeSuccess + } +} + +func recordHeader(fqdn string, rrtype uint16, ttl uint32) dns.RR_Header { + return dns.RR_Header{Name: fqdn, Rrtype: rrtype, Class: dns.ClassINET, Ttl: ttl} +} + +func lookupMX(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + recs, err := r.LookupMX(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, mx := range recs { + rrs = append(rrs, &dns.MX{ + Hdr: recordHeader(fqdn, dns.TypeMX, ttl), + Preference: mx.Pref, + Mx: dns.Fqdn(mx.Host), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupTXT(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + recs, err := r.LookupTXT(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, txt := range recs { + rrs = append(rrs, &dns.TXT{ + Hdr: recordHeader(fqdn, dns.TypeTXT, ttl), + Txt: chunkTXT(txt), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupNS(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + recs, err := r.LookupNS(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, ns := range recs { + rrs = append(rrs, &dns.NS{ + Hdr: recordHeader(fqdn, dns.TypeNS, ttl), + Ns: dns.Fqdn(ns.Host), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupSRV(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + _, recs, err := r.LookupSRV(ctx, "", "", name) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(recs)) + for _, srv := range recs { + rrs = append(rrs, &dns.SRV{ + Hdr: recordHeader(fqdn, dns.TypeSRV, ttl), + Priority: srv.Priority, + Weight: srv.Weight, + Port: srv.Port, + Target: dns.Fqdn(srv.Target), + }) + } + return rrs, dns.RcodeSuccess +} + +func lookupCNAME(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + cname, err := r.LookupCNAME(ctx, name) + if err != nil { + return nil, rcodeForRecordError(err) + } + // LookupCNAME returns the queried name itself when the name resolves but + // has no CNAME record; that is a NODATA result, not a CNAME. + if strings.EqualFold(dns.Fqdn(cname), fqdn) { + return nil, dns.RcodeSuccess + } + return []dns.RR{&dns.CNAME{ + Hdr: recordHeader(fqdn, dns.TypeCNAME, ttl), + Target: dns.Fqdn(cname), + }}, dns.RcodeSuccess +} + +func lookupPTR(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) { + addr, ok := ptrQueryAddr(name) + if !ok { + return nil, dns.RcodeSuccess + } + names, err := r.LookupAddr(ctx, addr) + if err != nil { + return nil, rcodeForRecordError(err) + } + rrs := make([]dns.RR, 0, len(names)) + for _, n := range names { + rrs = append(rrs, &dns.PTR{ + Hdr: recordHeader(fqdn, dns.TypePTR, ttl), + Ptr: dns.Fqdn(n), + }) + } + return rrs, dns.RcodeSuccess +} + +// ptrQueryAddr converts a reverse-DNS query name (in-addr.arpa or ip6.arpa) +// into the address string expected by net.Resolver.LookupAddr. It reports false +// when the name is not a well-formed reverse name. +func ptrQueryAddr(qname string) (string, bool) { + name := strings.TrimSuffix(strings.ToLower(dns.Fqdn(qname)), ".") + + switch { + case strings.HasSuffix(name, ".in-addr.arpa"): + return parseInAddrArpa(strings.TrimSuffix(name, ".in-addr.arpa")) + case strings.HasSuffix(name, ".ip6.arpa"): + return parseIP6Arpa(strings.TrimSuffix(name, ".ip6.arpa")) + default: + return "", false + } +} + +// parseInAddrArpa turns the label portion of an in-addr.arpa name into an IPv4 +// address string, reporting false when it is not a well-formed reverse name. +func parseInAddrArpa(labelPart string) (string, bool) { + labels := strings.Split(labelPart, ".") + if len(labels) != 4 { + return "", false + } + slices.Reverse(labels) + addr, err := netip.ParseAddr(strings.Join(labels, ".")) + if err != nil || !addr.Is4() { + return "", false + } + return addr.String(), true +} + +// parseIP6Arpa turns the nibble portion of an ip6.arpa name into an IPv6 +// address string, reporting false when it is not a well-formed reverse name. +func parseIP6Arpa(nibblePart string) (string, bool) { + nibbles := strings.Split(nibblePart, ".") + if len(nibbles) != 32 { + return "", false + } + slices.Reverse(nibbles) + var sb strings.Builder + for i, n := range nibbles { + if i > 0 && i%4 == 0 { + sb.WriteByte(':') + } + sb.WriteString(n) + } + addr, err := netip.ParseAddr(sb.String()) + if err != nil || !addr.Is6() { + return "", false + } + return addr.String(), true +} + +// rcodeForRecordError maps a non-address lookup error to a DNS rcode. A +// not-found result becomes NODATA rather than NXDOMAIN: net.DNSError.IsNotFound +// does not distinguish a missing name from a name that exists only with records +// of other types, so the name cannot be proven absent and must not be poisoned. +func rcodeForRecordError(err error) int { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return dns.RcodeSuccess + } + return dns.RcodeServerFailure +} + +// chunkTXT splits a TXT string into character-strings no longer than 255 bytes +// so the record can be packed. The chunks form one TXT resource record. +func chunkTXT(s string) []string { + const maxLen = 255 + if len(s) <= maxLen { + return []string{s} + } + + var chunks []string + for len(s) > maxLen { + chunks = append(chunks, s[:maxLen]) + s = s[maxLen:] + } + if len(s) > 0 { + chunks = append(chunks, s) + } + return chunks +} + // FormatAnswers formats DNS resource records for logging. func FormatAnswers(answers []dns.RR) string { if len(answers) == 0 { @@ -207,3 +435,35 @@ func FormatAnswers(answers []dns.RR) string { } return "[" + strings.Join(parts, ", ") + "]" } + +// StripOPT removes any OPT pseudo-RRs from the message's Extra section. Per +// RFC 6891 a responder must not include an OPT RR toward a client that did not +// advertise EDNS0. +func StripOPT(msg *dns.Msg) { + if len(msg.Extra) == 0 { + return + } + out := msg.Extra[:0] + for _, rr := range msg.Extra { + if _, ok := rr.(*dns.OPT); ok { + continue + } + out = append(out, rr) + } + msg.Extra = out +} + +// ExtractEDE returns the first Extended DNS Error (RFC 8914) option carried in +// the message, if present. +func ExtractEDE(msg *dns.Msg) (*dns.EDNS0_EDE, bool) { + opt := msg.IsEdns0() + if opt == nil { + return nil, false + } + for _, o := range opt.Option { + if ede, ok := o.(*dns.EDNS0_EDE); ok { + return ede, true + } + } + return nil, false +} diff --git a/client/internal/dns/resutil/resolve_test.go b/client/internal/dns/resutil/resolve_test.go index 432367c22..f51092a83 100644 --- a/client/internal/dns/resutil/resolve_test.go +++ b/client/internal/dns/resutil/resolve_test.go @@ -5,6 +5,7 @@ import ( "errors" "net" "net/netip" + "strings" "testing" "github.com/miekg/dns" @@ -120,3 +121,200 @@ func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) { assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "upstream failure should map to SERVFAIL") } + +func TestPtrQueryAddr(t *testing.T) { + tests := []struct { + name string + qname string + want string + wantOK bool + }{ + {name: "ipv4", qname: "4.3.2.1.in-addr.arpa.", want: "1.2.3.4", wantOK: true}, + {name: "ipv4 no trailing dot", qname: "1.0.0.127.in-addr.arpa", want: "127.0.0.1", wantOK: true}, + { + name: "ipv6", + qname: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", + want: "2001:db8::1", + wantOK: true, + }, + {name: "ipv4 wrong label count", qname: "2.1.in-addr.arpa.", wantOK: false}, + {name: "ipv6 wrong nibble count", qname: "1.0.ip6.arpa.", wantOK: false}, + {name: "not a reverse name", qname: "example.com.", wantOK: false}, + {name: "ipv4 bad octet", qname: "4.3.2.999.in-addr.arpa.", wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ptrQueryAddr(tt.qname) + assert.Equal(t, tt.wantOK, ok, "parse success mismatch") + if tt.wantOK { + assert.Equal(t, tt.want, got, "parsed address mismatch") + } + }) + } +} + +type mockRecordResolver struct { + mx []*net.MX + txt []string + ns []*net.NS + srv []*net.SRV + cname string + ptr []string + err error +} + +func (m *mockRecordResolver) LookupMX(context.Context, string) ([]*net.MX, error) { + return m.mx, m.err +} +func (m *mockRecordResolver) LookupTXT(context.Context, string) ([]string, error) { + return m.txt, m.err +} +func (m *mockRecordResolver) LookupNS(context.Context, string) ([]*net.NS, error) { + return m.ns, m.err +} +func (m *mockRecordResolver) LookupSRV(context.Context, string, string, string) (string, []*net.SRV, error) { + return "", m.srv, m.err +} +func (m *mockRecordResolver) LookupCNAME(context.Context, string) (string, error) { + return m.cname, m.err +} +func (m *mockRecordResolver) LookupAddr(context.Context, string) ([]string, error) { + return m.ptr, m.err +} + +func TestLookupRecords(t *testing.T) { + notFound := &net.DNSError{IsNotFound: true, Name: "example.com."} + + t.Run("MX success", func(t *testing.T) { + r := &mockRecordResolver{mx: []*net.MX{{Host: "mail.example.com.", Pref: 10}}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "mail.example.com.", rrs[0].(*dns.MX).Mx) + }) + + t.Run("TXT short string is one character-string", func(t *testing.T) { + r := &mockRecordResolver{txt: []string{"v=spf1 -all"}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeTXT, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, []string{"v=spf1 -all"}, rrs[0].(*dns.TXT).Txt) + }) + + t.Run("TXT chunks long strings", func(t *testing.T) { + long := strings.Repeat("a", 300) + r := &mockRecordResolver{txt: []string{long}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeTXT, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + txt := rrs[0].(*dns.TXT).Txt + require.Len(t, txt, 2, "300-byte string should split into two character-strings") + assert.Equal(t, 255, len(txt[0])) + assert.Equal(t, 45, len(txt[1])) + }) + + t.Run("NS success", func(t *testing.T) { + r := &mockRecordResolver{ns: []*net.NS{{Host: "ns1.example.com."}}} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeNS, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "ns1.example.com.", rrs[0].(*dns.NS).Ns) + }) + + t.Run("SRV success", func(t *testing.T) { + r := &mockRecordResolver{srv: []*net.SRV{{Target: "sip.example.com.", Port: 5060}}} + rrs, rcode := LookupRecords(context.Background(), r, "_sip._tcp.example.com.", dns.TypeSRV, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, uint16(5060), rrs[0].(*dns.SRV).Port) + }) + + t.Run("CNAME success", func(t *testing.T) { + r := &mockRecordResolver{cname: "target.example.com."} + rrs, rcode := LookupRecords(context.Background(), r, "www.example.com.", dns.TypeCNAME, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "target.example.com.", rrs[0].(*dns.CNAME).Target) + }) + + t.Run("CNAME equal to name is NODATA", func(t *testing.T) { + r := &mockRecordResolver{cname: "example.com."} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeCNAME, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + assert.Empty(t, rrs, "self-referential CNAME is NODATA") + }) + + t.Run("PTR success", func(t *testing.T) { + r := &mockRecordResolver{ptr: []string{"host.example.com."}} + rrs, rcode := LookupRecords(context.Background(), r, "4.3.2.1.in-addr.arpa.", dns.TypePTR, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + require.Len(t, rrs, 1) + assert.Equal(t, "host.example.com.", rrs[0].(*dns.PTR).Ptr) + }) + + t.Run("PTR malformed name is NODATA", func(t *testing.T) { + r := &mockRecordResolver{} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypePTR, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + assert.Empty(t, rrs) + }) + + t.Run("not found is NODATA never NXDOMAIN", func(t *testing.T) { + r := &mockRecordResolver{err: notFound} + _, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300) + assert.Equal(t, dns.RcodeSuccess, rcode, "missing record must not poison the name") + }) + + t.Run("server failure maps to SERVFAIL", func(t *testing.T) { + r := &mockRecordResolver{err: &net.DNSError{Err: "server misbehaving", IsTemporary: true}} + _, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300) + assert.Equal(t, dns.RcodeServerFailure, rcode) + }) + + t.Run("unsupported type is NODATA", func(t *testing.T) { + r := &mockRecordResolver{} + rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeCAA, 300) + assert.Equal(t, dns.RcodeSuccess, rcode) + assert.Empty(t, rrs) + }) +} + +func TestStripOPT(t *testing.T) { + rm := &dns.Msg{ + Extra: []dns.RR{ + &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}, + &dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)}, + }, + } + StripOPT(rm) + assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept") + _, isOPT := rm.Extra[0].(*dns.OPT) + assert.False(t, isOPT, "remaining record must not be OPT") +} + +func TestExtractEDE(t *testing.T) { + t.Run("no edns", func(t *testing.T) { + _, ok := ExtractEDE(&dns.Msg{}) + assert.False(t, ok, "message without OPT has no EDE") + }) + + t.Run("edns without ede", func(t *testing.T) { + rm := &dns.Msg{} + rm.SetEdns0(4096, false) + _, ok := ExtractEDE(rm) + assert.False(t, ok, "OPT without EDE option returns false") + }) + + t.Run("with ede", func(t *testing.T) { + rm := &dns.Msg{} + opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}} + opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: 49152, ExtraText: "upstream timeout"}) + rm.Extra = append(rm.Extra, opt) + + ede, ok := ExtractEDE(rm) + assert.True(t, ok, "EDE option should be found") + assert.Equal(t, uint16(49152), ede.InfoCode) + assert.Equal(t, "upstream timeout", ede.ExtraText) + }) +} diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index 7556c66cc..3af912792 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -82,6 +82,7 @@ type Server interface { PopulateManagementDomain(mgmtURL *url.URL) error SetRouteSources(selected, active func() route.HAMap) SetFirewall(Firewall) + SetPeerActivator(local.PeerActivator) } type nsGroupsByDomain struct { @@ -251,7 +252,7 @@ func NewDefaultServerPermanentUpstream( ds.hostsDNSHolder.set(hostsDnsList) ds.permanent = true ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort()) - ds.searchDomainNotifier = newNotifier(ds.SearchDomains()) + ds.searchDomainNotifier = newNotifier(ds.searchDomains()) ds.searchDomainNotifier.setListener(listener) setServerDns(ds) return ds @@ -491,6 +492,13 @@ func (s *DefaultServer) SetFirewall(fw Firewall) { } } +// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local +// resolver. Injected after the connection manager exists (it does not at +// DNS-server construction time). Pass nil to disable. +func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) { + s.localResolver.SetPeerActivator(a) +} + // Stop stops the server func (s *DefaultServer) Stop() { s.ctxCancel() @@ -594,6 +602,12 @@ func (s *DefaultServer) UpdateDNSServer(serial uint64, update nbdns.Config) erro } func (s *DefaultServer) SearchDomains() []string { + s.mux.Lock() + defer s.mux.Unlock() + return s.searchDomains() +} + +func (s *DefaultServer) searchDomains() []string { var searchDomains []string for _, dConf := range s.currentConfig.Domains { @@ -678,7 +692,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error { }() if s.searchDomainNotifier != nil { - s.searchDomainNotifier.onNewSearchDomains(s.SearchDomains()) + s.searchDomainNotifier.onNewSearchDomains(s.searchDomains()) } s.updateNSGroupStates(update.NameServerGroups) @@ -1435,11 +1449,11 @@ type localPeerConnectivity struct { // IsConnectedByIP looks the IP up in the peerstore and surfaces both // the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers. -func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) { +func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) { if l.status == nil { return false, false } - state, ok := l.status.PeerStateByIP(ip) + state, ok := l.status.PeerStateByIP(ip.String()) if !ok { return false, false } diff --git a/client/internal/dns/server_privileged_test.go b/client/internal/dns/server_privileged_test.go new file mode 100644 index 000000000..a17044cf5 --- /dev/null +++ b/client/internal/dns/server_privileged_test.go @@ -0,0 +1,485 @@ +//go:build privileged + +package dns + +import ( + "context" + "fmt" + "net/netip" + "os" + "testing" + + "go.uber.org/mock/gomock" + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/netbirdio/netbird/client/iface" + pfmock "github.com/netbirdio/netbird/client/iface/mocks" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/dns/local" + "github.com/netbirdio/netbird/client/internal/dns/test" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/stdnet" + nbdns "github.com/netbirdio/netbird/dns" +) + +func TestUpdateDNSServer(t *testing.T) { + + nameServers := []nbdns.NameServer{ + { + IP: netip.MustParseAddr("8.8.8.8"), + NSType: nbdns.UDPNameServerType, + Port: 53, + }, + { + IP: netip.MustParseAddr("8.8.4.4"), + NSType: nbdns.UDPNameServerType, + Port: 53, + }, + } + + testCases := []struct { + name string + initUpstreamMap []handlerWrapper + initLocalZones []nbdns.CustomZone + initSerial uint64 + inputSerial uint64 + inputUpdate nbdns.Config + shouldFail bool + expectedUpstreamMap []handlerWrapper + expectedLocalQs []dns.Question + }{ + { + name: "Initial Config Should Succeed", + initUpstreamMap: nil, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + Domains: []string{"netbird.io"}, + NameServers: nameServers, + }, + { + NameServers: nameServers, + Primary: true, + }, + }, + }, + expectedUpstreamMap: []handlerWrapper{ + { + domain: "netbird.io", + priority: PriorityUpstream, + }, + { + domain: "netbird.cloud", + priority: PriorityLocal, + }, + { + domain: nbdns.RootZone, + priority: PriorityDefault, + }, + }, + expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, + }, + { + name: "New Config Should Succeed", + initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, + initUpstreamMap: []handlerWrapper{ + { + domain: "netbird.cloud", + handler: &mockHandler{}, + priority: PriorityUpstream, + }, + }, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + Domains: []string{"netbird.io"}, + NameServers: nameServers, + }, + }, + }, + expectedUpstreamMap: []handlerWrapper{ + { + domain: "netbird.io", + priority: PriorityUpstream, + }, + { + domain: "netbird.cloud", + priority: PriorityLocal, + }, + }, + expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}}, + }, + { + name: "Smaller Config Serial Should Be Skipped", + initLocalZones: []nbdns.CustomZone{}, + initUpstreamMap: nil, + initSerial: 2, + inputSerial: 1, + shouldFail: true, + }, + { + name: "Empty NS Group Domain Or Not Primary Element Should Fail", + initLocalZones: []nbdns.CustomZone{}, + initUpstreamMap: nil, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + NameServers: nameServers, + }, + }, + }, + shouldFail: true, + }, + { + name: "Invalid NS Group Nameservers list Should Fail", + initLocalZones: []nbdns.CustomZone{}, + initUpstreamMap: nil, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + NameServers: nameServers, + }, + }, + }, + shouldFail: true, + }, + { + name: "Invalid Custom Zone Records list Should Skip", + initLocalZones: []nbdns.CustomZone{}, + initUpstreamMap: nil, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + NameServers: nameServers, + Primary: true, + }, + }, + }, + expectedUpstreamMap: []handlerWrapper{{ + domain: ".", + priority: PriorityDefault, + }}, + }, + { + name: "Empty Config Should Succeed and Clean Maps", + initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, + initUpstreamMap: []handlerWrapper{ + { + domain: zoneRecords[0].Name, + handler: &mockHandler{}, + priority: PriorityUpstream, + }, + }, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ServiceEnable: true}, + expectedUpstreamMap: nil, + expectedLocalQs: []dns.Question{}, + }, + { + name: "Disabled Service Should clean map", + initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, + initUpstreamMap: []handlerWrapper{ + { + domain: zoneRecords[0].Name, + handler: &mockHandler{}, + priority: PriorityUpstream, + }, + }, + initSerial: 0, + inputSerial: 1, + inputUpdate: nbdns.Config{ServiceEnable: false}, + expectedUpstreamMap: nil, + expectedLocalQs: []dns.Question{}, + }, + } + + for n, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + privKey, _ := wgtypes.GenerateKey() + newNet, err := stdnet.NewNet(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + + opts := iface.WGIFaceOpts{ + IFaceName: fmt.Sprintf("utun230%d", n), + Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)), + WGPort: 33100, + WGPrivKey: privKey.String(), + MTU: iface.DefaultMTU, + TransportNet: newNet, + } + + wgIface, err := iface.NewWGIFace(opts) + if err != nil { + t.Fatal(err) + } + err = wgIface.Create() + if err != nil { + t.Fatal(err) + } + defer func() { + err = wgIface.Close() + if err != nil { + t.Log(err) + } + }() + dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{ + WgInterface: wgIface, + CustomAddress: "", + StatusRecorder: peer.NewRecorder("mgm"), + StateManager: nil, + DisableSys: false, + }) + if err != nil { + t.Fatal(err) + } + err = dnsServer.Initialize() + if err != nil { + t.Fatal(err) + } + defer func() { + err = dnsServer.hostManager.restoreHostDNS() + if err != nil { + t.Log(err) + } + }() + + dnsServer.dnsMuxHandlers = testCase.initUpstreamMap + dnsServer.localResolver.Update(testCase.initLocalZones) + dnsServer.updateSerial = testCase.initSerial + + err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate) + if err != nil { + if testCase.shouldFail { + return + } + t.Fatalf("update dns server should not fail, got error: %v", err) + } + + if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) { + t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers)) + } + + for _, expected := range testCase.expectedUpstreamMap { + found := false + for _, got := range dnsServer.dnsMuxHandlers { + if got.domain == expected.domain && got.priority == expected.priority { + found = true + break + } + } + if !found { + t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers) + } + } + + var responseMSG *dns.Msg + responseWriter := &test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + responseMSG = m + return nil + }, + } + for _, q := range testCase.expectedLocalQs { + dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{ + Question: []dns.Question{q}, + }) + } + + if len(testCase.expectedLocalQs) > 0 { + assert.NotNil(t, responseMSG, "response message should not be nil") + assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success") + assert.NotEmpty(t, responseMSG.Answer, "response message should have answers") + } + }) + } +} + +func TestDNSFakeResolverHandleUpdates(t *testing.T) { + ov := os.Getenv("NB_WG_KERNEL_DISABLED") + defer t.Setenv("NB_WG_KERNEL_DISABLED", ov) + + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"}) + if err != nil { + t.Errorf("create stdnet: %v", err) + return + } + + privKey, _ := wgtypes.GeneratePrivateKey() + opts := iface.WGIFaceOpts{ + IFaceName: "utun2301", + Address: wgaddr.MustParseWGAddress("100.66.100.1/32"), + WGPort: 33100, + WGPrivKey: privKey.String(), + MTU: iface.DefaultMTU, + TransportNet: newNet, + } + wgIface, err := iface.NewWGIFace(opts) + if err != nil { + t.Errorf("build interface wireguard: %v", err) + return + } + + err = wgIface.Create() + if err != nil { + t.Errorf("create and init wireguard interface: %v", err) + return + } + defer func() { + if err = wgIface.Close(); err != nil { + t.Logf("close wireguard interface: %v", err) + } + }() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + packetfilter := pfmock.NewMockPacketFilter(ctrl) + packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes() + packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + + if err := wgIface.SetFilter(packetfilter); err != nil { + t.Errorf("set packet filter: %v", err) + return + } + + dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{ + WgInterface: wgIface, + CustomAddress: "", + StatusRecorder: peer.NewRecorder("mgm"), + StateManager: nil, + DisableSys: false, + }) + if err != nil { + t.Errorf("create DNS server: %v", err) + return + } + + err = dnsServer.Initialize() + if err != nil { + t.Errorf("run DNS server: %v", err) + return + } + defer func() { + if err = dnsServer.hostManager.restoreHostDNS(); err != nil { + t.Logf("restore DNS settings on the host: %v", err) + return + } + }() + + dnsServer.dnsMuxHandlers = []handlerWrapper{ + { + domain: zoneRecords[0].Name, + handler: &local.Resolver{}, + priority: PriorityUpstream, + }, + } + dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}) + dnsServer.updateSerial = 0 + + nameServers := []nbdns.NameServer{ + { + IP: netip.MustParseAddr("8.8.8.8"), + NSType: nbdns.UDPNameServerType, + Port: 53, + }, + { + IP: netip.MustParseAddr("8.8.4.4"), + NSType: nbdns.UDPNameServerType, + Port: 53, + }, + } + + update := nbdns.Config{ + ServiceEnable: true, + CustomZones: []nbdns.CustomZone{ + { + Domain: "netbird.cloud", + Records: zoneRecords, + }, + }, + NameServerGroups: []*nbdns.NameServerGroup{ + { + Domains: []string{"netbird.io"}, + NameServers: nameServers, + }, + { + NameServers: nameServers, + Primary: true, + }, + }, + } + + // Start the server with regular configuration + if err := dnsServer.UpdateDNSServer(1, update); err != nil { + t.Fatalf("update dns server should not fail, got error: %v", err) + return + } + + update2 := update + update2.ServiceEnable = false + // Disable the server, stop the listener + if err := dnsServer.UpdateDNSServer(2, update2); err != nil { + t.Fatalf("update dns server should not fail, got error: %v", err) + return + } + + update3 := update2 + update3.NameServerGroups = update3.NameServerGroups[:1] + // But service still get updates and we checking that we handle + // internal state in the right way + if err := dnsServer.UpdateDNSServer(3, update3); err != nil { + t.Fatalf("update dns server should not fail, got error: %v", err) + return + } +} diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go index 4ef790412..96e55a354 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -10,7 +10,6 @@ import ( "testing" "time" - "github.com/golang/mock/gomock" "github.com/miekg/dns" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -23,7 +22,6 @@ import ( "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/device" - pfmock "github.com/netbirdio/netbird/client/iface/mocks" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/dns/local" "github.com/netbirdio/netbird/client/internal/dns/test" @@ -104,466 +102,6 @@ func init() { formatter.SetTextFormatter(log.StandardLogger()) } -func TestUpdateDNSServer(t *testing.T) { - - nameServers := []nbdns.NameServer{ - { - IP: netip.MustParseAddr("8.8.8.8"), - NSType: nbdns.UDPNameServerType, - Port: 53, - }, - { - IP: netip.MustParseAddr("8.8.4.4"), - NSType: nbdns.UDPNameServerType, - Port: 53, - }, - } - - testCases := []struct { - name string - initUpstreamMap []handlerWrapper - initLocalZones []nbdns.CustomZone - initSerial uint64 - inputSerial uint64 - inputUpdate nbdns.Config - shouldFail bool - expectedUpstreamMap []handlerWrapper - expectedLocalQs []dns.Question - }{ - { - name: "Initial Config Should Succeed", - initUpstreamMap: nil, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - Domains: []string{"netbird.io"}, - NameServers: nameServers, - }, - { - NameServers: nameServers, - Primary: true, - }, - }, - }, - expectedUpstreamMap: []handlerWrapper{ - { - domain: "netbird.io", - priority: PriorityUpstream, - }, - { - domain: "netbird.cloud", - priority: PriorityLocal, - }, - { - domain: nbdns.RootZone, - priority: PriorityDefault, - }, - }, - expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}, - }, - { - name: "New Config Should Succeed", - initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: []handlerWrapper{ - { - domain: "netbird.cloud", - handler: &mockHandler{}, - priority: PriorityUpstream, - }, - }, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - Domains: []string{"netbird.io"}, - NameServers: nameServers, - }, - }, - }, - expectedUpstreamMap: []handlerWrapper{ - { - domain: "netbird.io", - priority: PriorityUpstream, - }, - { - domain: "netbird.cloud", - priority: PriorityLocal, - }, - }, - expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}}, - }, - { - name: "Smaller Config Serial Should Be Skipped", - initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: nil, - initSerial: 2, - inputSerial: 1, - shouldFail: true, - }, - { - name: "Empty NS Group Domain Or Not Primary Element Should Fail", - initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: nil, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - NameServers: nameServers, - }, - }, - }, - shouldFail: true, - }, - { - name: "Invalid NS Group Nameservers list Should Fail", - initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: nil, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - NameServers: nameServers, - }, - }, - }, - shouldFail: true, - }, - { - name: "Invalid Custom Zone Records list Should Skip", - initLocalZones: []nbdns.CustomZone{}, - initUpstreamMap: nil, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - NameServers: nameServers, - Primary: true, - }, - }, - }, - expectedUpstreamMap: []handlerWrapper{{ - domain: ".", - priority: PriorityDefault, - }}, - }, - { - name: "Empty Config Should Succeed and Clean Maps", - initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: []handlerWrapper{ - { - domain: zoneRecords[0].Name, - handler: &mockHandler{}, - priority: PriorityUpstream, - }, - }, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ServiceEnable: true}, - expectedUpstreamMap: nil, - expectedLocalQs: []dns.Question{}, - }, - { - name: "Disabled Service Should clean map", - initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}, - initUpstreamMap: []handlerWrapper{ - { - domain: zoneRecords[0].Name, - handler: &mockHandler{}, - priority: PriorityUpstream, - }, - }, - initSerial: 0, - inputSerial: 1, - inputUpdate: nbdns.Config{ServiceEnable: false}, - expectedUpstreamMap: nil, - expectedLocalQs: []dns.Question{}, - }, - } - - for n, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - privKey, _ := wgtypes.GenerateKey() - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } - - opts := iface.WGIFaceOpts{ - IFaceName: fmt.Sprintf("utun230%d", n), - Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)), - WGPort: 33100, - WGPrivKey: privKey.String(), - MTU: iface.DefaultMTU, - TransportNet: newNet, - } - - wgIface, err := iface.NewWGIFace(opts) - if err != nil { - t.Fatal(err) - } - err = wgIface.Create() - if err != nil { - t.Fatal(err) - } - defer func() { - err = wgIface.Close() - if err != nil { - t.Log(err) - } - }() - dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{ - WgInterface: wgIface, - CustomAddress: "", - StatusRecorder: peer.NewRecorder("mgm"), - StateManager: nil, - DisableSys: false, - }) - if err != nil { - t.Fatal(err) - } - err = dnsServer.Initialize() - if err != nil { - t.Fatal(err) - } - defer func() { - err = dnsServer.hostManager.restoreHostDNS() - if err != nil { - t.Log(err) - } - }() - - dnsServer.dnsMuxHandlers = testCase.initUpstreamMap - dnsServer.localResolver.Update(testCase.initLocalZones) - dnsServer.updateSerial = testCase.initSerial - - err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate) - if err != nil { - if testCase.shouldFail { - return - } - t.Fatalf("update dns server should not fail, got error: %v", err) - } - - if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) { - t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers)) - } - - for _, expected := range testCase.expectedUpstreamMap { - found := false - for _, got := range dnsServer.dnsMuxHandlers { - if got.domain == expected.domain && got.priority == expected.priority { - found = true - break - } - } - if !found { - t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers) - } - } - - var responseMSG *dns.Msg - responseWriter := &test.MockResponseWriter{ - WriteMsgFunc: func(m *dns.Msg) error { - responseMSG = m - return nil - }, - } - for _, q := range testCase.expectedLocalQs { - dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{ - Question: []dns.Question{q}, - }) - } - - if len(testCase.expectedLocalQs) > 0 { - assert.NotNil(t, responseMSG, "response message should not be nil") - assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success") - assert.NotEmpty(t, responseMSG.Answer, "response message should have answers") - } - }) - } -} - -func TestDNSFakeResolverHandleUpdates(t *testing.T) { - ov := os.Getenv("NB_WG_KERNEL_DISABLED") - defer t.Setenv("NB_WG_KERNEL_DISABLED", ov) - - t.Setenv("NB_WG_KERNEL_DISABLED", "true") - newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"}) - if err != nil { - t.Errorf("create stdnet: %v", err) - return - } - - privKey, _ := wgtypes.GeneratePrivateKey() - opts := iface.WGIFaceOpts{ - IFaceName: "utun2301", - Address: wgaddr.MustParseWGAddress("100.66.100.1/32"), - WGPort: 33100, - WGPrivKey: privKey.String(), - MTU: iface.DefaultMTU, - TransportNet: newNet, - } - wgIface, err := iface.NewWGIFace(opts) - if err != nil { - t.Errorf("build interface wireguard: %v", err) - return - } - - err = wgIface.Create() - if err != nil { - t.Errorf("create and init wireguard interface: %v", err) - return - } - defer func() { - if err = wgIface.Close(); err != nil { - t.Logf("close wireguard interface: %v", err) - } - }() - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - packetfilter := pfmock.NewMockPacketFilter(ctrl) - packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes() - packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - - if err := wgIface.SetFilter(packetfilter); err != nil { - t.Errorf("set packet filter: %v", err) - return - } - - dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{ - WgInterface: wgIface, - CustomAddress: "", - StatusRecorder: peer.NewRecorder("mgm"), - StateManager: nil, - DisableSys: false, - }) - if err != nil { - t.Errorf("create DNS server: %v", err) - return - } - - err = dnsServer.Initialize() - if err != nil { - t.Errorf("run DNS server: %v", err) - return - } - defer func() { - if err = dnsServer.hostManager.restoreHostDNS(); err != nil { - t.Logf("restore DNS settings on the host: %v", err) - return - } - }() - - dnsServer.dnsMuxHandlers = []handlerWrapper{ - { - domain: zoneRecords[0].Name, - handler: &local.Resolver{}, - priority: PriorityUpstream, - }, - } - dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}}) - dnsServer.updateSerial = 0 - - nameServers := []nbdns.NameServer{ - { - IP: netip.MustParseAddr("8.8.8.8"), - NSType: nbdns.UDPNameServerType, - Port: 53, - }, - { - IP: netip.MustParseAddr("8.8.4.4"), - NSType: nbdns.UDPNameServerType, - Port: 53, - }, - } - - update := nbdns.Config{ - ServiceEnable: true, - CustomZones: []nbdns.CustomZone{ - { - Domain: "netbird.cloud", - Records: zoneRecords, - }, - }, - NameServerGroups: []*nbdns.NameServerGroup{ - { - Domains: []string{"netbird.io"}, - NameServers: nameServers, - }, - { - NameServers: nameServers, - Primary: true, - }, - }, - } - - // Start the server with regular configuration - if err := dnsServer.UpdateDNSServer(1, update); err != nil { - t.Fatalf("update dns server should not fail, got error: %v", err) - return - } - - update2 := update - update2.ServiceEnable = false - // Disable the server, stop the listener - if err := dnsServer.UpdateDNSServer(2, update2); err != nil { - t.Fatalf("update dns server should not fail, got error: %v", err) - return - } - - update3 := update2 - update3.NameServerGroups = update3.NameServerGroups[:1] - // But service still get updates and we checking that we handle - // internal state in the right way - if err := dnsServer.UpdateDNSServer(3, update3); err != nil { - t.Fatalf("update dns server should not fail, got error: %v", err) - return - } -} - func TestDNSServerStartStop(t *testing.T) { testCases := []struct { name string diff --git a/client/internal/dns/service_listener.go b/client/internal/dns/service_listener.go index 9c0e52af8..3dc29c4dc 100644 --- a/client/internal/dns/service_listener.go +++ b/client/internal/dns/service_listener.go @@ -292,18 +292,16 @@ func (s *serviceViaListener) generateFreePort() (uint16, error) { return customPort, nil } - udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort("0.0.0.0:0")) - probeListener, err := net.ListenUDP("udp", udpAddr) + probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{}) if err != nil { log.Debugf("failed to bind random port for DNS: %s", err) return 0, err } - addrPort := netip.MustParseAddrPort(probeListener.LocalAddr().String()) // might panic if address is incorrect - err = probeListener.Close() - if err != nil { + port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port) + if err = probeListener.Close(); err != nil { log.Debugf("failed to free up DNS port: %s", err) return 0, err } - return addrPort.Port(), nil + return port, nil } diff --git a/client/internal/dns/unclean_shutdown_windows.go b/client/internal/dns/unclean_shutdown_windows.go index 24a9eca50..ab0b2cc63 100644 --- a/client/internal/dns/unclean_shutdown_windows.go +++ b/client/internal/dns/unclean_shutdown_windows.go @@ -5,9 +5,8 @@ import ( ) type ShutdownState struct { - Guid string - GPO bool - NRPTEntryCount int + Guid string + GPO bool } func (s *ShutdownState) Name() string { @@ -16,9 +15,8 @@ func (s *ShutdownState) Name() string { func (s *ShutdownState) Cleanup() error { manager := ®istryConfigurator{ - guid: s.Guid, - gpo: s.GPO, - nrptEntryCount: s.NRPTEntryCount, + guid: s.Guid, + gpo: s.GPO, } if err := manager.restoreUncleanShutdownDNS(); err != nil { diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go index 9c0d00212..72fc0450c 100644 --- a/client/internal/dns/upstream.go +++ b/client/internal/dns/upstream.go @@ -457,7 +457,7 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.M // 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) } return raceResult{msg: rm, upstream: upstream, protocol: proto, ede: edeName(code)}, nil } @@ -466,7 +466,7 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.M } if !hadEdns { - stripOPT(rm) + resutil.StripOPT(rm) } return raceResult{msg: rm, upstream: upstream, protocol: proto}, nil @@ -523,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_ios.go b/client/internal/dns/upstream_ios.go index b989bf0f9..793d87fca 100644 --- a/client/internal/dns/upstream_ios.go +++ b/client/internal/dns/upstream_ios.go @@ -130,8 +130,3 @@ func GetClientPrivate(iface privateClientIface, upstreamIP netip.Addr, dialTimeo } return client, nil } - -func getInterfaceIndex(interfaceName string) (int, error) { - iface, err := net.InterfaceByName(interfaceName) - return iface.Index, err -} diff --git a/client/internal/dns/upstream_test.go b/client/internal/dns/upstream_test.go index afd2053cc..4c2784545 100644 --- a/client/internal/dns/upstream_test.go +++ b/client/internal/dns/upstream_test.go @@ -985,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/dns_peer_activator.go b/client/internal/dns_peer_activator.go new file mode 100644 index 000000000..c283d6251 --- /dev/null +++ b/client/internal/dns_peer_activator.go @@ -0,0 +1,76 @@ +package internal + +import ( + "context" + "net/netip" + "time" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" +) + +const dnsActivationPollInterval = 50 * time.Millisecond + +// dnsPeerActivator wakes lazy-connection peers from the DNS resolution path. It +// implements dns/local.PeerActivator. DNS queries run on their own goroutines, +// so it only touches state that is safe for concurrent use — ConnMgr.ActivatePeer, +// peerstore.Store and peer.Status — and never takes the engine's syncMsgMux, +// keeping DNS resolution from contending with network-map processing. +type dnsPeerActivator struct { + connMgr *ConnMgr + peerStore *peerstore.Store + status *peer.Status + // ctx is the engine's long-lived context. The connection dial is tied to it + // (not the per-query DNS wait budget) so a handshake that outlasts the wait + // still completes in the background rather than being cancelled at the deadline. + ctx context.Context +} + +// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and waits +// until one is connected or ctx (the per-query DNS wait budget) expires. +// Activation itself is tied to the engine's long-lived context so the dial +// survives a wait that times out. Unknown or already-connected addresses are +// skipped, so the steady-state (warm) path adds no latency. +func (a *dnsPeerActivator) ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) { + if a == nil || a.connMgr == nil { + return + } + + var pending []string + for _, addr := range addrs { + ip := addr.String() + st, ok := a.status.PeerStateByIP(ip) + if !ok || st.ConnStatus == peer.StatusConnected { + continue + } + conn, ok := a.peerStore.PeerConn(st.PubKey) + if !ok { + continue + } + a.connMgr.ActivatePeer(a.ctx, conn) + pending = append(pending, ip) + } + + if len(pending) == 0 { + return + } + a.waitConnected(ctx, pending) +} + +// waitConnected blocks until any of ips reports a connected peer or ctx expires. +func (a *dnsPeerActivator) waitConnected(ctx context.Context, ips []string) { + ticker := time.NewTicker(dnsActivationPollInterval) + defer ticker.Stop() + for { + for _, ip := range ips { + if st, ok := a.status.PeerStateByIP(ip); ok && st.ConnStatus == peer.StatusConnected { + return + } + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/client/internal/dns_peer_activator_test.go b/client/internal/dns_peer_activator_test.go new file mode 100644 index 000000000..8c3b75e59 --- /dev/null +++ b/client/internal/dns_peer_activator_test.go @@ -0,0 +1,129 @@ +package internal + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" +) + +func newTestPeerConn(t *testing.T, key string) *peer.Conn { + t.Helper() + conn, err := peer.NewConn(peer.ConnConfig{ + Key: key, + LocalKey: "local", + WgConfig: peer.WgConfig{ + AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}, + }, + }, peer.ServiceDependencies{}) + require.NoError(t, err) + return conn +} + +func newTestDNSPeerActivator(t *testing.T) (*dnsPeerActivator, *peer.Status, *peerstore.Store) { + t.Helper() + status := peer.NewRecorder("https://mgm") + store := peerstore.NewConnStore() + // ConnMgr without Start: the lazy manager is nil, so ActivatePeer is a + // no-op — these tests exercise the activator's skip/wait logic. + connMgr := NewConnMgr(&EngineConfig{}, status, store, nil) + return &dnsPeerActivator{ + connMgr: connMgr, + peerStore: store, + status: status, + ctx: context.Background(), + }, status, store +} + +func TestDNSPeerActivator_NilSafe(t *testing.T) { + var a *dnsPeerActivator + a.ActivatePeersByIP(context.Background(), []netip.Addr{netip.MustParseAddr("100.64.0.1")}) +} + +// TestDNSPeerActivator_SkipsUnknownAndConnectedPeers verifies the steady-state +// (warm) path adds no latency: already-connected and unknown addresses never +// enter the wait loop. +func TestDNSPeerActivator_SkipsUnknownAndConnectedPeers(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "fd00::1")) + require.NoError(t, status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected})) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{ + netip.MustParseAddr("100.64.0.1"), // known, connected -> skipped + netip.MustParseAddr("fd00::1"), // known via IPv6, connected -> skipped + netip.MustParseAddr("100.64.0.99"), // unknown -> skipped + }) + require.Less(t, time.Since(start), time.Second, "no pending peer must mean no wait") +} + +// TestDNSPeerActivator_WaitsForPendingPeerToConnect verifies the wait loop +// returns as soon as a pending peer reports connected, well before the +// per-query budget expires. +func TestDNSPeerActivator_WaitsForPendingPeerToConnect(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + go func() { + time.Sleep(150 * time.Millisecond) + _ = status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected}) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + elapsed := time.Since(start) + + require.GreaterOrEqual(t, elapsed, 100*time.Millisecond, "must wait for the pending peer") + require.Less(t, elapsed, 5*time.Second, "must return on connect, not at the deadline") +} + +// TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle verifies a peer that +// never connects releases the DNS response at the per-query budget instead of +// blocking it indefinitely. +func TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle(t *testing.T) { + a, status, store := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + store.AddPeerConn("peerA", newTestPeerConn(t, "peerA")) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + elapsed := time.Since(start) + + require.GreaterOrEqual(t, elapsed, 250*time.Millisecond, "must wait out the budget for a pending peer") + require.Less(t, elapsed, 5*time.Second, "must not block past the budget") +} + +// TestDNSPeerActivator_NoWaitWithoutPeerConn verifies a known-but-idle peer +// with no connection object in the store is not waited on: there is nothing to +// activate, so waiting could only ever time out. +func TestDNSPeerActivator_NoWaitWithoutPeerConn(t *testing.T) { + a, status, _ := newTestDNSPeerActivator(t) + + require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "")) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + start := time.Now() + a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")}) + require.Less(t, time.Since(start), time.Second, "peer without a conn must not be waited on") +} diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go index 2e8ef84ab..b7e5a10e3 100644 --- a/client/internal/dnsfwd/forwarder.go +++ b/client/internal/dnsfwd/forwarder.go @@ -26,8 +26,23 @@ import ( const errResolveFailed = "failed to resolve query for domain=%s: %v" const upstreamTimeout = 15 * time.Second +// EDE info codes the forwarder emits on upstream failures so the querying +// client can see the reason without inspecting this peer's logs. They live in +// the RFC 8914 Private Use range (49152-65535); the Go resolver never exposes a +// real upstream EDE here, so these cannot collide with a genuine code. +const ( + edeNetbirdUpstreamTimeout uint16 = 49152 + edeNetbirdUpstreamFailure uint16 = 49153 +) + type resolver interface { LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) + LookupMX(ctx context.Context, name string) ([]*net.MX, error) + LookupTXT(ctx context.Context, name string) ([]string, error) + LookupNS(ctx context.Context, name string) ([]*net.NS, error) + LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) + LookupCNAME(ctx context.Context, host string) (string, error) + LookupAddr(ctx context.Context, addr string) ([]string, error) } type firewaller interface { @@ -201,12 +216,6 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass]) resp := query.SetReply(query) - network := resutil.NetworkForQtype(question.Qtype) - if network == "" { - resp.Rcode = dns.RcodeNotImplemented - f.writeResponse(logger, w, resp, qname, startTime) - return - } mostSpecificResId, matchingEntries := f.getMatchingEntries(strings.TrimSuffix(qname, ".")) if mostSpecificResId == "" { @@ -218,9 +227,46 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q ctx, cancel := context.WithTimeout(context.Background(), upstreamTimeout) defer cancel() + reqHasEdns := query.IsEdns0() != nil + + switch question.Qtype { + case dns.TypeA, dns.TypeAAAA: + f.handleAddressQuery(ctx, logger, w, resp, mostSpecificResId, matchingEntries, reqHasEdns, startTime) + case dns.TypeMX, dns.TypeTXT, dns.TypeNS, dns.TypeSRV, dns.TypeCNAME, dns.TypePTR: + f.handleRecordQuery(ctx, logger, w, resp, startTime) + default: + // The domain is routed here, so any other type is answered NODATA + // (NOERROR, empty answer) rather than falling back to a resolver that + // would poison the name with NXDOMAIN. The Extended DNS Error lets a + // client tell this capability-driven NODATA apart from an + // authoritative one. The OPT pseudo-record must not appear unless the + // query advertised EDNS0. + if reqHasEdns { + attachEDE(resp, dns.ExtendedErrorCodeNotSupported, "netbird forwarder: unsupported query type") + } + f.writeResponse(logger, w, resp, qname, startTime) + } +} + +// handleAddressQuery resolves A/AAAA queries, programs the firewall sets and +// resolved-IP state, and caches the answer for resilience on upstream failure. +func (f *DNSForwarder) handleAddressQuery( + ctx context.Context, + logger *log.Entry, + w dns.ResponseWriter, + resp *dns.Msg, + mostSpecificResId route.ResID, + matchingEntries []*ForwarderEntry, + reqHasEdns bool, + startTime time.Time, +) { + question := resp.Question[0] + qname := strings.ToLower(question.Name) + + network := resutil.NetworkForQtype(question.Qtype) result := resutil.LookupIP(ctx, f.resolver, network, qname, question.Qtype) if result.Err != nil { - f.handleDNSError(ctx, logger, w, question, resp, qname, result, startTime) + f.handleDNSError(ctx, logger, w, question, resp, qname, result, reqHasEdns, startTime) return } @@ -231,6 +277,25 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q f.writeResponse(logger, w, resp, qname, startTime) } +// handleRecordQuery resolves non-address record types (MX, TXT, NS, SRV, +// CNAME, PTR) through the host resolver. Missing records are answered NODATA so +// the routed name is never poisoned with NXDOMAIN. +func (f *DNSForwarder) handleRecordQuery( + ctx context.Context, + logger *log.Entry, + w dns.ResponseWriter, + resp *dns.Msg, + startTime time.Time, +) { + question := resp.Question[0] + qname := strings.ToLower(question.Name) + + records, rcode := resutil.LookupRecords(ctx, f.resolver, qname, question.Qtype, f.ttl) + resp.Rcode = rcode + resp.Answer = append(resp.Answer, records...) + f.writeResponse(logger, w, resp, qname, startTime) +} + func (f *DNSForwarder) writeResponse(logger *log.Entry, w dns.ResponseWriter, resp *dns.Msg, qname string, startTime time.Time) { if err := w.WriteMsg(resp); err != nil { logger.Errorf("failed to write DNS response: %v", err) @@ -333,6 +398,7 @@ func (f *DNSForwarder) handleDNSError( resp *dns.Msg, domain string, result resutil.LookupResult, + reqHasEdns bool, startTime time.Time, ) { qType := question.Qtype @@ -374,6 +440,10 @@ func (f *DNSForwarder) handleDNSError( logger.Warnf(errResolveFailed, domain, result.Err) } + if reqHasEdns { + attachEDE(resp, edeCodeFor(dnsErr), edeText(dnsErr)) + } + f.writeResponse(logger, w, resp, domain, startTime) } @@ -414,3 +484,33 @@ func (f *DNSForwarder) getMatchingEntries(domain string) (route.ResID, []*Forwar return selectedResId, matches } + +// edeCodeFor maps an upstream lookup error to the NetBird EDE info code. +func edeCodeFor(dnsErr *net.DNSError) uint16 { + if dnsErr != nil && dnsErr.IsTimeout { + return edeNetbirdUpstreamTimeout + } + return edeNetbirdUpstreamFailure +} + +// edeText builds the EDE extra-text describing the class of upstream failure. +// It deliberately omits the upstream server address, which may be an internal +// resolver and is exposed to any client permitted to use the route; the full +// detail stays in the forwarder's local log. +func edeText(dnsErr *net.DNSError) string { + if dnsErr != nil && dnsErr.IsTimeout { + return "netbird forwarder: upstream timeout" + } + return "netbird forwarder: upstream failure" +} + +// attachEDE adds an Extended DNS Error (RFC 8914) option to the response, +// creating the OPT pseudo-record if the response does not already carry one. +func attachEDE(resp *dns.Msg, code uint16, text string) { + opt := resp.IsEdns0() + if opt == nil { + resp.SetEdns0(dns.DefaultMsgSize, false) + opt = resp.IsEdns0() + } + opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text}) +} diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go index 7325ef8a7..c69a9166e 100644 --- a/client/internal/dnsfwd/forwarder_test.go +++ b/client/internal/dnsfwd/forwarder_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" firewall "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/dns/resutil" "github.com/netbirdio/netbird/client/internal/dns/test" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/route" @@ -132,6 +133,41 @@ func (m *MockResolver) LookupNetIP(ctx context.Context, network, host string) ([ return args.Get(0).([]netip.Addr), args.Error(1) } +func (m *MockResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) { + args := m.Called(ctx, name) + recs, _ := args.Get(0).([]*net.MX) + return recs, args.Error(1) +} + +func (m *MockResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { + args := m.Called(ctx, name) + recs, _ := args.Get(0).([]string) + return recs, args.Error(1) +} + +func (m *MockResolver) LookupNS(ctx context.Context, name string) ([]*net.NS, error) { + args := m.Called(ctx, name) + recs, _ := args.Get(0).([]*net.NS) + return recs, args.Error(1) +} + +func (m *MockResolver) LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) { + args := m.Called(ctx, service, proto, name) + recs, _ := args.Get(1).([]*net.SRV) + return args.String(0), recs, args.Error(2) +} + +func (m *MockResolver) LookupCNAME(ctx context.Context, host string) (string, error) { + args := m.Called(ctx, host) + return args.String(0), args.Error(1) +} + +func (m *MockResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) { + args := m.Called(ctx, addr) + recs, _ := args.Get(0).([]string) + return recs, args.Error(1) +} + func TestDNSForwarder_SubdomainAccessLogic(t *testing.T) { tests := []struct { name string @@ -544,12 +580,15 @@ func TestDNSForwarder_MultipleIPsInSingleUpdate(t *testing.T) { } func TestDNSForwarder_ResponseCodes(t *testing.T) { + // A type with no net.Resolver Lookup method (CAA) must answer NODATA + // (NOERROR, empty) rather than NXDOMAIN/NOTIMP to avoid poisoning the name. tests := []struct { name string queryType uint16 queryDomain string configured string expectedCode int + expectEDE bool description string }{ { @@ -561,28 +600,13 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { description: "RFC compliant REFUSED for unauthorized queries", }, { - name: "unsupported query type returns NOTIMP", - queryType: dns.TypeMX, + name: "unsupported query type returns NODATA", + queryType: dns.TypeCAA, queryDomain: "example.com", configured: "example.com", - expectedCode: dns.RcodeNotImplemented, - description: "RFC compliant NOTIMP for unsupported types", - }, - { - name: "CNAME query returns NOTIMP", - queryType: dns.TypeCNAME, - queryDomain: "example.com", - configured: "example.com", - expectedCode: dns.RcodeNotImplemented, - description: "CNAME queries not supported", - }, - { - name: "TXT query returns NOTIMP", - queryType: dns.TypeTXT, - queryDomain: "example.com", - configured: "example.com", - expectedCode: dns.RcodeNotImplemented, - description: "TXT queries not supported", + expectedCode: dns.RcodeSuccess, + expectEDE: true, + description: "Unsupported types answer NODATA, not NXDOMAIN/NOTIMP", }, } @@ -598,6 +622,7 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { query := &dns.Msg{} query.SetQuestion(dns.Fqdn(tt.queryDomain), tt.queryType) + query.SetEdns0(dns.DefaultMsgSize, false) // Capture the written response var writtenResp *dns.Msg @@ -613,6 +638,288 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { // Check the response written to the writer require.NotNil(t, writtenResp, "Expected response to be written") assert.Equal(t, tt.expectedCode, writtenResp.Rcode, tt.description) + assert.Empty(t, writtenResp.Answer, "Non-address response should carry no answers") + + if tt.expectEDE { + require.NotNil(t, writtenResp.IsEdns0(), "EDNS0 client should get an OPT in the reply") + assert.True(t, hasEDE(writtenResp, dns.ExtendedErrorCodeNotSupported), + "unsupported type NODATA should carry EDE Not Supported") + } + }) + } +} + +func hasEDE(m *dns.Msg, code uint16) bool { + opt := m.IsEdns0() + if opt == nil { + return false + } + for _, o := range opt.Option { + if ede, ok := o.(*dns.EDNS0_EDE); ok && ede.InfoCode == code { + return true + } + } + return false +} + +func TestDNSForwarder_RecordQueries(t *testing.T) { + notFound := &net.DNSError{IsNotFound: true, Name: "example.com"} + + t.Run("MX records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupMX", mock.Anything, "example.com."). + Return([]*net.MX{{Host: "mail.example.com.", Pref: 10}}, nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeMX) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + mx, ok := resp.Answer[0].(*dns.MX) + require.True(t, ok, "answer should be an MX record") + assert.Equal(t, uint16(10), mx.Preference) + assert.Equal(t, "mail.example.com.", mx.Mx) + mockResolver.AssertExpectations(t) + }) + + t.Run("missing MX is NODATA not NXDOMAIN", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + // A not-found cannot prove the name is absent (it may exist with only + // other record types), so it must answer NODATA, never NXDOMAIN. + mockResolver.On("LookupMX", mock.Anything, "example.com."). + Return(nil, notFound).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeMX) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "missing record must be NODATA") + assert.Empty(t, resp.Answer) + mockResolver.AssertExpectations(t) + }) + + t.Run("NS records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupNS", mock.Anything, "example.com."). + Return([]*net.NS{{Host: "ns1.example.com."}}, nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeNS) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + ns, ok := resp.Answer[0].(*dns.NS) + require.True(t, ok, "answer should be an NS record") + assert.Equal(t, "ns1.example.com.", ns.Ns) + mockResolver.AssertExpectations(t) + }) + + t.Run("missing NS is NODATA", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupNS", mock.Anything, "example.com."). + Return(nil, notFound).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeNS) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode) + assert.Empty(t, resp.Answer) + mockResolver.AssertExpectations(t) + }) + + t.Run("SRV records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "_sip._tcp.example.com") + + mockResolver.On("LookupSRV", mock.Anything, "", "", "_sip._tcp.example.com."). + Return("", []*net.SRV{{Target: "sip.example.com.", Port: 5060, Priority: 10, Weight: 5}}, nil).Once() + + resp := runRecordQuery(t, forwarder, "_sip._tcp.example.com", dns.TypeSRV) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + srv, ok := resp.Answer[0].(*dns.SRV) + require.True(t, ok, "answer should be an SRV record") + assert.Equal(t, "sip.example.com.", srv.Target) + assert.Equal(t, uint16(5060), srv.Port) + assert.Equal(t, uint16(10), srv.Priority) + mockResolver.AssertExpectations(t) + }) + + t.Run("missing SRV is NODATA", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "_sip._tcp.example.com") + + mockResolver.On("LookupSRV", mock.Anything, "", "", "_sip._tcp.example.com."). + Return("", nil, notFound).Once() + + resp := runRecordQuery(t, forwarder, "_sip._tcp.example.com", dns.TypeSRV) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode) + assert.Empty(t, resp.Answer) + mockResolver.AssertExpectations(t) + }) + + t.Run("TXT records are forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + mockResolver.On("LookupTXT", mock.Anything, "example.com."). + Return([]string{"v=spf1 -all"}, nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeTXT) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + txt, ok := resp.Answer[0].(*dns.TXT) + require.True(t, ok, "answer should be a TXT record") + assert.Equal(t, []string{"v=spf1 -all"}, txt.Txt) + mockResolver.AssertExpectations(t) + }) + + t.Run("CNAME record is forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "www.example.com") + + mockResolver.On("LookupCNAME", mock.Anything, "www.example.com."). + Return("target.example.com.", nil).Once() + + resp := runRecordQuery(t, forwarder, "www.example.com", dns.TypeCNAME) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok, "answer should be a CNAME record") + assert.Equal(t, "target.example.com.", cname.Target) + mockResolver.AssertExpectations(t) + }) + + t.Run("CNAME equal to the name is NODATA", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "example.com") + + // No CNAME exists: LookupCNAME echoes the queried name back. + mockResolver.On("LookupCNAME", mock.Anything, "example.com."). + Return("example.com.", nil).Once() + + resp := runRecordQuery(t, forwarder, "example.com", dns.TypeCNAME) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode) + assert.Empty(t, resp.Answer, "self-referential CNAME means no CNAME record") + mockResolver.AssertExpectations(t) + }) + + t.Run("PTR record is forwarded", func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := newRecordTestForwarder(t, mockResolver, "*.in-addr.arpa") + + // The reverse name is parsed back to the address LookupAddr expects. + mockResolver.On("LookupAddr", mock.Anything, "1.2.3.4"). + Return([]string{"host.example.com."}, nil).Once() + + resp := runRecordQuery(t, forwarder, "4.3.2.1.in-addr.arpa", dns.TypePTR) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + ptr, ok := resp.Answer[0].(*dns.PTR) + require.True(t, ok, "answer should be a PTR record") + assert.Equal(t, "host.example.com.", ptr.Ptr) + mockResolver.AssertExpectations(t) + }) +} + +func newRecordTestForwarder(t *testing.T, r resolver, configured string) *DNSForwarder { + t.Helper() + forwarder := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 300, nil, &peer.Status{}, nil) + forwarder.resolver = r + + d, err := domain.FromString(configured) + require.NoError(t, err) + forwarder.UpdateDomains([]*ForwarderEntry{{Domain: d, ResID: "test-res"}}) + return forwarder +} + +func runRecordQuery(t *testing.T, forwarder *DNSForwarder, qname string, qtype uint16) *dns.Msg { + t.Helper() + query := &dns.Msg{} + query.SetQuestion(dns.Fqdn(qname), qtype) + + mockWriter := &test.MockResponseWriter{} + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) + + resp := mockWriter.GetLastResponse() + require.NotNil(t, resp, "expected response to be written") + return resp +} + +func TestDNSForwarder_UpstreamFailureEDE(t *testing.T) { + tests := []struct { + name string + lookupErr error + reqEdns bool + wantEDE bool + wantCode uint16 + wantTextHas string + }{ + { + name: "timeout with edns0", + lookupErr: &net.DNSError{Err: "i/o timeout", Server: "10.0.0.53:53", IsTimeout: true}, + reqEdns: true, + wantEDE: true, + wantCode: edeNetbirdUpstreamTimeout, + wantTextHas: "netbird forwarder: upstream timeout", + }, + { + name: "server failure with edns0", + lookupErr: &net.DNSError{Err: "server misbehaving", Server: "10.0.0.53:53"}, + reqEdns: true, + wantEDE: true, + wantCode: edeNetbirdUpstreamFailure, + wantTextHas: "netbird forwarder: upstream failure", + }, + { + name: "no edns0 in request omits ede", + lookupErr: &net.DNSError{Err: "server misbehaving", Server: "10.0.0.53:53"}, + reqEdns: false, + wantEDE: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockResolver := &MockResolver{} + forwarder := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 300, nil, &peer.Status{}, nil) + forwarder.resolver = mockResolver + + d, err := domain.FromString("example.com") + require.NoError(t, err) + forwarder.UpdateDomains([]*ForwarderEntry{{Domain: d, ResID: "test-res"}}) + + mockResolver.On("LookupNetIP", mock.Anything, "ip4", "example.com."). + Return([]netip.Addr(nil), tt.lookupErr).Once() + + query := &dns.Msg{} + query.SetQuestion("example.com.", dns.TypeA) + if tt.reqEdns { + query.SetEdns0(dns.DefaultMsgSize, false) + } + + var writtenResp *dns.Msg + mockWriter := &test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + writtenResp = m + return nil + }, + } + + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) + mockResolver.AssertExpectations(t) + + require.NotNil(t, writtenResp, "expected a response") + assert.Equal(t, dns.RcodeServerFailure, writtenResp.Rcode, "upstream failure must be SERVFAIL") + + ede, ok := resutil.ExtractEDE(writtenResp) + if !tt.wantEDE { + assert.False(t, ok, "response must not carry EDE") + return + } + require.True(t, ok, "response must carry EDE") + assert.Equal(t, tt.wantCode, ede.InfoCode, "EDE info code") + assert.Contains(t, ede.ExtraText, tt.wantTextHas, "EDE extra-text") + assert.NotContains(t, ede.ExtraText, "10.0.0.53", "must not leak upstream server address") }) } } diff --git a/client/internal/dnsfwd/manager.go b/client/internal/dnsfwd/manager.go index c4c16cd3f..29ca0d247 100644 --- a/client/internal/dnsfwd/manager.go +++ b/client/internal/dnsfwd/manager.go @@ -101,7 +101,7 @@ func (m *Manager) Start(fwdEntries []*ForwarderEntry) error { m.dnsForwarder = NewDNSForwarder(listenAddress, dnsTTL, m.firewall, m.statusRecorder, m.wgIface) go func() { - if err := m.dnsForwarder.Listen(fwdEntries); err != nil { + if err := m.dnsForwarder.Listen(fwdEntries); err != nil { //nolint:staticcheck // todo handle close error if it is exists log.Errorf("failed to start DNS forwarder, err: %v", err) } diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.o b/client/internal/ebpf/ebpf/bpf_bpfeb.o index 6e9cda44a..7433ad740 100644 Binary files a/client/internal/ebpf/ebpf/bpf_bpfeb.o and b/client/internal/ebpf/ebpf/bpf_bpfeb.o differ diff --git a/client/internal/ebpf/ebpf/bpf_bpfel.o b/client/internal/ebpf/ebpf/bpf_bpfel.o index 6338f4774..779f43a00 100644 Binary files a/client/internal/ebpf/ebpf/bpf_bpfel.o and b/client/internal/ebpf/ebpf/bpf_bpfel.o differ diff --git a/client/internal/ebpf/ebpf/manager_linux.go b/client/internal/ebpf/ebpf/manager_linux.go index 7520a6387..64a3e5b54 100644 --- a/client/internal/ebpf/ebpf/manager_linux.go +++ b/client/internal/ebpf/ebpf/manager_linux.go @@ -2,17 +2,21 @@ package ebpf import ( _ "embed" + "fmt" "net" "sync" "github.com/cilium/ebpf/link" "github.com/cilium/ebpf/rlimit" log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" "github.com/netbirdio/netbird/client/internal/ebpf/manager" ) const ( + xdpProgName = "nb_xdp_prog" + mapKeyFeatures uint32 = 0 featureFlagWGProxy = 0b00000001 @@ -68,21 +72,50 @@ func (tf *GeneralManager) loadXdp() error { return err } - // load pre-compiled programs into the kernel. - err = loadBpfObjects(&tf.bpfObjs, nil) + // lo has no native XDP, so the program runs in generic mode. Unless it + // declares multi-buffer support the kernel must linearize every non-linear + // skb before running it. Loopback packets are up to 64 KB, so that is a + // contiguous GFP_ATOMIC allocation per packet, and when it fails the packet + // is dropped before the program runs, stalling local TCP connections. + // Multi-buffer XDP in generic mode requires kernel 6.3, so fall back to a + // plain attach when the kernel rejects it. + err = tf.attachXdp(iFace.Index, true) + if err == nil { + return nil + } + log.Debugf("failed to attach multi-buffer xdp program, retrying without it: %s", err) + + return tf.attachXdp(iFace.Index, false) +} + +func (tf *GeneralManager) attachXdp(iFaceIndex int, multiBuffer bool) error { + spec, err := loadBpf() if err != nil { - return err + return fmt.Errorf("load bpf spec: %w", err) + } + + if multiBuffer { + prog, ok := spec.Programs[xdpProgName] + if !ok { + return fmt.Errorf("program %s not found in bpf spec", xdpProgName) + } + prog.Flags |= unix.BPF_F_XDP_HAS_FRAGS + } + + if err := spec.LoadAndAssign(&tf.bpfObjs, nil); err != nil { + return fmt.Errorf("load bpf objects: %w", err) } tf.link, err = link.AttachXDP(link.XDPOptions{ Program: tf.bpfObjs.NbXdpProg, - Interface: iFace.Index, + Interface: iFaceIndex, }) - if err != nil { - _ = tf.bpfObjs.Close() + if closeErr := tf.bpfObjs.Close(); closeErr != nil { + log.Debugf("failed to close bpf objects after xdp attach error: %s", closeErr) + } tf.link = nil - return err + return fmt.Errorf("attach xdp: %w", err) } return nil } diff --git a/client/internal/ebpf/ebpf/src/dns_fwd.c b/client/internal/ebpf/ebpf/src/dns_fwd.c index 5f3fbcc32..9f8de2001 100644 --- a/client/internal/ebpf/ebpf/src/dns_fwd.c +++ b/client/internal/ebpf/ebpf/src/dns_fwd.c @@ -52,11 +52,14 @@ int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) { if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) { udp->dest = dns_port; + // Clear the now-stale checksum; zero means "not computed" for IPv4. + udp->check = 0; return XDP_PASS; } if (udp->source == dns_port && ip->saddr == dns_ip) { udp->source = GENERAL_DNS_PORT; + udp->check = 0; return XDP_PASS; } diff --git a/client/internal/ebpf/ebpf/src/wg_proxy.c b/client/internal/ebpf/ebpf/src/wg_proxy.c index 88fea65cf..5e7474928 100644 --- a/client/internal/ebpf/ebpf/src/wg_proxy.c +++ b/client/internal/ebpf/ebpf/src/wg_proxy.c @@ -50,5 +50,11 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) { __be16 new_dst_port = htons(proxy_port); udp->dest = new_dst_port; udp->source = new_src_port; + + // The ports are covered by the UDP checksum. This is an IPv4 loopback hop + // and the payload is already integrity-protected, so clear the checksum (a + // zero UDP checksum means "not computed" for IPv4) rather than leave a + // stale value the kernel would drop as UDP_CSUM. + udp->check = 0; return XDP_PASS; } diff --git a/client/internal/engine.go b/client/internal/engine.go index 42712da92..7f3f8185f 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -23,6 +23,7 @@ import ( "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "github.com/netbirdio/netbird/client/anonymize" nberrors "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/client/firewall" "github.com/netbirdio/netbird/client/firewall/firewalld" @@ -40,6 +41,7 @@ import ( "github.com/netbirdio/netbird/client/internal/dnsfwd" "github.com/netbirdio/netbird/client/internal/expose" "github.com/netbirdio/netbird/client/internal/ingressgw" + "github.com/netbirdio/netbird/client/internal/lazyconn" "github.com/netbirdio/netbird/client/internal/metrics" "github.com/netbirdio/netbird/client/internal/netflow" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" @@ -57,13 +59,17 @@ import ( "github.com/netbirdio/netbird/client/internal/syncstore" "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/jobexec" + "github.com/netbirdio/netbird/client/netstate" cProto "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/system" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/route" mgm "github.com/netbirdio/netbird/shared/management/client" "github.com/netbirdio/netbird/shared/management/domain" + sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc" + nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap" mgmProto "github.com/netbirdio/netbird/shared/management/proto" + types "github.com/netbirdio/netbird/shared/management/types" "github.com/netbirdio/netbird/shared/netiputil" auth "github.com/netbirdio/netbird/shared/relay/auth/hmac" relayClient "github.com/netbirdio/netbird/shared/relay/client" @@ -82,10 +88,18 @@ const ( PeerConnectionTimeoutMax = 45000 // ms PeerConnectionTimeoutMin = 30000 // ms disableAutoUpdate = "disabled" + + // systemInfoTimeout bounds how long the sync loop waits for system info / posture + // check gathering. The gathering runs uncancellable system calls (process scan, + // exec, os.Stat); without this bound a single stuck call freezes handleSync, and + // thus syncMsgMux, for as long as the call hangs (observed multi-minute freezes). + systemInfoTimeout = 15 * time.Second ) var ErrResetConnection = fmt.Errorf("reset connection") +var ErrEngineAlreadyStarted = errors.New("engine already started") + type EngineConfig struct { WgPort int WgIfaceName string @@ -138,8 +152,11 @@ type EngineConfig struct { BlockLANAccess bool BlockInbound bool DisableIPv6 bool + SyncMessageVersion *int - LazyConnectionEnabled bool + // LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to + // the env var and management feature flag. + LazyConnection lazyconn.State MTU uint16 @@ -164,6 +181,10 @@ type EngineServices struct { StateManager *statemanager.Manager UpdateManager *updater.Manager ClientMetrics *metrics.ClientMetrics + MetricsCtx context.Context + // NetState gates the reconnection loops on OS-reported network + // availability; nil disables gating. + NetState *netstate.State } // Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers. @@ -187,6 +208,10 @@ type Engine struct { config *EngineConfig mobileDep MobileDependency + // netState gates the peer reconnection guards on OS-reported network + // availability; nil disables gating. + netState *netstate.State + // STUNs is a list of STUN servers used by ICE STUNs []*stun.URI // TURNs is a list of STUN servers used by ICE @@ -199,6 +224,8 @@ type Engine struct { ctx context.Context cancel context.CancelFunc + started bool + wgInterface WGIface udpMux *udpmux.UniversalUDPMuxDefault @@ -206,6 +233,13 @@ type Engine struct { // networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service networkSerial uint64 + // latestComponents is the most-recent NetworkMapComponents decoded from + // a NetworkMapEnvelope (capability=3 peers only). Held alongside the + // NetworkMap that Calculate() produced from it so future incremental + // updates have a base to apply changes against. nil for legacy-format + // peers. Guarded by syncMsgMux. + latestComponents *types.NetworkMapComponents + networkMonitor *networkmonitor.NetworkMonitor sshServer sshServer @@ -254,11 +288,26 @@ type Engine struct { // clientMetrics collects and pushes metrics clientMetrics *metrics.ClientMetrics + metricsCtx context.Context jobExecutor *jobexec.Executor jobExecutorWG sync.WaitGroup exposeManager *expose.Manager + + sessionWatcher sessionDeadlineWatcher +} + +// sessionDeadlineWatcher is the engine-facing surface of the SSO session +// expiry watcher. The concrete implementation (sessionwatch.Watcher) is wired +// in via newSessionWatcher, which is build-tagged so the js/wasm build links a +// no-op stub instead of pulling the full sessionwatch package (and its timer +// machinery) into the binary — the wasm client never runs the engine's +// session-warning flow. +type sessionDeadlineWatcher interface { + Update(deadline time.Time) error + Dismiss() + Close() } // Peer is an instance of the Connection Peer @@ -279,9 +328,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, @@ -290,6 +345,7 @@ func NewEngine( syncMsgMux: &sync.Mutex{}, config: config, mobileDep: mobileDep, + netState: services.NetState, STUNs: []*stun.URI{}, TURNs: []*stun.URI{}, networkSerial: 0, @@ -300,9 +356,21 @@ func NewEngine( probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL), jobExecutor: jobexec.NewExecutor(), clientMetrics: services.ClientMetrics, + metricsCtx: services.MetricsCtx, updateManager: services.UpdateManager, syncStoreDir: config.StateDir, } + // sessionWatcher keeps the SubscribeStatus consumers in sync with the + // session expiry deadline. Deadline-change ticks come for free via + // Status.SetSessionExpiresAt; the watcher exists to push a wake-up at + // T-WarningLead and T-FinalWarningLead so the UI repaints the remaining + // time / warning state even when nothing else changed, and to publish + // two SystemEvents (the warning composition lives in sessionwatch so + // the wire format stays owned by one package): + // - T-WarningLead → interactive "Extend now / Dismiss" notification + // - T-FinalWarningLead → auto-opened SessionAboutToExpire dialog, + // suppressed when the user dismissed the earlier warning + engine.sessionWatcher = newSessionWatcher(engine.statusRecorder) log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String()) return engine @@ -314,8 +382,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() } @@ -343,6 +437,10 @@ func (e *Engine) Stop() error { e.srWatcher.Close() } + if e.sessionWatcher != nil { + e.sessionWatcher.Close() + } + if e.updateManager != nil { e.updateManager.SetDownloadOnly() } @@ -366,10 +464,6 @@ func (e *Engine) Stop() error { // so dbus and friends don't complain because of a missing interface e.stopDNSServer() - if e.cancel != nil { - e.cancel() - } - e.jobExecutorWG.Wait() // block until job goroutines finish e.close() @@ -388,21 +482,6 @@ func (e *Engine) Stop() error { if err := e.stateManager.PersistState(context.Background()); err != nil { log.Errorf("failed to persist state: %v", err) } - - e.syncMsgMux.Unlock() - - timeout := e.calculateShutdownTimeout() - log.Debugf("waiting for goroutines to finish with timeout: %v", timeout) - shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - if err := waitWithContext(shutdownCtx, &e.shutdownWg); err != nil { - log.Warnf("shutdown timeout exceeded after %v, some goroutines may still be running", timeout) - } - - log.Infof("stopped Netbird Engine") - - return nil } // calculateShutdownTimeout returns shutdown timeout: 10s base + 100ms per peer, capped at 30s. @@ -440,18 +519,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() @@ -473,7 +572,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) } else { log.Infof("running rosenpass in strict mode") } - e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName) + e.rpManager, err = rosenpass.NewManager(e.config.PreSharedKey, e.config.WgIfaceName, publicKey) if err != nil { return fmt.Errorf("create rosenpass manager: %w", err) } @@ -483,15 +582,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) } e.stateManager.Start() - initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings() + dnsServer, err := e.newDnsServer() 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 @@ -508,10 +600,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) WGInterface: e.wgInterface, StatusRecorder: e.statusRecorder, RelayManager: e.relayManager, - InitialRoutes: initialRoutes, StateManager: e.stateManager, DNSServer: dnsServer, - DNSFeatureFlag: dnsFeatureFlag, PeerStore: e.peerStore, DisableClientRoutes: e.config.DisableClientRoutes, DisableServerRoutes: e.config.DisableServerRoutes, @@ -526,7 +616,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) if err = e.wgInterfaceCreate(); err != nil { log.Errorf("failed creating tunnel interface %s: [%s]", e.config.WgIfaceName, err.Error()) - e.close() return fmt.Errorf("create wg interface: %w", err) } @@ -535,7 +624,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) } if err := e.createFirewall(); err != nil { - e.close() return err } @@ -547,7 +635,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) e.udpMux, err = e.wgInterface.Up() if err != nil { log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error()) - e.close() return fmt.Errorf("up wg interface: %w", err) } @@ -572,21 +659,37 @@ 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) } iceCfg := e.createICEConfig() e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface) + e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error { + if e.routeManager == nil { + return nil + } + return e.routeManager.ReconcilePeerAllowedIPs(peerKey) + }) e.connMgr.Start(e.ctx) + // Wire DNS-time lazy-connection warm-up now that the connection manager + // exists (it does not at DNS-server construction time). A DNS answer that + // points at an idle peer then wakes it before the client's first request. + e.dnsServer.SetPeerActivator(&dnsPeerActivator{ + connMgr: e.connMgr, + peerStore: e.peerStore, + status: e.statusRecorder, + ctx: e.ctx, + }) + e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg) e.srWatcher.Start(peer.IsForceRelayed()) - e.receiveSignalEvents() + if err = e.receiveSignalEvents(); err != nil { + return err + } e.receiveManagementEvents() e.receiveJobEvents() @@ -638,7 +741,6 @@ func (e *Engine) createFirewall() error { func (e *Engine) initFirewall() error { if err := e.routeManager.SetFirewall(e.firewall); err != nil { - e.close() return fmt.Errorf("set firewall: %w", err) } @@ -864,6 +966,16 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate e.updateManager.SetVersion(autoUpdateSettings.Version, autoUpdateSettings.AlwaysUpdate) } +// phase times a sync sub-phase: it returns a function that records the elapsed +// duration when called. Starting the timer at the call site keeps inter-phase +// glue code out of the measurement. +func (e *Engine) phase(name string) func() { + start := time.Now() + return func() { + e.clientMetrics.RecordSyncPhase(e.ctx, name, time.Since(start)) + } +} + func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { started := time.Now() defer func() { @@ -879,29 +991,86 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return e.ctx.Err() } - if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil { - e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate) + e.ApplySessionDeadline(update.GetSessionExpiresAt()) + + // Envelope sync responses carry PeerConfig at the top level; legacy + // NetworkMap syncs carry it under NetworkMap.PeerConfig. + if pc := update.GetPeerConfig(); pc != nil { + e.handleAutoUpdateVersion(pc.GetAutoUpdate()) + } else if nm := update.GetNetworkMap(); nm != nil && nm.GetPeerConfig() != nil { + e.handleAutoUpdateVersion(nm.GetPeerConfig().GetAutoUpdate()) } - if err := e.updateNetbirdConfig(update.GetNetbirdConfig()); err != nil { + done := e.phase("netbird_config") + err := e.updateNetbirdConfig(update.GetNetbirdConfig()) + done() + if err != nil { return err } + // Decode the network map from either the components envelope or the + // legacy proto.NetworkMap before the posture-check gating below, so the + // "is there a network map" decision covers both wire shapes. + var ( + nm *mgmProto.NetworkMap + components *types.NetworkMapComponents + ) + if version := update.GetVersion(); version == int32(sharedgrpc.ComponentNetworkMap) { + // Components-format peer: decode the envelope back to typed + // components, run Calculate() locally, and convert to the wire + // NetworkMap shape the rest of the engine consumes. Components are + // retained so future incremental updates can apply deltas instead + // of doing a full reconstruction. + envelope := update.GetNetworkMapEnvelope() + if envelope == nil { + return fmt.Errorf("received a SyncReponse indicating use of components network map, but components are missing") + } + + localKey := e.config.WgPrivateKey.PublicKey().String() + dnsName := "" + if pc := update.GetPeerConfig(); pc != nil { + // PeerConfig.Fqdn = "." — extract the + // shared domain by stripping the peer's own label prefix. Falls + // back to empty if the FQDN doesn't have the expected shape. + dnsName = extractDNSDomainFromFQDN(pc.GetFqdn()) + } + result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName) + if err != nil { + return fmt.Errorf("decode network map envelope: %w", err) + } + nm = result.NetworkMap + components = result.Components + } else { + nm = update.GetNetworkMap() + } + // Posture checks are bound to the network map presence: // NetworkMap != nil, checks present -> apply the received checks // NetworkMap != nil, checks nil -> posture checks were removed, clear them // NetworkMap == nil -> config-only update (e.g. relay token rotation), // leave the previously applied checks untouched - nm := update.GetNetworkMap() if nm == nil { return nil } - if err := e.updateChecksIfNew(update.Checks); err != nil { + done = e.phase("checks") + err = e.updateChecksIfNew(update.Checks) + done() + if err != nil { return err } + done = e.phase("persist") + // Only retain the components view when the server sent the envelope + // path. A legacy proto.NetworkMap means components == nil; writing it + // here would clobber a previously-cached snapshot, breaking the + // incremental-delta base on a future envelope sync. + if components != nil { + e.latestComponents = components + } + e.persistSyncResponse(update) + done() // only apply new changes and ignore old ones if err := e.updateNetworkMap(nm); err != nil { @@ -913,6 +1082,19 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return nil } +// extractDNSDomainFromFQDN returns the trailing dotted domain part of the +// receiving peer's FQDN — the same value the management server fills as +// dnsName when it builds the legacy NetworkMap. "peer42.netbird.cloud" → +// "netbird.cloud". An empty string is returned for unrecognized formats. +func extractDNSDomainFromFQDN(fqdn string) string { + for i := 0; i < len(fqdn); i++ { + if fqdn[i] == '.' && i+1 < len(fqdn) { + return fqdn[i+1:] + } + } + return "" +} + // updateNetbirdConfig applies the management-provided NetBird configuration: // STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op, // which is the case for sync updates carrying only a network map. @@ -942,6 +1124,8 @@ func (e *Engine) updateNetbirdConfig(wCfg *mgmProto.NetbirdConfig) error { return fmt.Errorf("handle the flow configuration: %w", err) } + e.handleMetricsUpdate(wCfg.GetMetrics()) + if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil { log.Warnf("Failed to update DNS server config: %v", err) } @@ -1011,6 +1195,14 @@ func (e *Engine) handleFlowUpdate(config *mgmProto.FlowConfig) error { return e.flowManager.Update(flowConfig) } +func (e *Engine) handleMetricsUpdate(config *mgmProto.MetricsConfig) { + if config == nil { + return + } + log.Infof("received metrics configuration from management: enabled=%v", config.GetEnabled()) + e.clientMetrics.UpdatePushFromMgm(e.metricsCtx, config.GetEnabled()) +} + func toFlowLoggerConfig(config *mgmProto.FlowConfig) (*nftypes.FlowConfig, error) { if config.GetInterval() == nil { return nil, errors.New("flow interval is nil") @@ -1035,11 +1227,22 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { } e.checks = checks - info, err := system.GetInfoWithChecks(e.ctx, checks) - if err != nil { - log.Warnf("failed to get system info with checks: %v", err) - info = system.GetInfo(e.ctx) + info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...) + if !ok { + // Gathering timed out; skip the meta sync this cycle rather than blocking the + // sync loop (and syncMsgMux) on a stuck system call. A later sync will retry. + return nil } + e.applyInfoFlags(info) + + if err := e.mgmClient.SyncMeta(info); err != nil { + return fmt.Errorf("could not sync meta: error %s", err) + } + return nil +} + +// applyInfoFlags sets the engine's config-derived feature flags on the gathered system info. +func (e *Engine) applyInfoFlags(info *system.Info) { info.SetFlags( e.config.RosenpassEnabled, e.config.RosenpassPermissive, @@ -1051,19 +1254,27 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { e.config.BlockLANAccess, e.config.BlockInbound, e.config.DisableIPv6, - e.config.LazyConnectionEnabled, + e.config.SyncMessageVersion, e.config.EnableSSHRoot, e.config.EnableSSHSFTP, e.config.EnableSSHLocalPortForwarding, e.config.EnableSSHRemotePortForwarding, e.config.DisableSSHAuth, ) +} - if err := e.mgmClient.SyncMeta(info); err != nil { - log.Errorf("could not sync meta: error %s", err) - return err +// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it +// can be excluded from the reported network addresses; the interface coming and +// going otherwise churns the peer meta on the management server. +func (e *Engine) overlayAddresses() []netip.Addr { + var ips []netip.Addr + if e.config.WgAddr.IP.IsValid() { + ips = append(ips, e.config.WgAddr.IP) } - return nil + if e.config.WgAddr.HasIPv6() { + ips = append(ips, e.config.WgAddr.IPv6) + } + return ips } func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { @@ -1178,12 +1389,13 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR ClientMetrics: e.clientMetrics, DaemonVersion: version.NetbirdVersion(), RefreshStatus: func() { - e.RunHealthProbes(true) + e.RunHealthProbes(e.ctx, true) }, } bundleJobParams := debug.BundleConfig{ Anonymize: params.Anonymize, + AnonymizeLevel: anonymize.ParseLevel(params.AnonymizeLevel), IncludeSystemInfo: true, LogFileCount: uint32(params.LogFileCount), } @@ -1209,31 +1421,15 @@ func (e *Engine) receiveManagementEvents() { e.shutdownWg.Add(1) go func() { defer e.shutdownWg.Done() - info, err := system.GetInfoWithChecks(e.ctx, e.checks) - if err != nil { - log.Warnf("failed to get system info with checks: %v", err) + info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...) + if !ok { + // Gathering timed out; connect the stream with base info so management + // connectivity still comes up rather than blocking here. info = system.GetInfo(e.ctx) } - info.SetFlags( - e.config.RosenpassEnabled, - e.config.RosenpassPermissive, - &e.config.ServerSSHAllowed, - e.config.DisableClientRoutes, - e.config.DisableServerRoutes, - e.config.DisableDNS, - e.config.DisableFirewall, - e.config.BlockLANAccess, - e.config.BlockInbound, - e.config.DisableIPv6, - e.config.LazyConnectionEnabled, - e.config.EnableSSHRoot, - e.config.EnableSSHSFTP, - e.config.EnableSSHLocalPortForwarding, - e.config.EnableSSHRemotePortForwarding, - e.config.DisableSSHAuth, - ) + e.applyInfoFlags(info) - err = e.mgmClient.Sync(e.ctx, info, e.handleSync) + err := e.mgmClient.Sync(e.ctx, info, e.handleSync) if err != nil { // happens if management is unavailable for a long time. // We want to cancel the operation of the whole client @@ -1326,13 +1522,16 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { dnsConfig := toDNSConfig(protoDNSConfig, e.wgInterface.Address()) + done := e.phase("dns_server") if err := e.dnsServer.UpdateDNSServer(serial, dnsConfig); err != nil { log.Errorf("failed to update dns server, err: %v", err) } + done() e.routeManager.SetDNSForwarderPort(dnsConfig.ForwarderPort) // apply routes first, route related actions might depend on routing being enabled + done = e.phase("routes_classify") routes := toRoutes(networkMap.GetRoutes()) serverRoutes, clientRoutes := e.routeManager.ClassifyRoutes(routes) @@ -1341,29 +1540,60 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { e.connMgr.UpdateRouteHAMap(clientRoutes) log.Debugf("updated lazy connection manager with %d HA groups", len(clientRoutes)) } + done() + done = e.phase("routes_apply") dnsRouteFeatureFlag := toDNSFeatureFlag(networkMap) if err := e.routeManager.UpdateRoutes(serial, serverRoutes, clientRoutes, dnsRouteFeatureFlag); err != nil { log.Errorf("failed to update routes: %v", err) } + done() + done = e.phase("filtering") if e.acl != nil { e.acl.ApplyFiltering(networkMap, dnsRouteFeatureFlag) } + done() + done = e.phase("dns_forwarder") fwdEntries := toRouteDomains(e.config.WgPrivateKey.PublicKey().String(), routes) e.updateDNSForwarder(dnsRouteFeatureFlag, fwdEntries) + done() // Ingress forward rules + done = e.phase("forward_rules") forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules()) if err != nil { log.Errorf("failed to update forward rules, err: %v", err) } + done() log.Debugf("got peers update from Management Service, total peers to connect to = %d", len(networkMap.GetRemotePeers())) + done = e.phase("offline_peers") e.updateOfflinePeers(networkMap.GetOfflinePeers()) + done() + remotePeers, err := e.reconcilePeers(networkMap) + if err != nil { + return err + } + + // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store + done = e.phase("lazy_exclude") + excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers) + e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers) + done() + + e.networkSerial = serial + + return nil +} + +// reconcilePeers applies the remote peer list from the network map (removing, +// modifying and adding peers, then updating SSH config) and returns the remote +// peers with our own peer filtered out, for use by later sync steps. +func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap) ([]*mgmProto.RemotePeerConfig, error) { // Filter out own peer from the remote peers list localPubKey := e.config.WgPrivateKey.PublicKey().String() remotePeers := make([]*mgmProto.RemotePeerConfig, 0, len(networkMap.GetRemotePeers())) @@ -1378,42 +1608,43 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { err := e.removeAllPeers() e.statusRecorder.FinishPeerListModifications() if err != nil { - return err + return nil, err } - } else { - err := e.removePeers(remotePeers) - if err != nil { - return err - } - - err = e.modifyPeers(remotePeers) - if err != nil { - return err - } - - err = e.addNewPeers(remotePeers) - if err != nil { - return err - } - - e.statusRecorder.FinishPeerListModifications() - - e.updatePeerSSHHostKeys(remotePeers) - - if err := e.updateSSHClientConfig(remotePeers); err != nil { - log.Warnf("failed to update SSH client config: %v", err) - } - - e.updateSSHServerAuth(networkMap.GetSshAuth()) + return remotePeers, nil } - // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store - excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers) - e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers) + done := e.phase("removed_peers") + err := e.removePeers(remotePeers) + done() + if err != nil { + return nil, err + } - e.networkSerial = serial + done = e.phase("modified_peers") + err = e.modifyPeers(remotePeers) + done() + if err != nil { + return nil, err + } - return nil + done = e.phase("added_peers") + err = e.addNewPeers(remotePeers) + done() + if err != nil { + return nil, err + } + + e.statusRecorder.FinishPeerListModifications() + + e.updatePeerSSHHostKeys(remotePeers) + + if err := e.updateSSHClientConfig(remotePeers); err != nil { + log.Warnf("failed to update SSH client config: %v", err) + } + + e.updateSSHServerAuth(networkMap.GetSshAuth()) + + return remotePeers, nil } func toDNSFeatureFlag(networkMap *mgmProto.NetworkMap) bool { @@ -1671,7 +1902,8 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV Addr: e.getRosenpassAddr(), PermissiveMode: e.config.RosenpassPermissive, }, - ICEConfig: e.createICEConfig(), + ICEConfig: e.createICEConfig(), + NetworkState: e.netState, } serviceDependencies := peer.ServiceDependencies{ @@ -1698,7 +1930,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() @@ -1769,7 +2001,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 { @@ -1870,42 +2107,6 @@ func (e *Engine) close() { } } -func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) { - if runtime.GOOS != "android" { - // nolint:nilnil - return nil, nil, false, nil - } - - info := system.GetInfo(e.ctx) - info.SetFlags( - e.config.RosenpassEnabled, - e.config.RosenpassPermissive, - &e.config.ServerSSHAllowed, - e.config.DisableClientRoutes, - e.config.DisableServerRoutes, - e.config.DisableDNS, - e.config.DisableFirewall, - e.config.BlockLANAccess, - e.config.BlockInbound, - e.config.DisableIPv6, - e.config.LazyConnectionEnabled, - e.config.EnableSSHRoot, - e.config.EnableSSHSFTP, - e.config.EnableSSHLocalPortForwarding, - e.config.EnableSSHRemotePortForwarding, - e.config.DisableSSHAuth, - ) - - netMap, err := e.mgmClient.GetNetworkMap(info) - if err != nil { - return nil, nil, false, err - } - routes := toRoutes(netMap.GetRoutes()) - dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address()) - dnsFeatureFlag := toDNSFeatureFlag(netMap) - return routes, &dnsCfg, dnsFeatureFlag, nil -} - func (e *Engine) newWgIface() (*iface.WGIface, error) { transportNet, err := e.newStdNet() if err != nil { @@ -1940,7 +2141,7 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) { func (e *Engine) wgInterfaceCreate() (err error) { switch runtime.GOOS { case "android": - err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains()) + err = e.wgInterface.CreateOnAndroid(e.routeManager.CurrentRouteRange(), e.dnsServer.DnsIP().String(), e.dnsServer.SearchDomains()) case "ios": e.mobileDep.NetworkChangeListener.SetInterfaceIP(e.config.WgAddr.String()) if e.config.WgAddr.HasIPv6() { @@ -1953,7 +2154,7 @@ func (e *Engine) wgInterfaceCreate() (err error) { return err } -func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) { +func (e *Engine) newDnsServer() (dns.Server, error) { // due to tests where we are using a mocked version of the DNS server if e.dnsServer != nil { return e.dnsServer, nil @@ -1965,7 +2166,7 @@ func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) { e.ctx, e.wgInterface, e.mobileDep.HostDNSAddresses, - *dnsConfig, + nbdns.Config{}, e.mobileDep.NetworkChangeListener, e.statusRecorder, e.config.DisableDNS, @@ -2081,7 +2282,20 @@ func (e *Engine) getRosenpassAddr() string { // RunHealthProbes executes health checks for Signal, Management, Relay, and WireGuard services // and updates the status recorder with the latest states. -func (e *Engine) RunHealthProbes(waitForResult bool) bool { +// +// ctx scopes the (potentially slow) STUN/TURN probing: a caller that gives up — +// e.g. a Status RPC whose client disconnected — cancels its ctx and the probe +// returns instead of running to its per-component timeout. The engine's own +// lifetime ctx still applies independently, so an engine shutdown aborts the +// probe even if the caller's ctx is context.Background(). +func (e *Engine) RunHealthProbes(ctx context.Context, waitForResult bool) bool { + // Tie the caller's ctx to the engine lifetime: either cancelling aborts + // the probe below. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + stop := context.AfterFunc(e.ctx, cancel) + defer stop() + e.syncMsgMux.Lock() signalHealthy := e.signal.IsHealthy() @@ -2104,9 +2318,9 @@ func (e *Engine) RunHealthProbes(waitForResult bool) bool { if runtime.GOOS != "js" { var results []relay.ProbeResult if waitForResult { - results = e.probeStunTurn.ProbeAllWaitResult(e.ctx, stuns, turns) + results = e.probeStunTurn.ProbeAllWaitResult(ctx, stuns, turns) } else { - results = e.probeStunTurn.ProbeAll(e.ctx, stuns, turns) + results = e.probeStunTurn.ProbeAll(ctx, stuns, turns) } e.statusRecorder.UpdateRelayStates(results) @@ -2358,7 +2572,7 @@ func (e *Engine) SetCapture(pc device.PacketCapture) error { } afc := capture.NewAFPacketCapture(intf.Name(), sess) - if err := afc.Start(); err != nil { + if err := afc.Start(); err != nil { //nolint:staticcheck // always errors on non-Linux builds return fmt.Errorf("start AF_PACKET capture on %s: %w", intf.Name(), err) } e.afpacketCapture = afc @@ -2449,13 +2663,14 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool { excludedPeers := make(map[string]bool) + + // Ingress forward targets: inbound forwarded traffic is initiated remotely and + // cannot wake a lazy connection, so the peer routing the target must stay + // permanently connected. AllowedIPs are already parsed on the peer conn, so + // reuse those typed prefixes instead of re-parsing the network map strings. for _, r := range rules { - ip := r.TranslatedAddress for _, p := range peers { - for _, allowedIP := range p.GetAllowedIps() { - if allowedIP != ip.String() { - continue - } + if e.peerRoutesAddr(p, r.TranslatedAddress) { log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey()) excludedPeers[p.GetWgPubKey()] = true } @@ -2465,6 +2680,27 @@ func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers return excludedPeers } +// peerRoutesAddr reports whether the peer is a router for addr, matched against +// the peer's already-parsed AllowedIPs from the store (the same typed value the +// lazy manager consumes) rather than re-parsing the network map strings. +func (e *Engine) peerRoutesAddr(p *mgmProto.RemotePeerConfig, addr netip.Addr) bool { + prefixes, ok := e.peerStore.AllowedIPs(p.GetWgPubKey()) + if !ok { + return false + } + return prefixesContain(prefixes, addr) +} + +// prefixesContain reports whether addr falls within any of the prefixes. +func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool { + for _, prefix := range prefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} + // isChecksEqual checks if two slices of checks are equal. func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool { normalize := func(checks []*mgmProto.Checks) []string { diff --git a/client/internal/engine_authsession.go b/client/internal/engine_authsession.go new file mode 100644 index 000000000..725c0903f --- /dev/null +++ b/client/internal/engine_authsession.go @@ -0,0 +1,108 @@ +package internal + +import ( + "context" + "errors" + "fmt" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + cProto "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/system" +) + +// ApplySessionDeadline propagates the absolute SSO session deadline carried on +// LoginResponse / SyncResponse to both the watcher (for the edge-triggered +// warning) and the status recorder (for the SubscribeStatus / Status RPC +// snapshot the UI consumes). +// +// The wire field is 3-state: +// - nil → snapshot carries no info; keep the +// previously-anchored deadline (no-op) +// - explicit zero (s=0, n=0) → peer is not SSO-registered or expiry is +// disabled; clear both sinks +// - valid timestamp → new deadline; arm watcher, expose on +// status recorder +// +// Deadline sanity-checks live in sessionwatch.Watcher.Update. Any rejected +// value is treated as a clear on both sinks: the alternative — leaving the +// previously-known deadline in place — risks the UI confidently displaying +// a stale "expires in X" while the server has actually invalidated it. +func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) { + if ts == nil { + return + } + var deadline time.Time + // Explicit zero (seconds=0 AND nanos=0) is the sentinel for "disabled". + // Everything else flows through Watcher.Update, whose sanity-checks + // reject out-of-range / pre-epoch / far-future / too-stale values and + // clear on rejection. + if ts.GetSeconds() != 0 || ts.GetNanos() != 0 { + deadline = ts.AsTime().UTC() + } + if e.sessionWatcher == nil { + return + } + // Watcher.Update owns the propagation to the status recorder (the + // SubscribeStatus / Status snapshot the UI reads): a set writes the + // deadline, a clear or a sanity-check rejection writes the zero value. + // Keeping a single writer is what stops the recorder from drifting out + // of sync with the warning timers. + if err := e.sessionWatcher.Update(deadline); err != nil { + log.Errorf("auth session deadline rejected: %v, clearing", err) + e.statusRecorder.PublishEvent( + cProto.SystemEvent_ERROR, + cProto.SystemEvent_AUTHENTICATION, + "session deadline rejected", + "", + map[string]string{sessionwatch.MetaSessionDeadlineRejected: err.Error()}, + ) + } +} + +// DismissSessionWarning records the user's "Dismiss" click on the +// T-WarningLead interactive notification and suppresses the upcoming +// T-FinalWarningLead fallback for the current deadline. No-op when the +// watcher is not running or holds no deadline. +func (e *Engine) DismissSessionWarning() { + if e.sessionWatcher == nil { + return + } + e.sessionWatcher.Dismiss() +} + +// ExtendAuthSession asks the management server to refresh the SSO session +// expiry deadline using the supplied JWT, then mirrors the new deadline into +// the daemon's state. The tunnel is untouched; no resync, no reconnect. +// +// Returns the new absolute UTC deadline (or zero time when the server +// reports the peer is not eligible for extension). +func (e *Engine) ExtendAuthSession(ctx context.Context, jwtToken string) (time.Time, error) { + if jwtToken == "" { + return time.Time{}, errors.New("jwt token is required") + } + if e.mgmClient == nil { + return time.Time{}, errors.New("management client is not initialised") + } + + info, err := system.GetInfoWithChecks(ctx, e.checks) + if err != nil { + log.Warnf("failed to collect system info for session extend: %v", err) + info = system.GetInfo(ctx) + } + + resp, err := e.mgmClient.ExtendAuthSession(info, jwtToken) + if err != nil { + return time.Time{}, fmt.Errorf("extend auth session on management: %w", err) + } + + e.ApplySessionDeadline(resp.GetSessionExpiresAt()) + + if resp.GetSessionExpiresAt().IsValid() { + return resp.GetSessionExpiresAt().AsTime().UTC(), nil + } + return time.Time{}, nil +} diff --git a/client/internal/engine_lazy_exclude_test.go b/client/internal/engine_lazy_exclude_test.go new file mode 100644 index 000000000..b5ef16c3b --- /dev/null +++ b/client/internal/engine_lazy_exclude_test.go @@ -0,0 +1,87 @@ +package internal + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + + firewallManager "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestPrefixesContain(t *testing.T) { + tests := []struct { + name string + prefixes []string + addr string + want bool + }{ + {name: "own overlay /32 matches", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.145", want: true}, + {name: "addr inside routed subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.121.208.4", want: true}, + {name: "addr outside subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.122.0.1", want: false}, + {name: "different /32", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.146", want: false}, + {name: "ipv6 /128 matches", prefixes: []string{"fd00::1/128"}, addr: "fd00::1", want: true}, + {name: "no prefixes", prefixes: nil, addr: "10.121.208.4", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prefixes := make([]netip.Prefix, 0, len(tt.prefixes)) + for _, p := range tt.prefixes { + prefixes = append(prefixes, netip.MustParsePrefix(p)) + } + require.Equal(t, tt.want, prefixesContain(prefixes, netip.MustParseAddr(tt.addr))) + }) + } +} + +// TestToExcludedLazyPeers_ForwardTarget guards a regression: the forward-target +// peer (the peer routing a ForwardRule.TranslatedAddress) must be excluded from +// lazy connections, matched via the peer's already-parsed AllowedIPs. +func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) { + const targetPeerKey = "cccccccccccccccccccccccccccccccccccccccccc0=" + const otherPeerKey = "dddddddddddddddddddddddddddddddddddddddddd0=" + + store := peerstore.NewConnStore() + store.AddPeerConn(targetPeerKey, newTestConn(t, targetPeerKey, "100.110.8.145/32")) + store.AddPeerConn(otherPeerKey, newTestConn(t, otherPeerKey, "100.110.9.10/32")) + + e := &Engine{peerStore: store} + + peers := []*mgmProto.RemotePeerConfig{ + {WgPubKey: targetPeerKey, AllowedIps: []string{"100.110.8.145/32"}}, + {WgPubKey: otherPeerKey, AllowedIps: []string{"100.110.9.10/32"}}, + } + rules := []firewallManager.ForwardRule{ + {TranslatedAddress: netip.MustParseAddr("100.110.8.145")}, + } + + excluded := e.toExcludedLazyPeers(rules, peers) + + require.True(t, excluded[targetPeerKey], "forward-target peer must be excluded from lazy connections") + require.False(t, excluded[otherPeerKey], "non-target peer must not be excluded") + require.Len(t, excluded, 1) +} + +func TestToExcludedLazyPeers_NoRules(t *testing.T) { + e := &Engine{peerStore: peerstore.NewConnStore()} + + peers := []*mgmProto.RemotePeerConfig{ + {WgPubKey: "peer-a", AllowedIps: []string{"100.110.8.145/32"}}, + } + + require.Empty(t, e.toExcludedLazyPeers(nil, peers)) +} + +func newTestConn(t *testing.T, key, allowedIP string) *peer.Conn { + t.Helper() + conn, err := peer.NewConn(peer.ConnConfig{ + Key: key, + WgConfig: peer.WgConfig{AllowedIps: []netip.Prefix{netip.MustParsePrefix(allowedIP)}}, + }, peer.ServiceDependencies{}) + require.NoError(t, err) + return conn +} diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go new file mode 100644 index 000000000..032992464 --- /dev/null +++ b/client/internal/engine_privileged_test.go @@ -0,0 +1,565 @@ +//go:build privileged + +package internal + +import ( + "context" + "fmt" + "net" + "runtime" + "strings" + "sync" + "testing" + "time" + + "go.uber.org/mock/gomock" + "github.com/google/uuid" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc" + "google.golang.org/grpc/keepalive" + + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/peer" + nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/client/system" + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" + "github.com/netbirdio/netbird/management/internals/server/config" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/activity" + nbcache "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/server/groups" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" + "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/job" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/management/server/types" + mgmt "github.com/netbirdio/netbird/shared/management/client" + mgmtProto "github.com/netbirdio/netbird/shared/management/proto" + relayClient "github.com/netbirdio/netbird/shared/relay/client" + signal "github.com/netbirdio/netbird/shared/signal/client" + "github.com/netbirdio/netbird/shared/signal/proto" + signalServer "github.com/netbirdio/netbird/signal/server" + "github.com/netbirdio/netbird/util" +) + +func TestEngine_SSH(t *testing.T) { + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + return + } + + sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + if err != nil { + t.Fatal(err) + return + } + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + engine := NewEngine( + ctx, cancel, + &EngineConfig{ + WgIfaceName: "utun101", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + ServerSSHAllowed: true, + MTU: iface.DefaultMTU, + SSHKey: sshKey, + }, + EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: &mgmt.MockClient{}, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, + MobileDependency{}, + ) + + engine.dnsServer = &dns.MockServer{ + UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil }, + } + + err = engine.Start(nil, nil) + require.NoError(t, err) + + defer func() { + err := engine.Stop() + if err != nil { + return + } + }() + + peerWithSSH := &mgmtProto.RemotePeerConfig{ + WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + AllowedIps: []string{"100.64.0.21/24"}, + SshConfig: &mgmtProto.SSHConfig{ + SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"), + }, + } + + // SSH server is not enabled so SSH config of a remote peer should be ignored + networkMap := &mgmtProto.NetworkMap{ + Serial: 6, + PeerConfig: nil, + RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, + RemotePeersIsEmpty: false, + } + + err = engine.updateNetworkMap(networkMap) + require.NoError(t, err) + + assert.Nil(t, engine.sshServer) + + // SSH server is enabled, therefore SSH config should be applied + networkMap = &mgmtProto.NetworkMap{ + Serial: 7, + PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24", + SshConfig: &mgmtProto.SSHConfig{ + SshEnabled: true, + JwtConfig: &mgmtProto.JWTConfig{ + Issuer: "test-issuer", + Audience: "test-audience", + KeysLocation: "test-keys", + MaxTokenAge: 3600, + }, + }}, + RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, + RemotePeersIsEmpty: false, + } + + err = engine.updateNetworkMap(networkMap) + require.NoError(t, err) + + time.Sleep(250 * time.Millisecond) + assert.NotNil(t, engine.sshServer) + + // now remove peer + networkMap = &mgmtProto.NetworkMap{ + Serial: 8, + RemotePeers: []*mgmtProto.RemotePeerConfig{}, + RemotePeersIsEmpty: false, + } + + err = engine.updateNetworkMap(networkMap) + require.NoError(t, err) + + // time.Sleep(250 * time.Millisecond) + assert.NotNil(t, engine.sshServer) + + // now disable SSH server + networkMap = &mgmtProto.NetworkMap{ + Serial: 9, + PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24", + SshConfig: &mgmtProto.SSHConfig{SshEnabled: false}}, + RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, + RemotePeersIsEmpty: false, + } + + err = engine.updateNetworkMap(networkMap) + require.NoError(t, err) + + assert.Nil(t, engine.sshServer) +} + +func TestEngine_Sync(t *testing.T) { + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + return + } + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + // feed updates to Engine via mocked Management client + updates := make(chan *mgmtProto.SyncResponse) + defer close(updates) + syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error { + for msg := range updates { + err := msgHandler(msg) + if err != nil { + t.Fatal(err) + } + } + return nil + } + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + engine := NewEngine(ctx, cancel, &EngineConfig{ + WgIfaceName: "utun103", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + MTU: iface.DefaultMTU, + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: &mgmt.MockClient{SyncFunc: syncFunc}, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}) + engine.ctx = ctx + + engine.dnsServer = &dns.MockServer{ + UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil }, + } + + defer func() { + err := engine.Stop() + if err != nil { + return + } + }() + + err = engine.Start(nil, nil) + if err != nil { + t.Fatal(err) + return + } + + peer1 := &mgmtProto.RemotePeerConfig{ + WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + AllowedIps: []string{"100.64.0.10/24"}, + } + peer2 := &mgmtProto.RemotePeerConfig{ + WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=", + AllowedIps: []string{"100.64.0.11/24"}, + } + peer3 := &mgmtProto.RemotePeerConfig{ + WgPubKey: "GGHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=", + AllowedIps: []string{"100.64.0.12/24"}, + } + // 1st update with just 1 peer and serial larger than the current serial of the engine => apply update + updates <- &mgmtProto.SyncResponse{ + NetworkMap: &mgmtProto.NetworkMap{ + Serial: 10, + PeerConfig: nil, + RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3}, + RemotePeersIsEmpty: false, + }, + } + + timeout := time.After(time.Second * 2) + for { + select { + case <-timeout: + t.Fatalf("timeout while waiting for test to finish") + return + default: + } + + if getPeers(engine) == 3 && engine.networkSerial == 10 { + break + } + } +} + +func TestEngine_MultiplePeers(t *testing.T) { + // log.SetLevel(log.DebugLevel) + + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) + defer cancel() + + sigServer, signalAddr, err := startSignal(t) + if err != nil { + t.Fatal(err) + return + } + defer sigServer.Stop() + mgmtServer, mgmtAddr, err := startManagement(t, t.TempDir(), "../testdata/store.sql") + if err != nil { + t.Fatal(err) + return + } + defer mgmtServer.GracefulStop() + + setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" + + mu := sync.Mutex{} + engines := []*Engine{} + numPeers := 10 + wg := sync.WaitGroup{} + wg.Add(numPeers) + // create and start peers + for i := 0; i < numPeers; i++ { + j := i + go func() { + engine, err := createEngine(ctx, cancel, setupKey, j, mgmtAddr, signalAddr) + if err != nil { + wg.Done() + t.Errorf("unable to create the engine for peer %d with error %v", j, err) + return + } + engine.dnsServer = &dns.MockServer{} + mu.Lock() + defer mu.Unlock() + guid := fmt.Sprintf("{%s}", uuid.New().String()) + device.CustomWindowsGUIDString = strings.ToLower(guid) + err = engine.Start(nil, nil) + if err != nil { + t.Errorf("unable to start engine for peer %d with error %v", j, err) + wg.Done() + return + } + engines = append(engines, engine) + wg.Done() + }() + } + + // wait until all have been created and started + wg.Wait() + if len(engines) != numPeers { + t.Fatal("not all peers were started") + } + // check whether all the peer have expected peers connected + + expectedConnected := numPeers * (numPeers - 1) + + // adjust according to timeouts + timeout := 50 * time.Second + timeoutChan := time.After(timeout) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() +loop: + for { + select { + case <-timeoutChan: + t.Fatalf("waiting for expected connections timeout after %s", timeout.String()) + break loop + case <-ticker.C: + totalConnected := 0 + for _, engine := range engines { + totalConnected += getConnectedPeers(engine) + } + if totalConnected == expectedConnected { + log.Infof("total connected=%d", totalConnected) + break loop + } + log.Infof("total connected=%d", totalConnected) + } + } + // cleanup test + for n, peerEngine := range engines { + t.Logf("stopping peer with interface %s from multipeer test, loopIndex %d", peerEngine.wgInterface.Name(), n) + errStop := peerEngine.mgmClient.Close() + if errStop != nil { + log.Infoln("got error trying to close management clients from engine: ", errStop) + } + errStop = peerEngine.Stop() + if errStop != nil { + log.Infoln("got error trying to close testing peers engine: ", errStop) + } + } +} + +var ( + kaep = keepalive.EnforcementPolicy{ + MinTime: 15 * time.Second, + PermitWithoutStream: true, + } + + kasp = keepalive.ServerParameters{ + MaxConnectionIdle: 15 * time.Second, + MaxConnectionAgeGrace: 5 * time.Second, + Time: 5 * time.Second, + Timeout: 2 * time.Second, + } +) + +func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey string, i int, mgmtAddr string, signalAddr string) (*Engine, error) { + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + return nil, err + } + mgmtClient, err := mgmt.NewClient(ctx, mgmtAddr, key, false) + if err != nil { + return nil, err + } + signalClient, err := signal.NewClient(ctx, signalAddr, key, false) + if err != nil { + return nil, err + } + + info := system.GetInfo(ctx) + resp, err := mgmtClient.Register(setupKey, "", info, nil, nil) + if err != nil { + return nil, err + } + + var ifaceName string + if runtime.GOOS == "darwin" { + ifaceName = fmt.Sprintf("utun1%d", i) + } else { + ifaceName = fmt.Sprintf("wt%d", i) + } + + wgPort := 33100 + i + conf := &EngineConfig{ + WgIfaceName: ifaceName, + WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address), + WgPrivateKey: key, + WgPort: wgPort, + MTU: iface.DefaultMTU, + } + + relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) + e, err := NewEngine(ctx, cancel, conf, EngineServices{ + SignalClient: signalClient, + MgmClient: mgmtClient, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}), nil + e.ctx = ctx + return e, err +} + +func startSignal(t *testing.T) (*grpc.Server, string, error) { + t.Helper() + + s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + log.Fatalf("failed to listen: %v", err) + } + + srv, err := signalServer.NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + proto.RegisterSignalExchangeServer(s, srv) + + go func() { + if err = s.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } + }() + + return s, lis.Addr().String(), nil +} + +func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, string, error) { + t.Helper() + + config := &config.Config{ + Stuns: []*config.Host{}, + TURNConfig: &config.TURNConfig{}, + Relay: &config.Relay{ + Addresses: []string{"127.0.0.1:1234"}, + CredentialsTTL: util.Duration{Duration: time.Hour}, + Secret: "222222222222222222", + }, + Signal: &config.Host{ + Proto: "http", + URI: "localhost:10000", + }, + Datadir: dataDir, + HttpConfig: nil, + } + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + return nil, "", err + } + s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) + + store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), testFile, config.Datadir) + if err != nil { + return nil, "", err + } + t.Cleanup(cleanUp) + + eventStore := &activity.InMemoryEventStore{} + if err != nil { + return nil, "", err + } + + permissionsManager := permissions.NewManager(store) + peersManager := peers.NewManager(store, permissionsManager) + jobManager := job.NewJobManager(nil, store, peersManager) + + cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + return nil, "", err + } + + ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore) + + metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + require.NoError(t, err) + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + settingsMockManager := settings.NewMockManager(ctrl) + settingsMockManager.EXPECT(). + GetSettings(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.Settings{}, nil). + AnyTimes() + settingsMockManager.EXPECT(). + GetExtraSettings(gomock.Any(), gomock.Any()). + Return(&types.ExtraSettings{}, nil). + AnyTimes() + + groupsManager := groups.NewManagerMock() + + updateManager := update_channel.NewPeersUpdateManager(metrics) + requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) + networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) + accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + if err != nil { + return nil, "", err + } + + secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) + if err != nil { + return nil, "", err + } + mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil) + if err != nil { + return nil, "", err + } + mgmtProto.RegisterManagementServiceServer(s, mgmtServer) + go func() { + if err = s.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } + }() + + return s, lis.Addr().String(), nil +} + +// getConnectedPeers returns a connection Status or nil if peer connection wasn't found +func getConnectedPeers(e *Engine) int { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + i := 0 + for _, id := range e.peerStore.PeersPubKey() { + conn, _ := e.peerStore.PeerConn(id) + if conn.IsConnected() { + i++ + } + } + return i +} + +func getPeers(e *Engine) int { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + + return len(e.peerStore.PeersPubKey()) +} diff --git a/client/internal/engine_session_deadline_test.go b/client/internal/engine_session_deadline_test.go new file mode 100644 index 000000000..5a67f103a --- /dev/null +++ b/client/internal/engine_session_deadline_test.go @@ -0,0 +1,88 @@ +package internal + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + "github.com/netbirdio/netbird/client/internal/peer" +) + +// TestApplySessionDeadline_ThreeState pins down the 3-state semantics of the +// wire field carried on LoginResponse / SyncResponse: +// +// - nil pointer → no info; previously-anchored deadline survives +// - explicit zero value → "expiry disabled" sentinel; both sinks cleared +// - valid future timestamp → new deadline propagated to both sinks +func TestApplySessionDeadline_ThreeState(t *testing.T) { + newEngine := func() *Engine { + recorder := peer.NewRecorder("") + return &Engine{ + statusRecorder: recorder, + sessionWatcher: sessionwatch.New(recorder), + } + } + + t.Run("valid timestamp sets deadline on both sinks", func(t *testing.T) { + e := newEngine() + deadline := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + + e.ApplySessionDeadline(timestamppb.New(deadline)) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(deadline), + "status recorder should hold the new deadline") + }) + + t.Run("nil is a no-op and preserves previous deadline", func(t *testing.T) { + e := newEngine() + seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + e.ApplySessionDeadline(timestamppb.New(seeded)) + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded)) + + e.ApplySessionDeadline(nil) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded), + "nil snapshot must not disturb the existing deadline") + }) + + t.Run("explicit zero clears a previously-anchored deadline", func(t *testing.T) { + e := newEngine() + seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + e.ApplySessionDeadline(timestamppb.New(seeded)) + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded)) + + // Explicit zero Timestamp{} (seconds=0, nanos=0) is the + // "expiry disabled / not SSO" sentinel. + e.ApplySessionDeadline(×tamppb.Timestamp{}) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(), + "explicit zero sentinel must clear the deadline") + }) + + t.Run("invalid timestamp clears the deadline", func(t *testing.T) { + e := newEngine() + seeded := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + e.ApplySessionDeadline(timestamppb.New(seeded)) + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(seeded)) + + // Out-of-range nanos → IsValid()==false; same-meaning as the + // disabled sentinel for downstream sinks. + e.ApplySessionDeadline(×tamppb.Timestamp{Seconds: 1, Nanos: -1}) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(), + "invalid timestamp must clear the deadline") + }) + + t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) { + e := newEngine() + expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second) + + e.ApplySessionDeadline(timestamppb.New(expired)) + + require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired), + "recently-expired deadline must stay on the recorder so consumers render it as expired") + }) +} diff --git a/client/internal/engine_sessionwatch.go b/client/internal/engine_sessionwatch.go new file mode 100644 index 000000000..a46d73f87 --- /dev/null +++ b/client/internal/engine_sessionwatch.go @@ -0,0 +1,16 @@ +//go:build !js + +package internal + +import ( + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + "github.com/netbirdio/netbird/client/internal/peer" +) + +// newSessionWatcher returns the real SSO session expiry watcher for every +// non-wasm build. The js/wasm build gets a no-op stub from +// engine_sessionwatch_js.go so the sessionwatch package (and its timer +// machinery) never links into the wasm binary. +func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher { + return sessionwatch.New(recorder) +} diff --git a/client/internal/engine_sessionwatch_js.go b/client/internal/engine_sessionwatch_js.go new file mode 100644 index 000000000..50e148ab9 --- /dev/null +++ b/client/internal/engine_sessionwatch_js.go @@ -0,0 +1,44 @@ +//go:build js + +package internal + +import ( + "time" + + "github.com/netbirdio/netbird/client/internal/peer" +) + +// noopSessionWatcher is the js/wasm stand-in for sessionwatch.Watcher. The +// wasm client never runs the engine's session-warning flow (the interactive +// T-WarningLead notification and the T-FinalWarningLead fallback dialog live +// in the desktop UI), so linking the full sessionwatch package (timers, event +// composition) would only bloat the binary. +// +// It still mirrors the deadline into the status recorder so the SubscribeStatus +// / Status snapshot the UI consumes stays correct — only the timer-driven +// warnings are dropped. +type noopSessionWatcher struct { + recorder *peer.Status +} + +func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher { + return noopSessionWatcher{recorder: recorder} +} + +// Update mirrors the real watcher's recorder propagation without the timers or +// sanity-check sentinels: a valid deadline is exposed on the status snapshot, +// the zero time clears it. +func (w noopSessionWatcher) Update(deadline time.Time) error { + if w.recorder != nil { + w.recorder.SetSessionExpiresAt(deadline) + } + return nil +} + +func (noopSessionWatcher) Dismiss() { + // No-op: only suppresses the timer-driven final-warning, which this stub never arms. +} + +func (noopSessionWatcher) Close() { + // No-op: no timers to stop and no state to unwind; the recorder is cleared via Update(zero). +} diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 289f1906f..fbd47ed74 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -6,37 +6,18 @@ import ( "net" "net/netip" "os" - "runtime" "strings" "sync" "testing" "time" - "github.com/golang/mock/gomock" - "github.com/google/uuid" - log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel" wgdevice "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "google.golang.org/grpc" - "google.golang.org/grpc/keepalive" "github.com/netbirdio/netbird/client/internal/stdnet" - "github.com/netbirdio/netbird/management/server/job" - - "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" - - "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" - "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" - "github.com/netbirdio/netbird/management/internals/modules/peers" - "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" - nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" - - "github.com/netbirdio/netbird/management/internals/server/config" - "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/configurer" @@ -50,18 +31,7 @@ import ( icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/routemanager" - nbssh "github.com/netbirdio/netbird/client/ssh" - "github.com/netbirdio/netbird/client/system" nbdns "github.com/netbirdio/netbird/dns" - "github.com/netbirdio/netbird/management/server" - "github.com/netbirdio/netbird/management/server/activity" - nbcache "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" - "github.com/netbirdio/netbird/management/server/permissions" - "github.com/netbirdio/netbird/management/server/settings" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/telemetry" - "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/monotime" "github.com/netbirdio/netbird/route" mgmt "github.com/netbirdio/netbird/shared/management/client" @@ -69,25 +39,9 @@ import ( "github.com/netbirdio/netbird/shared/netiputil" relayClient "github.com/netbirdio/netbird/shared/relay/client" signal "github.com/netbirdio/netbird/shared/signal/client" - "github.com/netbirdio/netbird/shared/signal/proto" - signalServer "github.com/netbirdio/netbird/signal/server" "github.com/netbirdio/netbird/util" ) -var ( - kaep = keepalive.EnforcementPolicy{ - MinTime: 15 * time.Second, - PermitWithoutStream: true, - } - - kasp = keepalive.ServerParameters{ - MaxConnectionIdle: 15 * time.Second, - MaxConnectionAgeGrace: 5 * time.Second, - Time: 5 * time.Second, - Timeout: 2 * time.Second, - } -) - type MockWGIface struct { CreateFunc func() error CreateOnAndroidFunc func(routeRange []string, ip string, domains []string) error @@ -224,6 +178,10 @@ func (m *MockWGIface) LastActivities() map[string]monotime.Time { return nil } +func (m *MockWGIface) MTU() uint16 { + return 1280 +} + func (m *MockWGIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error { return nil } @@ -234,129 +192,6 @@ func TestMain(m *testing.M) { os.Exit(code) } -func TestEngine_SSH(t *testing.T) { - key, err := wgtypes.GeneratePrivateKey() - if err != nil { - t.Fatal(err) - return - } - - sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) - if err != nil { - t.Fatal(err) - return - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - engine := NewEngine( - ctx, cancel, - &EngineConfig{ - WgIfaceName: "utun101", - WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), - WgPrivateKey: key, - WgPort: 33100, - ServerSSHAllowed: true, - MTU: iface.DefaultMTU, - SSHKey: sshKey, - }, - EngineServices{ - SignalClient: &signal.MockClient{}, - MgmClient: &mgmt.MockClient{}, - RelayManager: relayMgr, - StatusRecorder: peer.NewRecorder("https://mgm"), - }, - MobileDependency{}, - ) - - engine.dnsServer = &dns.MockServer{ - UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil }, - } - - err = engine.Start(nil, nil) - require.NoError(t, err) - - defer func() { - err := engine.Stop() - if err != nil { - return - } - }() - - peerWithSSH := &mgmtProto.RemotePeerConfig{ - WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", - AllowedIps: []string{"100.64.0.21/24"}, - SshConfig: &mgmtProto.SSHConfig{ - SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"), - }, - } - - // SSH server is not enabled so SSH config of a remote peer should be ignored - networkMap := &mgmtProto.NetworkMap{ - Serial: 6, - PeerConfig: nil, - RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, - RemotePeersIsEmpty: false, - } - - err = engine.updateNetworkMap(networkMap) - require.NoError(t, err) - - assert.Nil(t, engine.sshServer) - - // SSH server is enabled, therefore SSH config should be applied - networkMap = &mgmtProto.NetworkMap{ - Serial: 7, - PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24", - SshConfig: &mgmtProto.SSHConfig{ - SshEnabled: true, - JwtConfig: &mgmtProto.JWTConfig{ - Issuer: "test-issuer", - Audience: "test-audience", - KeysLocation: "test-keys", - MaxTokenAge: 3600, - }, - }}, - RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, - RemotePeersIsEmpty: false, - } - - err = engine.updateNetworkMap(networkMap) - require.NoError(t, err) - - time.Sleep(250 * time.Millisecond) - assert.NotNil(t, engine.sshServer) - - // now remove peer - networkMap = &mgmtProto.NetworkMap{ - Serial: 8, - RemotePeers: []*mgmtProto.RemotePeerConfig{}, - RemotePeersIsEmpty: false, - } - - err = engine.updateNetworkMap(networkMap) - require.NoError(t, err) - - // time.Sleep(250 * time.Millisecond) - assert.NotNil(t, engine.sshServer) - - // now disable SSH server - networkMap = &mgmtProto.NetworkMap{ - Serial: 9, - PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24", - SshConfig: &mgmtProto.SSHConfig{SshEnabled: false}}, - RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, - RemotePeersIsEmpty: false, - } - - err = engine.updateNetworkMap(networkMap) - require.NoError(t, err) - - assert.Nil(t, engine.sshServer) -} - func TestEngine_SSHUpdateLogic(t *testing.T) { // Test that SSH server start/stop logic works based on config engine := &Engine{ @@ -426,7 +261,7 @@ func TestEngine_UpdateNetworkMap(t *testing.T) { return } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) defer cancel() relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) @@ -631,97 +466,6 @@ func TestEngine_UpdateNetworkMap(t *testing.T) { } } -func TestEngine_Sync(t *testing.T) { - key, err := wgtypes.GeneratePrivateKey() - if err != nil { - t.Fatal(err) - return - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // feed updates to Engine via mocked Management client - updates := make(chan *mgmtProto.SyncResponse) - defer close(updates) - syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error { - for msg := range updates { - err := msgHandler(msg) - if err != nil { - t.Fatal(err) - } - } - return nil - } - relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - engine := NewEngine(ctx, cancel, &EngineConfig{ - WgIfaceName: "utun103", - WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), - WgPrivateKey: key, - WgPort: 33100, - MTU: iface.DefaultMTU, - }, EngineServices{ - SignalClient: &signal.MockClient{}, - MgmClient: &mgmt.MockClient{SyncFunc: syncFunc}, - RelayManager: relayMgr, - StatusRecorder: peer.NewRecorder("https://mgm"), - }, MobileDependency{}) - engine.ctx = ctx - - engine.dnsServer = &dns.MockServer{ - UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil }, - } - - defer func() { - err := engine.Stop() - if err != nil { - return - } - }() - - err = engine.Start(nil, nil) - if err != nil { - t.Fatal(err) - return - } - - peer1 := &mgmtProto.RemotePeerConfig{ - WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", - AllowedIps: []string{"100.64.0.10/24"}, - } - peer2 := &mgmtProto.RemotePeerConfig{ - WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=", - AllowedIps: []string{"100.64.0.11/24"}, - } - peer3 := &mgmtProto.RemotePeerConfig{ - WgPubKey: "GGHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=", - AllowedIps: []string{"100.64.0.12/24"}, - } - // 1st update with just 1 peer and serial larger than the current serial of the engine => apply update - updates <- &mgmtProto.SyncResponse{ - NetworkMap: &mgmtProto.NetworkMap{ - Serial: 10, - PeerConfig: nil, - RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3}, - RemotePeersIsEmpty: false, - }, - } - - timeout := time.After(time.Second * 2) - for { - select { - case <-timeout: - t.Fatalf("timeout while waiting for test to finish") - return - default: - } - - if getPeers(engine) == 3 && engine.networkSerial == 10 { - break - } - } -} - func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) { testCases := []struct { name string @@ -817,7 +561,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 +768,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) { return } - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(CtxInitState(context.Background())) defer cancel() wgIfaceName := fmt.Sprintf("utun%d", 104+n) @@ -1105,104 +849,6 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) { } } -func TestEngine_MultiplePeers(t *testing.T) { - // log.SetLevel(log.DebugLevel) - - ctx, cancel := context.WithCancel(CtxInitState(context.Background())) - defer cancel() - - sigServer, signalAddr, err := startSignal(t) - if err != nil { - t.Fatal(err) - return - } - defer sigServer.Stop() - mgmtServer, mgmtAddr, err := startManagement(t, t.TempDir(), "../testdata/store.sql") - if err != nil { - t.Fatal(err) - return - } - defer mgmtServer.GracefulStop() - - setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" - - mu := sync.Mutex{} - engines := []*Engine{} - numPeers := 10 - wg := sync.WaitGroup{} - wg.Add(numPeers) - // create and start peers - for i := 0; i < numPeers; i++ { - j := i - go func() { - engine, err := createEngine(ctx, cancel, setupKey, j, mgmtAddr, signalAddr) - if err != nil { - wg.Done() - t.Errorf("unable to create the engine for peer %d with error %v", j, err) - return - } - engine.dnsServer = &dns.MockServer{} - mu.Lock() - defer mu.Unlock() - guid := fmt.Sprintf("{%s}", uuid.New().String()) - device.CustomWindowsGUIDString = strings.ToLower(guid) - err = engine.Start(nil, nil) - if err != nil { - t.Errorf("unable to start engine for peer %d with error %v", j, err) - wg.Done() - return - } - engines = append(engines, engine) - wg.Done() - }() - } - - // wait until all have been created and started - wg.Wait() - if len(engines) != numPeers { - t.Fatal("not all peers was started") - } - // check whether all the peer have expected peers connected - - expectedConnected := numPeers * (numPeers - 1) - - // adjust according to timeouts - timeout := 50 * time.Second - timeoutChan := time.After(timeout) - ticker := time.NewTicker(time.Second) - defer ticker.Stop() -loop: - for { - select { - case <-timeoutChan: - t.Fatalf("waiting for expected connections timeout after %s", timeout.String()) - break loop - case <-ticker.C: - totalConnected := 0 - for _, engine := range engines { - totalConnected += getConnectedPeers(engine) - } - if totalConnected == expectedConnected { - log.Infof("total connected=%d", totalConnected) - break loop - } - log.Infof("total connected=%d", totalConnected) - } - } - // cleanup test - for n, peerEngine := range engines { - t.Logf("stopping peer with interface %s from multipeer test, loopIndex %d", peerEngine.wgInterface.Name(), n) - errStop := peerEngine.mgmClient.Close() - if errStop != nil { - log.Infoln("got error trying to close management clients from engine: ", errStop) - } - errStop = peerEngine.Stop() - if errStop != nil { - log.Infoln("got error trying to close testing peers engine: ", errStop) - } - } -} - func Test_ParseNATExternalIPMappings(t *testing.T) { ifaceList, err := net.Interfaces() if err != nil { @@ -1526,187 +1172,6 @@ func TestCompareNetIPLists(t *testing.T) { } } -func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey string, i int, mgmtAddr string, signalAddr string) (*Engine, error) { - key, err := wgtypes.GeneratePrivateKey() - if err != nil { - return nil, err - } - mgmtClient, err := mgmt.NewClient(ctx, mgmtAddr, key, false) - if err != nil { - return nil, err - } - signalClient, err := signal.NewClient(ctx, signalAddr, key, false) - if err != nil { - return nil, err - } - - info := system.GetInfo(ctx) - resp, err := mgmtClient.Register(setupKey, "", info, nil, nil) - if err != nil { - return nil, err - } - - var ifaceName string - if runtime.GOOS == "darwin" { - ifaceName = fmt.Sprintf("utun1%d", i) - } else { - ifaceName = fmt.Sprintf("wt%d", i) - } - - wgPort := 33100 + i - conf := &EngineConfig{ - WgIfaceName: ifaceName, - WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address), - WgPrivateKey: key, - WgPort: wgPort, - MTU: iface.DefaultMTU, - } - - relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - e, err := NewEngine(ctx, cancel, conf, EngineServices{ - SignalClient: signalClient, - MgmClient: mgmtClient, - RelayManager: relayMgr, - StatusRecorder: peer.NewRecorder("https://mgm"), - }, MobileDependency{}), nil - e.ctx = ctx - return e, err -} - -func startSignal(t *testing.T) (*grpc.Server, string, error) { - t.Helper() - - s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) - - lis, err := net.Listen("tcp", "localhost:0") - if err != nil { - log.Fatalf("failed to listen: %v", err) - } - - srv, err := signalServer.NewServer(context.Background(), otel.Meter("")) - require.NoError(t, err) - proto.RegisterSignalExchangeServer(s, srv) - - go func() { - if err = s.Serve(lis); err != nil { - log.Fatalf("failed to serve: %v", err) - } - }() - - return s, lis.Addr().String(), nil -} - -func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, string, error) { - t.Helper() - - config := &config.Config{ - Stuns: []*config.Host{}, - TURNConfig: &config.TURNConfig{}, - Relay: &config.Relay{ - Addresses: []string{"127.0.0.1:1234"}, - CredentialsTTL: util.Duration{Duration: time.Hour}, - Secret: "222222222222222222", - }, - Signal: &config.Host{ - Proto: "http", - URI: "localhost:10000", - }, - Datadir: dataDir, - HttpConfig: nil, - } - - lis, err := net.Listen("tcp", "localhost:0") - if err != nil { - return nil, "", err - } - s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) - - store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), testFile, config.Datadir) - if err != nil { - return nil, "", err - } - t.Cleanup(cleanUp) - - eventStore := &activity.InMemoryEventStore{} - if err != nil { - return nil, "", err - } - - permissionsManager := permissions.NewManager(store) - peersManager := peers.NewManager(store, permissionsManager) - jobManager := job.NewJobManager(nil, store, peersManager) - - cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) - if err != nil { - return nil, "", err - } - - ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore) - - metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) - require.NoError(t, err) - - ctrl := gomock.NewController(t) - t.Cleanup(ctrl.Finish) - settingsMockManager := settings.NewMockManager(ctrl) - settingsMockManager.EXPECT(). - GetSettings(gomock.Any(), gomock.Any(), gomock.Any()). - Return(&types.Settings{}, nil). - AnyTimes() - settingsMockManager.EXPECT(). - GetExtraSettings(gomock.Any(), gomock.Any()). - Return(&types.ExtraSettings{}, nil). - AnyTimes() - - groupsManager := groups.NewManagerMock() - - updateManager := update_channel.NewPeersUpdateManager(metrics) - requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) - networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) - accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) - if err != nil { - return nil, "", err - } - - secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) - if err != nil { - return nil, "", err - } - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil) - if err != nil { - return nil, "", err - } - mgmtProto.RegisterManagementServiceServer(s, mgmtServer) - go func() { - if err = s.Serve(lis); err != nil { - log.Fatalf("failed to serve: %v", err) - } - }() - - return s, lis.Addr().String(), nil -} - -// getConnectedPeers returns a connection Status or nil if peer connection wasn't found -func getConnectedPeers(e *Engine) int { - e.syncMsgMux.Lock() - defer e.syncMsgMux.Unlock() - i := 0 - for _, id := range e.peerStore.PeersPubKey() { - conn, _ := e.peerStore.PeerConn(id) - if conn.IsConnected() { - i++ - } - } - return i -} - -func getPeers(e *Engine) int { - e.syncMsgMux.Lock() - defer e.syncMsgMux.Unlock() - - return len(e.peerStore.PeersPubKey()) -} - func mustEncodePrefix(t *testing.T, p netip.Prefix) []byte { t.Helper() b, err := netiputil.EncodePrefix(p) diff --git a/client/internal/engine_tunsettings.go b/client/internal/engine_tunsettings.go new file mode 100644 index 000000000..34a59671a --- /dev/null +++ b/client/internal/engine_tunsettings.go @@ -0,0 +1,20 @@ +package internal + +func (e *Engine) TunSettings() ([]string, []string) { + e.syncMsgMux.Lock() + routeManager := e.routeManager + dnsServer := e.dnsServer + e.syncMsgMux.Unlock() + + var routes []string + if routeManager != nil { + routes = routeManager.CurrentRouteRange() + } + + var searchDomains []string + if dnsServer != nil { + searchDomains = dnsServer.SearchDomains() + } + + return routes, searchDomains +} diff --git a/client/internal/iface_common.go b/client/internal/iface_common.go index 2eeac1954..8ffa0b102 100644 --- a/client/internal/iface_common.go +++ b/client/internal/iface_common.go @@ -44,4 +44,5 @@ type wgIfaceBase interface { FullStats() (*configurer.Stats, error) LastActivities() map[string]monotime.Time SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error + MTU() uint16 } diff --git a/client/internal/ipcauth/creds_stub.go b/client/internal/ipcauth/creds_stub.go new file mode 100644 index 000000000..154948716 --- /dev/null +++ b/client/internal/ipcauth/creds_stub.go @@ -0,0 +1,31 @@ +//go:build !linux && !darwin && !freebsd && !windows + +package ipcauth + +import ( + "errors" + "net" + + "google.golang.org/grpc/credentials" +) + +// errUnsupported is returned on platforms with no local peer-identity +// primitive, so consumers fail closed instead of guessing an identity. +var errUnsupported = errors.New("peer identity is not available on this platform") + +// NewTransportCredentials returns nil: without a peer-identity primitive the +// daemon cannot authenticate local callers, and the caller must treat that as +// "authorization cannot be enforced". +func NewTransportCredentials() credentials.TransportCredentials { + return nil +} + +// PeerIdentity always fails on this platform. +func PeerIdentity(net.Conn) (Identity, error) { + return Identity{}, errUnsupported +} + +// ConnIdentity always fails on this platform. +func ConnIdentity(net.Conn) (Identity, error) { + return Identity{}, errUnsupported +} diff --git a/client/internal/ipcauth/creds_unix.go b/client/internal/ipcauth/creds_unix.go new file mode 100644 index 000000000..688fe4623 --- /dev/null +++ b/client/internal/ipcauth/creds_unix.go @@ -0,0 +1,56 @@ +//go:build linux || darwin || freebsd + +package ipcauth + +import ( + "context" + "net" + + "google.golang.org/grpc/credentials" +) + +// NewTransportCredentials returns gRPC transport credentials that expose the +// caller's kernel-authenticated identity via IdentityFromContext. It returns +// nil on platforms that have no peer-identity primitive, which the caller must +// treat as "authorization cannot be enforced". +// +// The handshake exchanges no bytes on the wire, so a client dialing with +// insecure credentials interoperates with a server using these. That keeps +// older CLI and UI binaries working against an upgraded daemon. +func NewTransportCredentials() credentials.TransportCredentials { + return unixCreds{} +} + +// ConnIdentity extracts the caller's identity from an accepted local IPC +// connection. It is shared by the gRPC transport credentials and by the JSON +// gateway, which reads the identity of its own HTTP clients. +func ConnIdentity(conn net.Conn) (Identity, error) { + return PeerIdentity(conn) +} + +type unixCreds struct{} + +func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + return conn, AuthInfo{}, nil +} + +// ServerHandshake extracts the peer identity and fails closed when it cannot +// be read, so a connection whose caller is unknown never reaches a handler. +func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + id, err := ConnIdentity(conn) + if err != nil { + return nil, nil, err + } + return conn, AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, nil +} + +func (unixCreds) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()} +} + +func (unixCreds) Clone() credentials.TransportCredentials { return unixCreds{} } + +func (unixCreds) OverrideServerName(string) error { return nil } diff --git a/client/internal/ipcauth/creds_windows.go b/client/internal/ipcauth/creds_windows.go new file mode 100644 index 000000000..37f902c52 --- /dev/null +++ b/client/internal/ipcauth/creds_windows.go @@ -0,0 +1,194 @@ +//go:build windows + +package ipcauth + +import ( + "context" + "fmt" + "net" + "runtime" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + "google.golang.org/grpc/credentials" +) + +var ( + modadvapi32 = windows.NewLazySystemDLL("advapi32.dll") + procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient") +) + +// DefaultPipeSDDL is the security descriptor for the daemon control pipe. +// +// D:P protected DACL, no inheritance +// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon's service account) +// (A;;GA;;;WD) allow GENERIC_ALL to Everyone +// +// Any local caller may connect, as with a Unix socket at 0666; what a caller may +// actually do is decided from its token, not from the DACL. Remote callers are not +// a concern here: winio.ListenPipe creates the pipe with +// FILE_PIPE_REJECT_REMOTE_CLIENTS, so NPFS rejects connections from other machines +// before the descriptor is consulted. +// +// A deny ACE on the NETWORK SID would not add anything and would break callers: +// that SID is present in any network-logon token, which includes OpenSSH and WinRM +// sessions, so it denies administrators driving the CLI over SSH and denies the +// daemon itself when started from such a session. +func DefaultPipeSDDL() string { + return "D:P(A;;GA;;;SY)(A;;GA;;;WD)" +} + +// NewTransportCredentials returns gRPC transport credentials that derive the +// caller's identity from the named-pipe client token. +// +// The client must connect at SECURITY_IDENTIFICATION for the daemon to be able +// to read its token, which is what DialNamedPipe does. +func NewTransportCredentials() credentials.TransportCredentials { + return winpipeCreds{} +} + +// ConnIdentity extracts the caller's identity from an accepted named-pipe +// connection by impersonating the pipe client and reading its token. It is +// shared by the gRPC transport credentials and by the JSON gateway, which +// reads the identity of its own HTTP clients. +func ConnIdentity(conn net.Conn) (Identity, error) { + // go-winio's pipe connection embeds *win32File, which exposes Fd(). + fdConn, ok := conn.(interface{ Fd() uintptr }) + if !ok { + return Identity{}, fmt.Errorf("connection %T does not expose a pipe handle", conn) + } + return pipeClientIdentity(windows.Handle(fdConn.Fd())) +} + +type winpipeCreds struct{} + +func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + return conn, AuthInfo{}, nil +} + +// ServerHandshake extracts the connecting client's identity and fails closed +// when the handle or token cannot be read, so a connection whose caller is +// unknown never reaches a handler. +func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + id, err := ConnIdentity(conn) + if err != nil { + return nil, nil, err + } + return conn, AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, nil +} + +func (winpipeCreds) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()} +} + +func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCreds{} } + +func (winpipeCreds) OverrideServerName(string) error { return nil } + +// pipeClientIdentity reads the connecting client's user SID, usable group +// SIDs, and elevation state by impersonating the pipe client on this thread +// and reading the resulting impersonation token. +func pipeClientIdentity(handle windows.Handle) (id Identity, err error) { + // Impersonation is per-thread, so the goroutine must stay on this thread + // until RevertToSelf, otherwise an unrelated goroutine could inherit the + // impersonated context. + runtime.LockOSThread() + + // The thread only goes back to the runtime's pool once it is provably no + // longer impersonating the client. If the revert fails, leaving it locked + // makes Go terminate it when this goroutine exits, which costs one thread + // and keeps a thread running as the client from ever being reused. + clean := false + defer func() { + if clean { + runtime.UnlockOSThread() + } + }() + + if err = impersonateNamedPipeClient(handle); err != nil { + clean = true + return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err) + } + defer func() { + // Surface the revert failure only when nothing else failed: leaving + // the thread impersonated is worse than the original error. + revErr := windows.RevertToSelf() + if revErr != nil { + if err == nil { + err = fmt.Errorf("revert impersonation: %w", revErr) + } + return + } + clean = true + }() + + // openAsSelf=true opens the token with the daemon's own process context + // rather than the impersonated client's, so the open cannot fail because + // the client lacks access to its own token. + var token windows.Token + if err = windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY, true, &token); err != nil { + return Identity{}, fmt.Errorf("open thread token: %w", err) + } + defer func() { + if cerr := token.Close(); cerr != nil { + log.Debugf("close client token: %v", cerr) + } + }() + + return identityFromToken(token) +} + +// identityFromToken reads the user SID, usable group SIDs and elevation state +// out of a Windows token. +func identityFromToken(token windows.Token) (Identity, error) { + user, err := token.GetTokenUser() + if err != nil { + return Identity{}, fmt.Errorf("read token user: %w", err) + } + + groups, err := tokenGroupSIDs(token) + if err != nil { + return Identity{}, err + } + + return Identity{ + SID: user.User.Sid.String(), + Groups: groups, + Elevated: token.IsElevated(), + }, nil +} + +// tokenGroupSIDs returns the SIDs of the groups the token can actually +// exercise. Groups that are disabled or marked deny-only are skipped: a +// UAC-filtered administrator carries BUILTIN\Administrators as deny-only, and +// treating that as membership would hand every admin account privilege it +// cannot currently use. +func tokenGroupSIDs(token windows.Token) ([]string, error) { + tg, err := token.GetTokenGroups() + if err != nil { + return nil, fmt.Errorf("read token groups: %w", err) + } + + var sids []string + for _, g := range tg.AllGroups() { + if g.Attributes&windows.SE_GROUP_ENABLED == 0 { + continue + } + if g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 { + continue + } + sids = append(sids, g.Sid.String()) + } + return sids, nil +} + +func impersonateNamedPipeClient(h windows.Handle) error { + r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h)) + if r == 0 { + return e + } + return nil +} diff --git a/client/internal/ipcauth/forward.go b/client/internal/ipcauth/forward.go new file mode 100644 index 000000000..57749528c --- /dev/null +++ b/client/internal/ipcauth/forward.go @@ -0,0 +1,272 @@ +package ipcauth + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "fmt" + "slices" + "strconv" + "strings" + + "google.golang.org/grpc/metadata" +) + +// Metadata keys the local JSON gateway uses to forward the identity of its own +// HTTP client to the daemon. The gateway runs inside the daemon process and +// re-dials the daemon over the control socket, so without forwarding every +// JSON request would appear to come from the daemon itself. +const ( + // mdFwd marks a request as forwarded by the JSON gateway. It is always + // set, even when the gateway could not read its client's identity, so the + // daemon can tell "no identity available" apart from "not forwarded". + mdFwd = "x-netbird-fwd" + mdFwdUID = "x-netbird-fwd-uid" // Unix user ID + mdFwdGID = "x-netbird-fwd-gid" // Unix primary group ID + mdFwdSID = "x-netbird-fwd-sid" // Windows user SID + mdFwdGroup = "x-netbird-fwd-group" // Windows group SID, repeated + mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" when elevated + + // mdFwdProof proves the forwarded identity was stamped by this process. The + // gateway runs inside the daemon, so a secret held in memory is available to + // the only legitimate producer and to nothing else. + mdFwdProof = "x-netbird-fwd-proof" +) + +// forwardKeys is every metadata key the gateway sets. An HTTP client must never +// be able to supply one itself: see IsReservedForwardKey. +var forwardKeys = []string{mdFwd, mdFwdUID, mdFwdGID, mdFwdSID, mdFwdGroup, mdFwdElevated, mdFwdProof} + +// forwardProof authenticates the gateway's forwarding metadata. It is generated +// once per daemon process and never leaves it: it is not written to disk, not +// logged, and not sent anywhere except over the daemon's own control socket to +// itself. +// +// Without it, trusting a forwarded identity rests on every layer in front of it +// stripping incoming forwarding keys, and on each key's value shape being +// distinguishable from an injected one. A single injected group SID or an +// injected "elevated" flag has the same shape as a legitimate one, so no +// cardinality rule can catch it. Requiring the proof means metadata that did not +// come from this process is refused whatever it contains. +var forwardProof = mustForwardProof() + +func mustForwardProof() string { + var buf [32]byte + if _, err := rand.Read(buf[:]); err != nil { + // Continuing would leave the forwarded path authenticated by a + // predictable value, which is worse than not starting. + panic(fmt.Sprintf("generate identity forwarding proof: %v", err)) + } + return hex.EncodeToString(buf[:]) +} + +// IsReservedForwardKey reports whether a gRPC metadata key belongs to the +// gateway's identity forwarding, and therefore must be dropped when it arrives +// from outside. +// +// grpc-gateway maps "Grpc-Metadata-" request headers into gRPC metadata and +// joins them ahead of the values its own annotators add. Without dropping these, +// an HTTP client could hand the daemon "x-netbird-fwd-uid: 0" and be believed, +// because the daemon trusts forwarded metadata when the transport peer is the +// (privileged) gateway. +func IsReservedForwardKey(key string) bool { + key = strings.ToLower(key) + return slices.Contains(forwardKeys, key) +} + +// ForwardIdentityMetadata encodes an HTTP client's identity for the JSON +// gateway to forward to the daemon. When known is false only the marker is +// set, which makes the daemon treat the caller as unidentified rather than as +// the daemon itself. +func ForwardIdentityMetadata(id Identity, known bool) metadata.MD { + md := metadata.MD{} + md.Set(mdFwd, "1") + md.Set(mdFwdProof, forwardProof) + if !known { + return md + } + + if id.IsWindows() { + md.Set(mdFwdSID, id.SID) + if len(id.Groups) > 0 { + md.Set(mdFwdGroup, id.Groups...) + } + if id.Elevated { + md.Set(mdFwdElevated, "1") + } + return md + } + + md.Set(mdFwdUID, strconv.FormatUint(uint64(id.UID), 10)) + md.Set(mdFwdGID, strconv.FormatUint(uint64(id.GID), 10)) + return md +} + +// CallerIdentity returns the identity to authorize a request against. For a +// direct connection that is the transport peer's kernel identity. For a +// request relayed by the local JSON gateway it is the identity the gateway +// forwarded, since the transport peer is then the daemon itself. +// +// A forwarded identity is only honoured when the transport peer is the daemon's +// own identity and the metadata carries this process's forwarding proof, so +// forged forwarding metadata gains a caller nothing. A forwarded request that +// carries no identity is reported as unidentified, never as the daemon. +// +// The second return value is false when no identity could be established, and +// callers MUST fail closed in that case. +func CallerIdentity(ctx context.Context) (Identity, bool) { + id, ok := IdentityFromContext(ctx) + if !ok { + return Identity{}, false + } + + // A forwarding key that arrives more than once did not come from the gateway + // alone, so nothing about the request can be trusted to describe its caller. + // Refusing outright matters because the alternative reading, "not forwarded", + // would authorize the request as the transport peer, which on the gateway's + // connection is the daemon itself. + if duplicatedForwardKey(ctx) { + return Identity{}, false + } + + forwarded := isForwarded(ctx) + + // Our own process on the other end of the socket is the JSON gateway, the only + // thing that dials the daemon from inside it. Such a call must carry a + // forwarded identity; without one there is no caller to authorize, and + // treating it as the daemon would authorize whatever reached the JSON socket. + // Only Linux reports the peer PID, so this is a belt on top of the gateway's + // interceptor rather than the sole guarantee. + if id.PID != 0 && int(id.PID) == selfPID && !forwarded { + return Identity{}, false + } + + // Only the gateway's own connection may speak for someone else. Being + // privileged is not enough and not the point: the gateway runs inside the + // daemon, so it dials as the daemon's identity whatever user that is, which + // also covers a rootless container. + if !forwarded || !IsDaemonSelf(id) { + return id, true + } + + // Speaking for someone else additionally requires the proof only this process + // holds. Refusing is the only safe reading: the transport peer here is the + // daemon itself, so falling back to it would authorize the request as the + // daemon. This is also what makes the forwarded values trustworthy once + // accepted, so they need no shape checks of their own. + if !authenticForward(ctx) { + return Identity{}, false + } + + return forwardedIdentity(ctx) +} + +// duplicatedForwardKey reports whether any forwarding key carries more than one +// value. The gateway's interceptor sets each key exactly once and replaces what +// was already there, so a repeat means a second source supplied it. +func duplicatedForwardKey(ctx context.Context) bool { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return false + } + for _, key := range forwardKeys { + // Group SIDs are legitimately repeated; the rest identify the caller. + if key == mdFwdGroup { + continue + } + if len(md.Get(key)) > 1 { + return true + } + } + return false +} + +// authenticForward reports whether the request carries this process's forwarding +// proof, which only the in-process JSON gateway can supply. +func authenticForward(ctx context.Context) bool { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return false + } + got := mdSingle(md, mdFwdProof) + return subtle.ConstantTimeCompare([]byte(got), []byte(forwardProof)) == 1 +} + +// isForwarded reports whether the request carries the JSON gateway marker. +func isForwarded(ctx context.Context) bool { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return false + } + return mdSingle(md, mdFwd) != "" +} + +// forwardedIdentity decodes the identity the JSON gateway attached. +func forwardedIdentity(ctx context.Context) (Identity, bool) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return Identity{}, false + } + + if sid := mdSingle(md, mdFwdSID); sid != "" { + return Identity{ + SID: sid, + // Repeated by design, one value per group, and only reachable once + // the forwarding proof has been verified. + Groups: md.Get(mdFwdGroup), + Elevated: mdSingle(md, mdFwdElevated) == "1", + }, true + } + + uid, err := strconv.ParseUint(mdSingle(md, mdFwdUID), 10, 32) + if err != nil { + return Identity{}, false + } + + id := Identity{UID: uint32(uid)} + if gid, err := strconv.ParseUint(mdSingle(md, mdFwdGID), 10, 32); err == nil { + id.GID = uint32(gid) + } + return id, true +} + +// mdSingle returns the value of a forwarded key only when exactly one was +// supplied. The gateway's interceptor sets each key exactly once, so more than one +// value means something else also supplied it, and the whole identity is treated as +// unknown rather than picking a winner. Defence in depth behind the gateway's +// header filter. +func mdSingle(md metadata.MD, key string) string { + if v := md.Get(key); len(v) == 1 { + return v[0] + } + return "" +} + +// WithForwardedIdentity stamps id onto a context's outgoing metadata for the JSON +// gateway's call to the daemon, replacing any forwarding keys already present so +// values supplied from outside cannot survive alongside it. +// +// This is deliberately not done with runtime.WithMetadata: grpc-gateway skips its +// annotators entirely when no request header maps to metadata ("if len(pairs) == 0 +// { return ctx, nil, nil }", runtime/context.go), which an HTTP/1.0 request with no +// Host header over a unix socket achieves. The daemon would then see an unmarked +// call whose transport peer is the daemon's own identity, and authorize it as the +// daemon. A client interceptor runs for every RPC regardless of headers. +func WithForwardedIdentity(ctx context.Context, id Identity, known bool) context.Context { + md, ok := metadata.FromOutgoingContext(ctx) + if !ok { + md = metadata.MD{} + } else { + md = md.Copy() + } + + for _, key := range forwardKeys { + delete(md, key) + } + for key, values := range ForwardIdentityMetadata(id, known) { + md[key] = values + } + + return metadata.NewOutgoingContext(ctx, md) +} diff --git a/client/internal/ipcauth/forward_test.go b/client/internal/ipcauth/forward_test.go new file mode 100644 index 000000000..d9adf05da --- /dev/null +++ b/client/internal/ipcauth/forward_test.go @@ -0,0 +1,214 @@ +package ipcauth + +import ( + "context" + "testing" + + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" +) + +// transportCtx builds a request context as the daemon's transport credentials +// would: the identity of whoever opened the socket, plus whatever metadata the +// request carried. +func transportCtx(id Identity, md metadata.MD) context.Context { + ctx := peer.NewContext(context.Background(), &peer.Peer{ + AuthInfo: AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, + }) + if md != nil { + ctx = metadata.NewIncomingContext(ctx, md) + } + return ctx +} + +var ( + root = Identity{UID: 0} + unprivUser = Identity{UID: 1000, GID: 1000} +) + +// asDaemon pins which identity counts as this process for the duration of a test. +// Without it the test binary's own uid decides, which silently changes what +// "the gateway" means. +func asDaemon(t *testing.T, id Identity) { + t.Helper() + prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate + t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate }) + selfIdentity, selfKnown = id, true + selfMayDelegate = !id.IsPrivileged() +} + +func TestCallerIdentity_DirectConnections(t *testing.T) { + t.Run("no transport credentials is not an identity", func(t *testing.T) { + if _, ok := CallerIdentity(context.Background()); ok { + t.Fatal("a caller with no credentials must not be identified") + } + }) + + t.Run("a direct caller is its transport identity", func(t *testing.T) { + id, ok := CallerIdentity(transportCtx(unprivUser, nil)) + if !ok || id.UID != 1000 { + t.Fatalf("got %v ok=%t, want uid 1000", id, ok) + } + }) + + // The whole point of honouring forwarded metadata only from a privileged + // transport peer: an unprivileged caller can set any metadata it likes on its + // own connection to the daemon socket. + t.Run("an unprivileged caller cannot forge an identity", func(t *testing.T) { + asDaemon(t, root) + forged := metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdGID, "0") + id, ok := CallerIdentity(transportCtx(unprivUser, forged)) + if !ok { + t.Fatal("caller should still be identified, as itself") + } + if id.IsPrivileged() || id.UID != 1000 { + t.Fatalf("forged metadata was believed: got %v", id) + } + }) +} + +func TestCallerIdentity_GatewayForwarding(t *testing.T) { + t.Run("the gateway's client identity is used, not the gateway's own", func(t *testing.T) { + asDaemon(t, root) + md := ForwardIdentityMetadata(unprivUser, true) + id, ok := CallerIdentity(transportCtx(root, md)) + if !ok { + t.Fatal("forwarded identity should be usable") + } + if id.IsPrivileged() || id.UID != 1000 { + t.Fatalf("got %v, want the forwarded uid 1000 and not privileged", id) + } + }) + + t.Run("a privileged gateway client stays privileged", func(t *testing.T) { + asDaemon(t, root) + md := ForwardIdentityMetadata(root, true) + id, ok := CallerIdentity(transportCtx(root, md)) + if !ok || !id.IsPrivileged() { + t.Fatalf("got %v ok=%t, want a privileged identity", id, ok) + } + }) + + // A JSON socket the gateway cannot read peer credentials from (a TCP socket, + // say) must not make every request look like the daemon itself. + t.Run("an unreadable client identity is unknown, not the daemon", func(t *testing.T) { + asDaemon(t, root) + md := ForwardIdentityMetadata(Identity{}, false) + if _, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatal("a forwarded request with no identity must not be identified") + } + }) + + // grpc-gateway turns Grpc-Metadata- headers into gRPC metadata and joins + // them ahead of its annotators' values. If an HTTP client's header survived + // that, this is the shape the daemon would see: the attacker's uid 0 first, + // the real uid second. The gateway filters those headers out, and reading a + // duplicated key as unknown makes the daemon safe even if it did not. + t.Run("a duplicated key from an injected header is not believed", func(t *testing.T) { + asDaemon(t, root) + md := metadata.MD{} + md.Append(mdFwd, "1") + md.Append(mdFwdUID, "0") // injected by the HTTP client + md.Append(mdFwdUID, "1000") // appended by the gateway's annotator + if id, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatalf("injected uid was accepted: got %v", id) + } + }) + + t.Run("a duplicated marker is not believed either", func(t *testing.T) { + asDaemon(t, root) + md := metadata.MD{} + md.Append(mdFwd, "1") + md.Append(mdFwd, "1") + md.Append(mdFwdUID, "1000") + // A repeated marker must not be read as "not forwarded": that would + // authorize the request as the transport peer, which on the gateway's + // connection is the daemon itself. + if id, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatalf("a duplicated marker was believed: got %v", id) + } + }) + + // The layers in front of this (the gateway's header matcher, and its + // interceptor replacing every forwarding key) are what keep outside metadata + // from arriving at all. The proof is what the daemon can check for itself, and + // it is the only defence that works for a value whose legitimate shape is + // indistinguishable from an injected one: a lone group SID, or "elevated". + t.Run("forwarding metadata without this process's proof is refused", func(t *testing.T) { + asDaemon(t, root) + for name, md := range map[string]metadata.MD{ + "no proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0"), + "wrong proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdProof, "deadbeef"), + "windows identity without a proof": metadata.Pairs(mdFwd, "1", + mdFwdSID, "S-1-5-21-1-2-3-1001", mdFwdGroup, sidAdministrators, mdFwdElevated, "1"), + } { + t.Run(name, func(t *testing.T) { + if id, ok := CallerIdentity(transportCtx(root, md)); ok { + t.Fatalf("unstamped forwarding metadata was believed: got %v", id) + } + }) + } + }) + + // A caller that reaches the gateway cannot see the proof, so it cannot append + // a group of its own to a genuine forwarded identity: doing so would have to + // go through the interceptor, which replaces the whole set. + t.Run("a group appended to a stamped identity does not survive the interceptor", func(t *testing.T) { + asDaemon(t, root) + injected := metadata.MD{} + injected.Append(mdFwdGroup, sidAdministrators) + + ctx := WithForwardedIdentity(metadata.NewOutgoingContext(context.Background(), injected), + Identity{SID: "S-1-5-21-1-2-3-1001"}, true) + out, ok := metadata.FromOutgoingContext(ctx) + if !ok { + t.Fatal("no outgoing metadata") + } + if groups := out.Get(mdFwdGroup); len(groups) != 0 { + t.Fatalf("injected group survived: %v", groups) + } + }) +} + +func TestIsReservedForwardKey(t *testing.T) { + for _, key := range forwardKeys { + if !IsReservedForwardKey(key) { + t.Errorf("%q must be reserved", key) + } + } + + // grpc-gateway canonicalises header names, so the check has to be + // case-insensitive. + if !IsReservedForwardKey("X-Netbird-Fwd-Uid") { + t.Error("the check must be case-insensitive") + } + + for _, key := range []string{"authorization", "x-netbird", "x-netbird-fwd-uid-extra", ""} { + if IsReservedForwardKey(key) { + t.Errorf("%q must not be reserved", key) + } + } +} + +func TestForwardIdentityMetadata_AlwaysMarksForwarded(t *testing.T) { + for _, tc := range []struct { + name string + id Identity + known bool + }{ + {"known unix identity", unprivUser, true}, + {"unknown identity", Identity{}, false}, + {"windows identity", Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + md := ForwardIdentityMetadata(tc.id, tc.known) + if got := md.Get(mdFwd); len(got) != 1 || got[0] != "1" { + t.Fatalf("marker = %v, want exactly one \"1\"", got) + } + }) + } +} diff --git a/client/internal/ipcauth/identity.go b/client/internal/ipcauth/identity.go new file mode 100644 index 000000000..ff70c209a --- /dev/null +++ b/client/internal/ipcauth/identity.go @@ -0,0 +1,127 @@ +// Package ipcauth provides the kernel-authenticated identity of a local IPC +// (gRPC) caller and the transport credentials that surface it into the gRPC +// context, so the daemon can authorize individual RPCs by caller identity. +// +// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or +// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the +// named-pipe client token. Platforms without a peer-identity primitive get no +// credentials, and every consumer must fail closed when no identity is +// available. +package ipcauth + +import ( + "context" + "fmt" + "slices" + + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" +) + +// Well-known Windows SIDs that identify a fully privileged principal. +const ( + sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM + sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE + sidNetworkService = "S-1-5-20" // NT AUTHORITY\NETWORK SERVICE + sidAdministrators = "S-1-5-32-544" // BUILTIN\Administrators +) + +// Identity is the kernel-authenticated identity of a local IPC caller. The +// zero value is not a valid identity: consumers must only use one obtained +// with a true ok/nil error return. +type Identity struct { + // UID and GID are the caller's Unix user ID and primary group ID. Both are + // zero on Windows, where SID is authoritative instead. + UID uint32 + GID uint32 + + // SID is the caller's Windows security identifier, empty on Unix. + SID string + + // Groups holds the caller's Windows group SIDs, captured from the client + // token at handshake time. Only groups that are enabled and not + // deny-only are captured, so a group listed here is one the caller can + // actually exercise. Empty on Unix. + Groups []string + + // Elevated reports whether the Windows client token is elevated (running + // as administrator, or an administrator with UAC turned off). Always false + // on Unix, where privilege is uid 0. + Elevated bool + + // PID is the caller's process ID where the platform reports it (Linux's + // SO_PEERCRED), and 0 where it does not. It identifies the daemon's own + // process dialling itself, which is what the JSON gateway does, and is never + // used to grant anything. + PID int32 +} + +// IsWindows reports whether this identity is a Windows principal (SID-based) +// rather than a Unix uid/gid principal. +func (i Identity) IsWindows() bool { + return i.SID != "" +} + +// IsPrivileged reports whether the caller is the platform's administrative +// principal, which is what the daemon requires for changes that cross the +// user-to-root boundary. +// +// On Windows the decision comes from the caller's token rather than from +// account names or group RIDs: an elevated token, one of the service accounts +// the daemon itself may run as, or a token with BUILTIN\Administrators +// enabled. A UAC-filtered administrator has that group marked deny-only, and +// deny-only groups are dropped when the identity is captured, so such a +// caller is correctly reported as unprivileged. Domain group memberships +// (Domain Admins and friends) are deliberately not consulted: they say +// nothing about what this token may do on this machine. +func (i Identity) IsPrivileged() bool { + if !i.IsWindows() { + return i.UID == 0 + } + + if i.Elevated { + return true + } + + switch i.SID { + case sidLocalSystem, sidLocalService, sidNetworkService: + return true + } + + return slices.Contains(i.Groups, sidAdministrators) +} + +// String renders the identity for audit logs and denial messages. +func (i Identity) String() string { + if i.IsWindows() { + return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated) + } + return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID) +} + +// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so +// handlers can retrieve it from the request context via IdentityFromContext. +type AuthInfo struct { + credentials.CommonAuthInfo + Identity Identity +} + +// AuthType identifies the authentication scheme. +func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" } + +// IdentityFromContext extracts the caller's kernel-authenticated identity from +// the gRPC peer context. The second return value is false when no IPC +// transport credentials were negotiated, which happens on a TCP daemon socket +// and on platforms without a peer-identity primitive. Callers MUST fail closed +// in that case. +func IdentityFromContext(ctx context.Context) (Identity, bool) { + p, ok := peer.FromContext(ctx) + if !ok { + return Identity{}, false + } + info, ok := p.AuthInfo.(AuthInfo) + if !ok { + return Identity{}, false + } + return info.Identity, true +} diff --git a/client/internal/ipcauth/ownedfile.go b/client/internal/ipcauth/ownedfile.go new file mode 100644 index 000000000..be7bf4864 --- /dev/null +++ b/client/internal/ipcauth/ownedfile.go @@ -0,0 +1,63 @@ +package ipcauth + +import ( + "fmt" + "os" +) + +// OpenOwnedFile opens path for reading on behalf of the IPC caller identified by +// id, and fails unless the opened file is a regular file that id owns. +// +// It exists for the paths a local caller hands to the daemon over the IPC. The +// daemon runs as root, so opening such a path unchecked lets any local user read +// any file through it. Ownership is the invariant that keeps the daemon from +// reading, with its own privileges, a file the caller could not read itself: a +// symlink or hard link planted at the path resolves to a file someone else owns +// and is refused. +// +// The check is made against the open descriptor rather than the path, so +// swapping the path between the check and the read cannot change the answer. +// +// A privileged caller is exempt: it can read the file directly, so refusing it +// here would protect nothing. The regular-file requirement still applies to +// everyone, since a fifo or device planted at the path is never a log file. +func OpenOwnedFile(id Identity, path string) (*os.File, error) { + f, err := openForRead(path) + if err != nil { + return nil, err + } + + if err := checkOwnership(id, f); err != nil { + if cerr := f.Close(); cerr != nil { + return nil, fmt.Errorf("%w (close: %v)", err, cerr) + } + return nil, err + } + + return f, nil +} + +func checkOwnership(id Identity, f *os.File) error { + info, err := f.Stat() + if err != nil { + return fmt.Errorf("stat %s: %w", f.Name(), err) + } + + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", f.Name()) + } + + if IsPrivilegedCaller(id) { + return nil + } + + owned, err := fileOwnedBy(id, f) + if err != nil { + return fmt.Errorf("read owner of %s: %w", f.Name(), err) + } + if !owned { + return fmt.Errorf("%s is not owned by the caller (%s)", f.Name(), id) + } + + return nil +} diff --git a/client/internal/ipcauth/ownedfile_test.go b/client/internal/ipcauth/ownedfile_test.go new file mode 100644 index 000000000..b8178ad45 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_test.go @@ -0,0 +1,64 @@ +package ipcauth + +import ( + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +// otherIdentity is an unprivileged caller that owns nothing the test creates. +func otherIdentity(t *testing.T) Identity { + t.Helper() + if runtime.GOOS == "windows" { + return Identity{SID: "S-1-5-21-1-2-3-1001"} + } + return Identity{UID: uint32(os.Geteuid() + 1), GID: uint32(os.Getegid() + 1)} +} + +func TestOpenOwnedFileReadsFileOwnedByCaller(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("hello"), 0600)) + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + f, err := OpenOwnedFile(id, path) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + content, err := io.ReadAll(f) + require.NoError(t, err) + require.Equal(t, "hello", string(content)) +} + +func TestOpenOwnedFileRefusesFileOwnedByAnother(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("secret"), 0600)) + + _, err := OpenOwnedFile(otherIdentity(t), path) + require.ErrorContains(t, err, "not owned by the caller") +} + +func TestOpenOwnedFileRefusesNonRegularFile(t *testing.T) { + dir := t.TempDir() + + // The caller owns the directory, so this is the regular-file requirement + // talking, not the ownership check. + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, dir) + require.ErrorContains(t, err, "not a regular file") +} + +func TestOpenOwnedFileRefusesMissingFile(t *testing.T) { + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, filepath.Join(t.TempDir(), "absent.log")) + require.Error(t, err) +} diff --git a/client/internal/ipcauth/ownedfile_unix.go b/client/internal/ipcauth/ownedfile_unix.go new file mode 100644 index 000000000..4a6afcea7 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_unix.go @@ -0,0 +1,35 @@ +//go:build !windows + +package ipcauth + +import ( + "fmt" + "os" + "syscall" +) + +// openForRead opens a caller-supplied path without following a symlink at its +// final component and without blocking: a fifo planted at the path would +// otherwise stall the open until a writer appears, and the daemon holds a lock +// while it collects the file. +func openForRead(path string) (*os.File, error) { + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + return f, nil +} + +func fileOwnedBy(id Identity, f *os.File) (bool, error) { + info, err := f.Stat() + if err != nil { + return false, err + } + + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return false, fmt.Errorf("no owner information in %T", info.Sys()) + } + + return stat.Uid == id.UID, nil +} diff --git a/client/internal/ipcauth/ownedfile_unix_test.go b/client/internal/ipcauth/ownedfile_unix_test.go new file mode 100644 index 000000000..9e7831991 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_unix_test.go @@ -0,0 +1,57 @@ +//go:build !windows + +package ipcauth + +import ( + "errors" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// A symlink is the shape the arbitrary-read attempt takes: the caller owns the +// link, the file it points at belongs to someone else. +func TestOpenOwnedFileRefusesSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("secret"), 0600)) + + link := filepath.Join(dir, "gui-client.log") + require.NoError(t, os.Symlink(target, link)) + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, link) + // O_NOFOLLOW on a symlink reports ELOOP on Linux/Darwin and EMLINK on FreeBSD. + if !errors.Is(err, syscall.ELOOP) && !errors.Is(err, syscall.EMLINK) { + t.Fatalf("symlink open: got %v, want ELOOP or EMLINK", err) + } +} + +// A fifo would block the open until a writer showed up, stalling the daemon +// while it holds its lock. +func TestOpenOwnedFileRefusesFifoWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, syscall.Mkfifo(path, 0600)) + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + done := make(chan error, 1) + go func() { + _, err := OpenOwnedFile(id, path) + done <- err + }() + + select { + case err := <-done: + require.ErrorContains(t, err, "not a regular file") + case <-time.After(5 * time.Second): + t.Fatal("opening a fifo blocked") + } +} diff --git a/client/internal/ipcauth/ownedfile_windows.go b/client/internal/ipcauth/ownedfile_windows.go new file mode 100644 index 000000000..19acaec4d --- /dev/null +++ b/client/internal/ipcauth/ownedfile_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package ipcauth + +import ( + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +// openForRead opens a caller-supplied path without following a reparse point at +// it. FILE_FLAG_OPEN_REPARSE_POINT is the Windows analogue of O_NOFOLLOW: it +// opens a symlink/junction itself rather than its target, so the regular-file +// check in checkOwnership refuses a link the caller planted to redirect the +// read. FILE_FLAG_BACKUP_SEMANTICS lets a directory open too (as os.Open does), +// so a directory planted at the path is refused as non-regular rather than +// erroring here. The share mode matches os.Open so a log being written stays +// openable. +func openForRead(path string) (*os.File, error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, fmt.Errorf("convert path %s: %w", path, err) + } + + handle, err := windows.CreateFile( + p, + windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, + 0, + ) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + + return os.NewFile(uintptr(handle), path), nil +} + +// fileOwnedBy compares the file's owner SID with the caller's. Files an elevated +// process creates are owned by BUILTIN\Administrators rather than by the user, +// but such a caller is privileged and never reaches this check. +func fileOwnedBy(id Identity, f *os.File) (bool, error) { + // x/sys/windows GetSecurityInfo frees the OS buffer itself and returns a + // Go-heap copy, so there is nothing to LocalFree here. + sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return false, fmt.Errorf("read security info: %w", err) + } + + owner, _, err := sd.Owner() + if err != nil { + return false, fmt.Errorf("read owner: %w", err) + } + + return id.SID != "" && owner.String() == id.SID, nil +} diff --git a/client/internal/ipcauth/ownedfile_windows_test.go b/client/internal/ipcauth/ownedfile_windows_test.go new file mode 100644 index 000000000..ab68fdf39 --- /dev/null +++ b/client/internal/ipcauth/ownedfile_windows_test.go @@ -0,0 +1,78 @@ +//go:build windows + +package ipcauth + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// fileOwnerSID reads the owner SID of path the same way OpenOwnedFile does, so +// the test can construct an Identity that matches (or deliberately does not). +func fileOwnerSID(t *testing.T, path string) string { + t.Helper() + f, err := os.Open(path) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + + sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION) + require.NoError(t, err) + owner, _, err := sd.Owner() + require.NoError(t, err) + return owner.String() +} + +// The allow branch of fileOwnedBy is the SID-equality path the legitimate GUI +// flow depends on. Running elevated, a created file is owned by +// BUILTIN\Administrators; an Identity carrying that SID with Elevated=false and +// no groups is unprivileged by IsPrivileged (which reads the token, not the +// SID's RID), so this exercises the real GetSecurityInfo equality rather than +// the privileged-caller shortcut. +func TestOpenOwnedFileWindowsOwnerMatchAllows(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("hello"), 0600)) + + ownerSID := fileOwnerSID(t, path) + id := Identity{SID: ownerSID} + require.False(t, id.IsPrivileged(), "identity built from the owner SID must be unprivileged for this to test the match path") + + f, err := OpenOwnedFile(id, path) + require.NoError(t, err) + _ = f.Close() +} + +func TestOpenOwnedFileWindowsOwnerMismatchRefuses(t *testing.T) { + path := filepath.Join(t.TempDir(), "gui-client.log") + require.NoError(t, os.WriteFile(path, []byte("secret"), 0600)) + + other := Identity{SID: "S-1-5-21-9-9-9-9999"} + require.False(t, other.IsPrivileged()) + + _, err := OpenOwnedFile(other, path) + require.ErrorContains(t, err, "not owned by the caller") +} + +// FILE_FLAG_OPEN_REPARSE_POINT must make OpenOwnedFile refuse a symlink the same +// way O_NOFOLLOW does on Unix, so a planted link can't redirect the read to +// another file. Creating a symlink needs a privilege the runner may lack, so the +// test skips rather than fails when it can't. +func TestOpenOwnedFileWindowsRefusesSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("secret"), 0600)) + + link := filepath.Join(dir, "gui-client.log") + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create symlink (privilege not held?): %v", err) + } + + id, err := CurrentProcessIdentity() + require.NoError(t, err) + + _, err = OpenOwnedFile(id, link) + require.Error(t, err, "a symlink must be refused") +} diff --git a/client/internal/ipcauth/peercred_bsd.go b/client/internal/ipcauth/peercred_bsd.go new file mode 100644 index 000000000..6d9c5247f --- /dev/null +++ b/client/internal/ipcauth/peercred_bsd.go @@ -0,0 +1,43 @@ +//go:build darwin || freebsd + +package ipcauth + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +// PeerIdentity reads the kernel-authenticated identity of the process on the +// other end of a Unix socket via LOCAL_PEERCRED. The xucred is recorded by the +// kernel at connect() time and carries the peer's uid and its group list, of +// which the first entry is the primary group. +func PeerIdentity(conn net.Conn) (Identity, error) { + uc, ok := conn.(*net.UnixConn) + if !ok { + return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn) + } + + raw, err := uc.SyscallConn() + if err != nil { + return Identity{}, fmt.Errorf("raw conn: %w", err) + } + + var cred *unix.Xucred + var credErr error + if err := raw.Control(func(fd uintptr) { + cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) + }); err != nil { + return Identity{}, fmt.Errorf("control raw conn: %w", err) + } + if credErr != nil { + return Identity{}, fmt.Errorf("read LOCAL_PEERCRED: %w", credErr) + } + + id := Identity{UID: cred.Uid} + if cred.Ngroups > 0 { + id.GID = cred.Groups[0] + } + return id, nil +} diff --git a/client/internal/ipcauth/peercred_linux.go b/client/internal/ipcauth/peercred_linux.go new file mode 100644 index 000000000..417cc1e00 --- /dev/null +++ b/client/internal/ipcauth/peercred_linux.go @@ -0,0 +1,39 @@ +//go:build linux + +package ipcauth + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +// PeerIdentity reads the kernel-authenticated identity of the process on the +// other end of a Unix socket via SO_PEERCRED. The credentials are recorded by +// the kernel at connect() time and cannot be changed for the life of the +// connection, so they are not spoofable by the caller. +func PeerIdentity(conn net.Conn) (Identity, error) { + uc, ok := conn.(*net.UnixConn) + if !ok { + return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn) + } + + raw, err := uc.SyscallConn() + if err != nil { + return Identity{}, fmt.Errorf("raw conn: %w", err) + } + + var cred *unix.Ucred + var credErr error + if err := raw.Control(func(fd uintptr) { + cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + }); err != nil { + return Identity{}, fmt.Errorf("control raw conn: %w", err) + } + if credErr != nil { + return Identity{}, fmt.Errorf("read SO_PEERCRED: %w", credErr) + } + + return Identity{UID: cred.Uid, GID: cred.Gid, PID: cred.Pid}, nil +} diff --git a/client/internal/ipcauth/pipeserver_windows.go b/client/internal/ipcauth/pipeserver_windows.go new file mode 100644 index 000000000..7ba59d574 --- /dev/null +++ b/client/internal/ipcauth/pipeserver_windows.go @@ -0,0 +1,87 @@ +//go:build windows + +package ipcauth + +import ( + "fmt" + "net" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +// PipeServerTrusted reports an error unless the pipe behind conn was created by a +// principal this client may hand secrets to. Clients call it for a pipe whose name +// carries no guarantee of its own, which is any name outside the +// ProtectedPrefix\Administrators namespace: that namespace already restricts +// creation to administrators and LocalSystem, while a plain name can be created by +// any local user before the daemon gets there. +// +// The decision is made from the pipe object's owner, not from the serving process, +// because a client cannot open a process running as another user at all, and the +// legitimate case is precisely an unprivileged client talking to a privileged +// daemon. Trusted owners are the service accounts, BUILTIN\Administrators, and +// this client's own user, the last of which is the daemon a user runs themselves +// as in netstack mode. A pipe owned by anyone else gets no setup key, pre-shared +// key or SSO prompt out of this client. +func PipeServerTrusted(conn net.Conn) error { + // go-winio's pipe connection embeds *win32File, which exposes Fd(). + fdConn, ok := conn.(interface{ Fd() uintptr }) + if !ok { + return fmt.Errorf("connection %T does not expose a pipe handle", conn) + } + + owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd())) + if err != nil { + return err + } + + if !trustedPipeOwner(owner) { + return fmt.Errorf("pipe owned by %s, which is neither an administrator nor this user", owner) + } + return nil +} + +// PipeOwnedBySelf reports whether the pipe behind conn was created by this very +// user, which is how a client recognises a daemon running as itself. Ownership it +// cannot read is reported as false. +func PipeOwnedBySelf(conn net.Conn) bool { + fdConn, ok := conn.(interface{ Fd() uintptr }) + if !ok { + return false + } + + owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd())) + if err != nil { + log.Debugf("read daemon pipe owner: %v", err) + return false + } + return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID +} + +// pipeOwnerSID reads the owner of the pipe object a client is connected to. The +// handle was opened with GENERIC_READ, which includes READ_CONTROL, so no extra +// access is needed. +func pipeOwnerSID(handle windows.Handle) (string, error) { + sd, err := windows.GetSecurityInfo(handle, windows.SE_KERNEL_OBJECT, windows.OWNER_SECURITY_INFORMATION) + if err != nil { + return "", fmt.Errorf("read pipe security info: %w", err) + } + + owner, _, err := sd.Owner() + if err != nil { + return "", fmt.Errorf("read pipe owner: %w", err) + } + return owner.String(), nil +} + +// trustedPipeOwner reports whether a pipe's owner is a principal a client may +// speak to. An elevated process's objects are owned by BUILTIN\Administrators by +// default, an unelevated one's by the user, which is why both forms appear here. +func trustedPipeOwner(owner string) bool { + switch owner { + case sidLocalSystem, sidLocalService, sidNetworkService, sidAdministrators: + return true + } + return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID +} diff --git a/client/internal/ipcauth/privileged.go b/client/internal/ipcauth/privileged.go new file mode 100644 index 000000000..95f2a50e9 --- /dev/null +++ b/client/internal/ipcauth/privileged.go @@ -0,0 +1,125 @@ +package ipcauth + +import ( + "os" + "runtime" +) + +// Fields of the ErrorInfo detail the daemon attaches to a PermissionDenied it +// raises for an operation that requires root/administrator. Clients match on +// Reason and Domain rather than on the message text, and render the summary and +// command themselves so the user gets guidance instead of a gRPC error dump. +const ( + // ErrorReasonPrivilegeRequired identifies the detail. + ErrorReasonPrivilegeRequired = "PRIVILEGE_REQUIRED" + // ErrorDomain scopes the reason to the NetBird daemon. + ErrorDomain = "daemon.netbird.io" + // ErrorMetaSummary is the one-sentence explanation of what was refused. + ErrorMetaSummary = "summary" + // ErrorMetaCommand is the command that performs the same operation with the + // privileges it needs, ready to copy and run. + ErrorMetaCommand = "command" +) + +// The identity of the process evaluating callers, captured once because it cannot +// change. selfKnown is false when it could not be read, in which case nothing is +// ever treated as this process. selfMayDelegate additionally requires this +// process to be unprivileged: see IsPrivilegedCaller. +var ( + selfIdentity Identity + selfKnown bool + selfMayDelegate bool + // selfPID is this process's PID, used to recognise the daemon dialling itself. + selfPID = os.Getpid() +) + +func init() { + id, err := CurrentProcessIdentity() + if err != nil { + return + } + selfIdentity, selfKnown = id, true + // Only an unprivileged daemon delegates its authority to its own identity. + // When it is root or LocalSystem, sharing its identity does not mean sharing + // its power: on Windows a filtered and a full token carry the same SID, so + // matching there would let a non-elevated shell of an administrator account + // act as an administrator, which is the boundary the token check exists to + // keep. + selfMayDelegate = !id.IsPrivileged() +} + +// IsDaemonSelf reports whether an identity is this very process. The JSON gateway +// runs inside the daemon and re-dials it locally, so this is what distinguishes +// the gateway from any other caller, whatever user the daemon runs as. +func IsDaemonSelf(id Identity) bool { + if !selfKnown || id.IsWindows() != selfIdentity.IsWindows() { + return false + } + if id.IsWindows() { + return id.SID != "" && id.SID == selfIdentity.SID + } + return id.UID == selfIdentity.UID +} + +// IsPrivilegedCaller reports whether an identity may make the changes the daemon +// restricts to the platform administrator. This is the daemon's own rule and +// cannot be evaluated by a client, which does not know what the daemon runs as. +// +// Beyond root/administrator it accepts a caller running as the daemon's own +// identity when the daemon is itself unprivileged. That keeps a rootless container +// working, where there is no uid 0 at all, and a Windows daemon in netstack mode, +// which needs no administrator rights. In those setups a caller sharing the +// daemon's identity can already rewrite the config files it reads and replace the +// binary it runs, so refusing it a config change would protect nothing; and an +// unprivileged daemon cannot hand out a root shell in the first place. +func IsPrivilegedCaller(id Identity) bool { + if id.IsPrivileged() { + return true + } + return selfMayDelegate && IsDaemonSelf(id) +} + +// SelfDelegatesTo returns the identity this process delegates its authority to, +// and whether it delegates at all. Only an unprivileged daemon does: see +// IsPrivilegedCaller. It exists so a refusal can name who may actually perform the +// operation, because on such a host root is neither required nor necessarily +// available. +func SelfDelegatesTo() (Identity, bool) { + if !selfKnown || !selfMayDelegate { + return Identity{}, false + } + return selfIdentity, true +} + +// PrivilegedActor names the principal a privileged operation requires, for use +// in messages shown to the user. +func PrivilegedActor() string { + if runtime.GOOS == "windows" { + return "administrator privileges" + } + return "root" +} + +// ElevatedCommand renders a command so that running it grants the privileges the +// operation needs. Windows has no in-line equivalent of sudo, so the command is +// returned unchanged and the user is expected to run it from an elevated +// terminal. +func ElevatedCommand(command string) string { + if runtime.GOOS == "windows" { + return command + } + return "sudo " + command +} + +// UpCommand renders an elevated `netbird up` with the given flags, preceded by a +// `down`. The down is what makes the command work on a connected client: `netbird +// up` prints "Already connected" and returns without applying any config flag, so +// on its own the command would appear to do nothing. It is a no-op, exit 0, when +// the client is not connected. +// +// ";" rather than "&&" so the line can be pasted into any of the shells a user +// might have: PowerShell 5.1, still the default on Windows Server, rejects "&&" +// as a syntax error. +func UpCommand(flags string) string { + return ElevatedCommand("netbird down") + "; " + ElevatedCommand("netbird up "+flags) +} diff --git a/client/internal/ipcauth/privileged_test.go b/client/internal/ipcauth/privileged_test.go new file mode 100644 index 000000000..c1c7c1543 --- /dev/null +++ b/client/internal/ipcauth/privileged_test.go @@ -0,0 +1,134 @@ +package ipcauth + +import "testing" + +// The self rule is the one place privilege is granted to something other than the +// platform administrator, so its two guards matter: it must apply only when the +// daemon is itself unprivileged, and only to a caller with the daemon's identity. +func TestIsPrivilegedCaller_SelfRule(t *testing.T) { + tests := []struct { + name string + // self stands in for the process the daemon runs as. + self Identity + selfKnown bool + caller Identity + want bool + }{ + { + name: "root is privileged whatever the daemon runs as", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{UID: 0}, + want: true, + }, + { + name: "an unprivileged daemon delegates to its own user (rootless container)", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{UID: 1000}, + want: true, + }, + { + name: "an unprivileged daemon delegates to nobody else", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{UID: 1001}, + want: false, + }, + { + // The daemon is root on a normal install, so sharing its identity is + // already covered by being root; nothing else may match. + name: "a root daemon delegates to nobody", + self: Identity{UID: 0}, + selfKnown: true, + caller: Identity{UID: 1000}, + want: false, + }, + { + // Windows netstack mode: the daemon needs no administrator rights. + name: "an unprivileged windows daemon delegates to its own SID", + self: Identity{SID: "S-1-5-21-1-2-3-1001"}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-1001"}, + want: true, + }, + { + name: "an unprivileged windows daemon delegates to no other SID", + self: Identity{SID: "S-1-5-21-1-2-3-1001"}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-1002"}, + want: false, + }, + { + // The UAC boundary: a filtered and a full token of the same account + // carry the same SID but not the same power, so an elevated daemon must + // never delegate to its own SID. + name: "an elevated windows daemon does not delegate to its own SID", + self: Identity{SID: "S-1-5-21-1-2-3-500", Elevated: true}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-500"}, + want: false, + }, + { + name: "LocalSystem is privileged on its own merits, not by delegation", + self: Identity{SID: sidLocalSystem}, + selfKnown: true, + caller: Identity{SID: sidLocalSystem}, + want: true, // LocalSystem is privileged on its own merits + }, + { + name: "identities of different kinds never match", + self: Identity{UID: 1000}, + selfKnown: true, + caller: Identity{SID: "S-1-5-21-1-2-3-1001"}, + want: false, + }, + { + name: "an unknown self identity delegates to nobody", + self: Identity{}, + selfKnown: false, + caller: Identity{UID: 1000}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate + t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate }) + + selfIdentity, selfKnown = tt.self, tt.selfKnown + selfMayDelegate = tt.selfKnown && !tt.self.IsPrivileged() + + if got := IsPrivilegedCaller(tt.caller); got != tt.want { + t.Fatalf("IsPrivilegedCaller(%v) with daemon %v = %t, want %t", + tt.caller, tt.self, got, tt.want) + } + }) + } +} + +// The real process must never accidentally delegate: a test binary running as a +// normal user is unprivileged, so it may match itself, but nothing else. +func TestIsPrivilegedCaller_ThisProcess(t *testing.T) { + id, err := CurrentProcessIdentity() + if err != nil { + t.Skipf("cannot read this process's identity: %v", err) + } + + // This process is always allowed to act as itself: either it is privileged, or + // it is unprivileged and therefore delegates to its own identity. + if !IsPrivilegedCaller(id) { + t.Errorf("this process %v was refused its own identity", id) + } + + // A caller that is neither root nor this process must be refused, whatever + // this process happens to be. + other := Identity{UID: id.UID + 1} + if id.IsWindows() { + other = Identity{SID: id.SID + "9"} + } + if IsPrivilegedCaller(other) { + t.Errorf("an unrelated identity %v was treated as privileged", other) + } +} diff --git a/client/internal/ipcauth/self_unix.go b/client/internal/ipcauth/self_unix.go new file mode 100644 index 000000000..1b86c4fc0 --- /dev/null +++ b/client/internal/ipcauth/self_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package ipcauth + +import "os" + +// CurrentProcessIdentity returns this process's identity as the daemon would +// see it if this process connected to the local IPC. It lets a client (the UI) +// decide up front whether a privileged operation can succeed, without a +// round-trip and without duplicating the rules: the answer comes from the same +// Identity.IsPrivileged the daemon applies. +func CurrentProcessIdentity() (Identity, error) { + return Identity{ + UID: uint32(os.Geteuid()), + GID: uint32(os.Getegid()), + }, nil +} diff --git a/client/internal/ipcauth/self_windows.go b/client/internal/ipcauth/self_windows.go new file mode 100644 index 000000000..5474cc101 --- /dev/null +++ b/client/internal/ipcauth/self_windows.go @@ -0,0 +1,35 @@ +//go:build windows + +package ipcauth + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// CurrentProcessIdentity returns this process's identity as the daemon would see +// it if this process connected to the local IPC. It lets a client (the UI) +// decide up front whether a privileged operation can succeed, without a +// round-trip and without duplicating the rules: the answer comes from the same +// Identity.IsPrivileged the daemon applies to the token it reads off the pipe. +func CurrentProcessIdentity() (Identity, error) { + // A pseudo-token, so it must not be closed. + token := windows.GetCurrentProcessToken() + + user, err := token.GetTokenUser() + if err != nil { + return Identity{}, fmt.Errorf("read token user: %w", err) + } + + groups, err := tokenGroupSIDs(token) + if err != nil { + return Identity{}, err + } + + return Identity{ + SID: user.User.Sid.String(), + Groups: groups, + Elevated: token.IsElevated(), + }, nil +} diff --git a/client/internal/lazyconn/activity/listener_bind.go b/client/internal/lazyconn/activity/listener_bind.go index 60b8baadb..72a0cfc76 100644 --- a/client/internal/lazyconn/activity/listener_bind.go +++ b/client/internal/lazyconn/activity/listener_bind.go @@ -119,15 +119,16 @@ func (d *BindListener) ReadPackets() { } d.peerCfg.Log.Debugf("removing lazy endpoint for peer %s", d.peerCfg.PublicKey) - if err := d.wgIface.RemovePeer(d.peerCfg.PublicKey); err != nil { - d.peerCfg.Log.Errorf("failed to remove endpoint: %s", err) - } - _ = d.lazyConn.Close() d.bind.RemoveEndpoint(d.fakeIP) d.done.Done() } +// CapturedPacket is unused in userspace bind mode: first-packet reinjection is kernel-only. +func (d *BindListener) CapturedPacket() []byte { + return nil +} + // Close stops the listener and cleans up resources. func (d *BindListener) Close() { d.peerCfg.Log.Infof("closing activity listener (LazyConn)") diff --git a/client/internal/lazyconn/activity/listener_bind_test.go b/client/internal/lazyconn/activity/listener_bind_test.go index 1baaae6be..7026a9c97 100644 --- a/client/internal/lazyconn/activity/listener_bind_test.go +++ b/client/internal/lazyconn/activity/listener_bind_test.go @@ -45,10 +45,6 @@ type MockWGIfaceBind struct { endpointMgr *mockEndpointManager } -func (m *MockWGIfaceBind) RemovePeer(string) error { - return nil -} - func (m *MockWGIfaceBind) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error { return nil } @@ -68,6 +64,10 @@ func (m *MockWGIfaceBind) GetBind() device.EndpointManager { return m.endpointMgr } +func (m *MockWGIfaceBind) MTU() uint16 { + return 1280 +} + func TestBindListener_Creation(t *testing.T) { mockEndpointMgr := newMockEndpointManager() mockIface := &MockWGIfaceBind{endpointMgr: mockEndpointMgr} @@ -207,8 +207,9 @@ func TestManager_BindMode(t *testing.T) { require.NoError(t, err) select { - case peerConnID := <-mgr.OnActivityChan: - assert.Equal(t, cfg.PeerConnID, peerConnID, "Received peer connection ID should match") + case ev := <-mgr.OnActivityChan: + assert.Equal(t, cfg.PeerConnID, ev.PeerConnID, "Received peer connection ID should match") + assert.Nil(t, ev.FirstPacket, "Bind mode does not capture packets: reinjection is kernel-only") case <-time.After(2 * time.Second): t.Fatal("timeout waiting for activity notification") } @@ -266,8 +267,8 @@ func TestManager_BindMode_MultiplePeers(t *testing.T) { receivedPeers := make(map[peerid.ConnID]bool) for i := 0; i < 2; i++ { select { - case peerConnID := <-mgr.OnActivityChan: - receivedPeers[peerConnID] = true + case ev := <-mgr.OnActivityChan: + receivedPeers[ev.PeerConnID] = true case <-time.After(2 * time.Second): t.Fatal("timeout waiting for activity notifications") } diff --git a/client/internal/lazyconn/activity/listener_udp.go b/client/internal/lazyconn/activity/listener_udp.go index e0b09be6c..4b7e0ddf7 100644 --- a/client/internal/lazyconn/activity/listener_udp.go +++ b/client/internal/lazyconn/activity/listener_udp.go @@ -3,11 +3,13 @@ package activity import ( "fmt" "net" + "slices" "sync" "sync/atomic" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/iface/bufsize" "github.com/netbirdio/netbird/client/internal/lazyconn" ) @@ -20,6 +22,8 @@ type UDPListener struct { done sync.Mutex isClosed atomic.Bool + + capturedPacket []byte } // NewUDPListener creates a listener that detects activity via UDP socket reads. @@ -46,9 +50,13 @@ func NewUDPListener(wgIface WgInterface, cfg lazyconn.PeerConfig) (*UDPListener, } // ReadPackets blocks reading from the UDP socket until activity is detected or the listener is closed. +// The first packet that triggers activity is captured so it can be reinjected through the real +// transport once it is established. Without this, kernel WireGuard's handshake initiation would be +// dropped and WG would only retry after REKEY_TIMEOUT. func (d *UDPListener) ReadPackets() { for { - n, remoteAddr, err := d.conn.ReadFromUDP(make([]byte, 1)) + buf := make([]byte, int(d.wgIface.MTU())+bufsize.WGBufferOverhead) + n, remoteAddr, err := d.conn.ReadFromUDP(buf) if err != nil { if d.isClosed.Load() { d.peerCfg.Log.Infof("exit from activity listener") @@ -62,20 +70,24 @@ func (d *UDPListener) ReadPackets() { d.peerCfg.Log.Warnf("received %d bytes from %s, too short", n, remoteAddr) continue } - d.peerCfg.Log.Infof("activity detected") + d.capturedPacket = slices.Clone(buf[:n]) + d.peerCfg.Log.Infof("activity detected, captured %d bytes for reinjection", n) break } - d.peerCfg.Log.Debugf("removing lazy endpoint: %s", d.endpoint.String()) - if err := d.wgIface.RemovePeer(d.peerCfg.PublicKey); err != nil { - d.peerCfg.Log.Errorf("failed to remove endpoint: %s", err) - } - - // Ignore close error as it may return "use of closed network connection" if already closed. + // Leave the peer in place. ConfigureWGEndpoint will UpdatePeer with the real endpoint; + // removing the peer here wipes kernel WG's staged queue and drops the user packet that + // triggered activation. _ = d.conn.Close() d.done.Unlock() } +// CapturedPacket returns the first packet that triggered activity, or nil if none was captured. +// Safe to call after ReadPackets returns. +func (d *UDPListener) CapturedPacket() []byte { + return d.capturedPacket +} + // Close stops the listener and cleans up resources. func (d *UDPListener) Close() { d.peerCfg.Log.Infof("closing activity listener: %s", d.conn.LocalAddr().String()) diff --git a/client/internal/lazyconn/activity/manager.go b/client/internal/lazyconn/activity/manager.go index cccc0669f..9de8c0fa7 100644 --- a/client/internal/lazyconn/activity/manager.go +++ b/client/internal/lazyconn/activity/manager.go @@ -19,17 +19,25 @@ import ( type listener interface { ReadPackets() Close() + CapturedPacket() []byte +} + +// Event reports activity on a managed peer. FirstPacket is the bytes that triggered activation, +// captured for reinjection through the real transport. +type Event struct { + PeerConnID peerid.ConnID + FirstPacket []byte } type WgInterface interface { - RemovePeer(peerKey string) error UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error IsUserspaceBind() bool Address() wgaddr.Address + MTU() uint16 } type Manager struct { - OnActivityChan chan peerid.ConnID + OnActivityChan chan Event wgIface WgInterface @@ -41,7 +49,7 @@ type Manager struct { func NewManager(wgIface WgInterface) *Manager { m := &Manager{ - OnActivityChan: make(chan peerid.ConnID, 1), + OnActivityChan: make(chan Event, 1), wgIface: wgIface, peers: make(map[peerid.ConnID]listener), done: make(chan struct{}), @@ -116,12 +124,12 @@ func (m *Manager) waitForTraffic(l listener, peerConnID peerid.ConnID) { delete(m.peers, peerConnID) m.mu.Unlock() - m.notify(peerConnID) + m.notify(Event{PeerConnID: peerConnID, FirstPacket: l.CapturedPacket()}) } -func (m *Manager) notify(peerConnID peerid.ConnID) { +func (m *Manager) notify(ev Event) { select { case <-m.done: - case m.OnActivityChan <- peerConnID: + case m.OnActivityChan <- ev: } } diff --git a/client/internal/lazyconn/activity/manager_test.go b/client/internal/lazyconn/activity/manager_test.go index 0768d9219..07dd8d84c 100644 --- a/client/internal/lazyconn/activity/manager_test.go +++ b/client/internal/lazyconn/activity/manager_test.go @@ -1,6 +1,7 @@ package activity import ( + "bytes" "net" "net/netip" "testing" @@ -25,10 +26,6 @@ func (m *MocPeer) ConnID() peerid.ConnID { type MocWGIface struct { } -func (m MocWGIface) RemovePeer(string) error { - return nil -} - func (m MocWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error { return nil } @@ -44,6 +41,10 @@ func (m MocWGIface) Address() wgaddr.Address { } } +func (m MocWGIface) MTU() uint16 { + return 1280 +} + // GetPeerListener is a test helper to access listeners func (m *Manager) GetPeerListener(peerConnID peerid.ConnID) (listener, bool) { m.mu.Lock() @@ -86,11 +87,15 @@ func TestManager_MonitorPeerActivity(t *testing.T) { } select { - case peerConnID := <-mgr.OnActivityChan: - if peerConnID != peerCfg1.PeerConnID { - t.Fatalf("unexpected peerConnID: %v", peerConnID) + case ev := <-mgr.OnActivityChan: + if ev.PeerConnID != peerCfg1.PeerConnID { + t.Fatalf("unexpected peerConnID: %v", ev.PeerConnID) + } + if !bytes.Equal(ev.FirstPacket, []byte{0x01, 0x02, 0x03, 0x04, 0x05}) { + t.Fatalf("unexpected first packet: %v", ev.FirstPacket) } case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for activity") } } diff --git a/client/internal/lazyconn/env.go b/client/internal/lazyconn/env.go index 649d1cd65..d408083e7 100644 --- a/client/internal/lazyconn/env.go +++ b/client/internal/lazyconn/env.go @@ -3,24 +3,57 @@ package lazyconn import ( "os" "strconv" + "strings" log "github.com/sirupsen/logrus" ) const ( - EnvEnableLazyConn = "NB_ENABLE_EXPERIMENTAL_LAZY_CONN" + EnvLazyConn = "NB_LAZY_CONN" EnvInactivityThreshold = "NB_LAZY_CONN_INACTIVITY_THRESHOLD" ) -func IsLazyConnEnabledByEnv() bool { - val := os.Getenv(EnvEnableLazyConn) - if val == "" { - return false - } - enabled, err := strconv.ParseBool(val) - if err != nil { - log.Warnf("failed to parse %s: %v", EnvEnableLazyConn, err) - return false - } - return enabled +// State is the tri-state local override for lazy connections read from the environment. +type State int + +const ( + // StateUnset means no local override; defer to the management feature flag. + StateUnset State = iota + // StateOn forces lazy connections on, overriding management. + StateOn + // StateOff forces lazy connections off, overriding management. + StateOff +) + +// EnvState reads NB_LAZY_CONN and returns the local override state. +func EnvState() State { + return ParseState(os.Getenv(EnvLazyConn)) +} + +// ParseState interprets a lazy-connection override value (from the environment or an MDM +// policy). It accepts the on/off aliases plus any value strconv.ParseBool understands +// (true/false/1/0). An empty or unrecognized value returns StateUnset so that the +// management feature flag remains in control. +func ParseState(raw string) State { + if raw == "" { + return StateUnset + } + + normalized := strings.ToLower(strings.TrimSpace(raw)) + switch normalized { + case "on": + return StateOn + case "off": + return StateOff + } + + enabled, err := strconv.ParseBool(normalized) + if err != nil { + log.Warnf("failed to parse lazy connection value %q (from %s env or MDM policy): %v", raw, EnvLazyConn, err) + return StateUnset + } + if enabled { + return StateOn + } + return StateOff } diff --git a/client/internal/lazyconn/env_test.go b/client/internal/lazyconn/env_test.go new file mode 100644 index 000000000..59ee40c4b --- /dev/null +++ b/client/internal/lazyconn/env_test.go @@ -0,0 +1,45 @@ +package lazyconn + +import ( + "os" + "testing" +) + +func TestEnvState(t *testing.T) { + tests := []struct { + value string + set bool + want State + }{ + {set: false, want: StateUnset}, + {value: "", set: true, want: StateUnset}, + {value: "on", set: true, want: StateOn}, + {value: "ON", set: true, want: StateOn}, + {value: "true", set: true, want: StateOn}, + {value: "1", set: true, want: StateOn}, + {value: " on ", set: true, want: StateOn}, + {value: "off", set: true, want: StateOff}, + {value: "OFF", set: true, want: StateOff}, + {value: "false", set: true, want: StateOff}, + {value: "0", set: true, want: StateOff}, + {value: "auto", set: true, want: StateUnset}, + {value: "garbage", set: true, want: StateUnset}, + } + + for _, tt := range tests { + name := tt.value + if !tt.set { + name = "unset" + } + t.Run(name, func(t *testing.T) { + t.Setenv(EnvLazyConn, tt.value) + if !tt.set { + os.Unsetenv(EnvLazyConn) + } + + if got := EnvState(); got != tt.want { + t.Fatalf("EnvState() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/client/internal/lazyconn/manager/manager.go b/client/internal/lazyconn/manager/manager.go index fc47bda39..b7424bb2f 100644 --- a/client/internal/lazyconn/manager/manager.go +++ b/client/internal/lazyconn/manager/manager.go @@ -29,6 +29,11 @@ type managedPeer struct { type Config struct { InactivityThreshold *time.Duration + // ReconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is + // armed. The activity listener creates the wake peer with the overlay /32 only; without the + // routed prefixes WireGuard would not steer subnet-bound traffic to the wake endpoint, so an + // idle routing peer could never be woken by that traffic. Optional; nil disables the reconcile. + ReconcileAllowedIPs func(peerKey string) error } // Manager manages lazy connections @@ -56,6 +61,9 @@ type Manager struct { peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group routesMu sync.RWMutex + + // reconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is armed. + reconcileAllowedIPs func(peerKey string) error } // NewManager creates a new lazy connection manager @@ -73,6 +81,7 @@ func NewManager(config Config, engineCtx context.Context, peerStore *peerstore.S activityManager: activity.NewManager(wgIface), peerToHAGroups: make(map[string][]route.HAUniqueID), haGroupToPeers: make(map[route.HAUniqueID][]string), + reconcileAllowedIPs: config.ReconcileAllowedIPs, } if wgIface.IsUserspaceBind() { @@ -130,8 +139,8 @@ func (m *Manager) Start(ctx context.Context) { select { case <-ctx.Done(): return - case peerConnID := <-m.activityManager.OnActivityChan: - m.onPeerActivity(peerConnID) + case ev := <-m.activityManager.OnActivityChan: + m.onPeerActivity(ev) case peerIDs := <-m.inactivityManager.InactivePeersChan(): m.onPeerInactivityTimedOut(peerIDs) } @@ -201,7 +210,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) { return false, nil } - if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil { + if err := m.armActivityListener(peerCfg); err != nil { return false, err } @@ -288,7 +297,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) { m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey) - if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil { + if err := m.armActivityListener(*mp.peerCfg); err != nil { mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err) return } @@ -465,6 +474,31 @@ func (m *Manager) close() { } // shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements +// armRoutedAllowedIPs re-applies the peer's routed allowed IPs onto its freshly armed wake +// endpoint. The activity listener creates the wake peer with the overlay /32 only, so without +// this the routed prefixes would be missing and traffic to a routed subnet could not wake the +// idle routing peer. It is a no-op when no reconciler is configured. +// armActivityListener (re)arms the peer's wake endpoint via the activity manager and then +// re-applies its routed allowed IPs, so traffic to a routed subnet can wake an idle routing +// peer. The routed prefixes must be re-applied after the wake endpoint exists because the +// listener creates it with the overlay /32 only. +func (m *Manager) armActivityListener(peerCfg lazyconn.PeerConfig) error { + if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil { + return err + } + m.armRoutedAllowedIPs(&peerCfg) + return nil +} + +func (m *Manager) armRoutedAllowedIPs(peerCfg *lazyconn.PeerConfig) { + if m.reconcileAllowedIPs == nil { + return + } + if err := m.reconcileAllowedIPs(peerCfg.PublicKey); err != nil { + peerCfg.Log.Errorf("failed to reconcile routed allowed IPs on wake endpoint: %v", err) + } +} + func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool { m.routesMu.RLock() defer m.routesMu.RUnlock() @@ -513,13 +547,13 @@ func (m *Manager) checkHaGroupActivity(haGroup route.HAUniqueID, peerID string, return false } -func (m *Manager) onPeerActivity(peerConnID peerid.ConnID) { +func (m *Manager) onPeerActivity(ev activity.Event) { m.managedPeersMu.Lock() defer m.managedPeersMu.Unlock() - mp, ok := m.managedPeersByConnID[peerConnID] + mp, ok := m.managedPeersByConnID[ev.PeerConnID] if !ok { - log.Errorf("peer not found by conn id: %v", peerConnID) + log.Errorf("peer not found by conn id: %v", ev.PeerConnID) return } @@ -536,7 +570,7 @@ func (m *Manager) onPeerActivity(peerConnID peerid.ConnID) { m.activateHAGroupPeers(mp.peerCfg) - m.peerStore.PeerConnOpen(m.engineCtx, mp.peerCfg.PublicKey) + m.peerStore.PeerConnOpenWithFirstPacket(m.engineCtx, mp.peerCfg.PublicKey, ev.FirstPacket) } func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) { @@ -577,7 +611,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) { mp.peerCfg.Log.Infof("start activity monitor") - if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil { + if err := m.armActivityListener(*mp.peerCfg); err != nil { mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err) continue } diff --git a/client/internal/lazyconn/wgiface.go b/client/internal/lazyconn/wgiface.go index 0626c1815..f003ab3cf 100644 --- a/client/internal/lazyconn/wgiface.go +++ b/client/internal/lazyconn/wgiface.go @@ -17,4 +17,5 @@ type WGIface interface { IsUserspaceBind() bool Address() wgaddr.Address LastActivities() map[string]monotime.Time + MTU() uint16 } diff --git a/client/internal/metrics/connection_type.go b/client/internal/metrics/connection_type.go index a3406a6b8..d393e5112 100644 --- a/client/internal/metrics/connection_type.go +++ b/client/internal/metrics/connection_type.go @@ -4,11 +4,17 @@ package metrics type ConnectionType string const ( - // ConnectionTypeICE represents a direct peer-to-peer connection using ICE - ConnectionTypeICE ConnectionType = "ice" + // ConnectionTypeICEP2P represents a direct peer-to-peer connection using ICE + ConnectionTypeICEP2P ConnectionType = "ice_p2p" + + // ConnectionTypeICETurn represents an ICE connection through a TURN server + ConnectionTypeICETurn ConnectionType = "ice_turn" // ConnectionTypeRelay represents a relayed connection ConnectionTypeRelay ConnectionType = "relay" + + // ConnectionTypeUnknown represents a connection with no active transport. It is not pushed. + ConnectionTypeUnknown ConnectionType = "unknown" ) // String returns the string representation of the connection type diff --git a/client/internal/metrics/env.go b/client/internal/metrics/env.go index 1f06ce484..c19dcc7f1 100644 --- a/client/internal/metrics/env.go +++ b/client/internal/metrics/env.go @@ -60,6 +60,13 @@ func getMetricsInterval() time.Duration { return interval } +// isMetricsPushEnvSet returns true if NB_METRICS_PUSH_ENABLED is explicitly set (to any value). +// When set, the env var takes full precedence over management server configuration. +func isMetricsPushEnvSet() bool { + _, set := os.LookupEnv(EnvMetricsPushEnabled) + return set +} + func isForceSending() bool { force, _ := strconv.ParseBool(os.Getenv(EnvMetricsForceSending)) return force diff --git a/client/internal/metrics/influxdb.go b/client/internal/metrics/influxdb.go index 531f6a986..4ba14bf44 100644 --- a/client/internal/metrics/influxdb.go +++ b/client/internal/metrics/influxdb.go @@ -120,6 +120,30 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI m.trimLocked() } +func (m *influxDBMetrics) RecordSyncPhase(_ context.Context, agentInfo AgentInfo, phase string, duration time.Duration) { + tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,phase=%s", + agentInfo.DeploymentType.String(), + agentInfo.Version, + agentInfo.OS, + agentInfo.Arch, + agentInfo.peerID, + phase, + ) + + m.mu.Lock() + defer m.mu.Unlock() + + m.samples = append(m.samples, influxSample{ + measurement: "netbird_sync_phase", + tags: tags, + fields: map[string]float64{ + "duration_seconds": duration.Seconds(), + }, + timestamp: time.Now(), + }) + m.trimLocked() +} + func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) { result := "success" if !success { diff --git a/client/internal/metrics/influxdb_test.go b/client/internal/metrics/influxdb_test.go index b964e31a3..6a226fe2f 100644 --- a/client/internal/metrics/influxdb_test.go +++ b/client/internal/metrics/influxdb_test.go @@ -28,7 +28,7 @@ func TestInfluxDBMetrics_RecordAndExport(t *testing.T) { WgHandshakeSuccess: time.Now().Add(-1 * time.Second), } - m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts) + m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts) var buf bytes.Buffer err := m.Export(&buf) @@ -60,7 +60,7 @@ func TestInfluxDBMetrics_ExportDeterministicFieldOrder(t *testing.T) { // Record multiple times and verify consistent field order for i := 0; i < 10; i++ { - m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts) + m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICEP2P, false, ts) } var buf bytes.Buffer diff --git a/client/internal/metrics/infra/README.md b/client/internal/metrics/infra/README.md index 5a93dbd87..0a69404df 100644 --- a/client/internal/metrics/infra/README.md +++ b/client/internal/metrics/infra/README.md @@ -56,14 +56,33 @@ Measurement: `netbird_peer_connection` Tags: - `deployment_type`: "cloud" | "selfhosted" | "unknown" -- `connection_type`: "ice" | "relay" +- `connection_type`: "ice_p2p" | "ice_turn" | "relay" (see below) - `attempt_type`: "initial" | "reconnection" - `version`: NetBird version string - `os`: Operating system (linux, darwin, windows, android, ios, etc.) - `arch`: CPU architecture (amd64, arm64, etc.) +- `peer_id`: anonymised peer identifier (truncated SHA-256 of the WireGuard public key) +- `connection_pair_id`: deterministic identifier for the peer pair, identical on both sides **Note:** `SignalingReceived` is set when the first offer or answer arrives from the remote peer (in both initial and reconnection paths). It excludes the potentially unbounded wait for the remote peer to come online. +#### `connection_type` values + +Derived from the connection priority (`conntype.ConnPriority`) by `metricsConnType` in `client/internal/peer/conn.go`: + +| Value | Priority | Traffic is | +|-------|----------|------------| +| `ice_p2p` | `ICEP2P` | direct peer-to-peer | +| `ice_turn` | `ICETurn` | relayed, through a TURN server | +| `relay` | `Relay` | relayed, through a NetBird relay | +| `unknown` | `None` or unrecognised | no active transport — **the sample is not pushed** | + +**Direct traffic is `ice_p2p` only.** `ice_turn` is relayed despite being negotiated by ICE, matching `Conn.isRelayed`. + +`None` means no transport is active: not established yet, or reset after a relay drop or a peer-state reset. Such a sample cannot be attributed to a transport, so `recordConnectionMetrics` drops it instead of pushing it — `unknown` therefore never appears in the bucket. Connection counts are counts of connections whose transport was known at sampling time. + +**Samples recorded before 0.77 used a single `ice` value** which covered `ICEP2P`, `ICETurn` *and* `None`, so historical `ice` samples overstate direct connections by an unknown amount and must not be compared with `ice_p2p`. + ### Sync Duration Measurement: `netbird_sync` @@ -78,6 +97,25 @@ Tags: - `os`: Operating system (linux, darwin, windows, android, ios, etc.) - `arch`: CPU architecture (amd64, arm64, etc.) +### Sync Phase Timing + +Measurement: `netbird_sync_phase` + +Breaks down where time goes inside a single sync, so the total `netbird_sync` duration can be attributed to the sub-step that dominates. + +| Field | Description | +|-------|-------------| +| `duration_seconds` | Time spent in one sub-phase of sync processing | + +Tags: +- `phase`: the sub-phase — `netbird_config`, `checks`, `persist`, `dns_server`, `routes_classify`, `routes_apply`, `filtering`, `dns_forwarder`, `forward_rules`, `offline_peers`, `removed_peers`, `modified_peers`, `added_peers`, `lazy_exclude` +- `deployment_type`: "cloud" | "selfhosted" | "unknown" +- `version`: NetBird version string +- `os`: Operating system (linux, darwin, windows, android, ios, etc.) +- `arch`: CPU architecture (amd64, arm64, etc.) + +**Note:** this is wall-time per phase — it includes both CPU work and time spent waiting on locks. A slow phase points to *where* the time goes, not *why*; pair it with lock-wait metrics to tell contention apart from real work. + ### Login Duration Measurement: `netbird_login` @@ -191,4 +229,52 @@ docker compose exec influxdb influx query \ # Check ingest server health curl http://localhost:8087/health -``` \ No newline at end of file +``` + +## Analyzing a Debug Bundle + +Metrics collection is always on, so every debug bundle ships a `metrics.txt` in InfluxDB line protocol — a timestamped time series of all recorded events (sync durations, sync phases, connection stages, login). You can replay it into the local stack and graph it, without a running client. + +The bundle's `metrics.txt` is a rolling window (capped at 5 days / ~20k samples, see [Buffer Limits](#buffer-limits)). For a connection incident the relevant window is short (connection setup is seconds), so a bundle captured during the issue is enough. + +### 1. Start the stack + +```bash +# From this directory (client/internal/metrics/infra) +INFLUXDB_ADMIN_TOKEN=admin123 INFLUXDB_ADMIN_PASSWORD=admin123 GRAFANA_ADMIN_PASSWORD=admin123 \ + docker compose up -d +``` + +(`admin123` are throwaway local credentials — fine for offline analysis.) + +### 2. Clear any previous data + +So you only see this bundle: + +```bash +docker exec influxdb influx delete --org netbird --bucket metrics --token admin123 \ + --start 1970-01-01T00:00:00Z --stop 2100-01-01T00:00:00Z +``` + +### 3. Import the bundle's metrics.txt + +InfluxDB is not exposed on the host, so import inside the container: + +```bash +docker cp /path/to/bundle/metrics.txt influxdb:/tmp/m.txt +docker exec influxdb influx write --org netbird --bucket metrics --precision ns \ + --token admin123 --file /tmp/m.txt +``` + +Re-importing the same file is idempotent (same measurement+tags+timestamp overwrites). + +### 4. View the dashboards + +Grafana on http://localhost:3001 (login `admin` / `admin123`), datasource pre-provisioned: + +- **Where sync time goes:** http://localhost:3001/d/netbird-sync-phases/netbird-sync-phases-where-time-goes +- **General client metrics:** http://localhost:3001/d/netbird-influxdb-metrics + +**Set the time range** to cover the bundle's timestamps (e.g. "Last 7 days" or an absolute range matching when the bundle was taken) — with the default short range the panels look empty. + +Bundles are distinguishable by the `version` tag; add a tag at import time (e.g. `sed 's/^netbird_\([a-z_]*\),/netbird_\1,bundle=mycase,/' metrics.txt`) if you want to compare several side by side. \ No newline at end of file diff --git a/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json new file mode 100644 index 000000000..69dbac0ae --- /dev/null +++ b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json @@ -0,0 +1,259 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "refresh": "", + "schemaVersion": 39, + "tags": [ + "netbird", + "sync" + ], + "templating": { + "list": [ + { + "current": { + "text": "All", + "value": "$__all" + }, + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "definition": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"metrics\", tag: \"version\")", + "includeAll": true, + "label": "version", + "multi": true, + "name": "version", + "query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"metrics\", tag: \"version\")", + "refresh": 2, + "type": "query", + "allValue": ".*" + } + ] + }, + "time": { + "from": "now-2d", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "NetBird Sync Phases (where time goes)", + "uid": "netbird-sync-phases", + "version": 1, + "panels": [ + { + "id": 1, + "title": "Time per phase over time (stacked, ms)", + "type": "timeseries", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "custom": { + "drawStyle": "bars", + "stacking": { + "mode": "normal", + "group": "A" + }, + "fillOpacity": 80, + "lineWidth": 0 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "max", + "mean" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> keep(columns: [\"_time\", \"_value\", \"phase\"])\n |> group(columns: [\"phase\"])" + } + ] + }, + { + "id": 2, + "title": "p95 per phase (ms)", + "type": "bargauge", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 11, + "w": 12, + "x": 0, + "y": 10 + }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "color": { + "mode": "continuous-GrYlRd" + } + }, + "overrides": [] + }, + "options": { + "displayMode": "gradient", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> quantile(q: 0.95)\n |> group()\n |> sort(columns: [\"_value\"], desc: true)" + } + ] + }, + { + "id": 3, + "title": "Per-phase stats (ms): mean / p95 / max", + "type": "table", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 11, + "w": 12, + "x": 12, + "y": 10 + }, + "fieldConfig": { + "defaults": { + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "max", + "desc": true + } + ] + }, + "transformations": [ + { + "id": "merge", + "options": {} + } + ], + "targets": [ + { + "refId": "mean", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> mean()\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"mean\"})" + }, + { + "refId": "p95", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> quantile(q: 0.95)\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"p95\"})" + }, + { + "refId": "max", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> max()\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"max\"})" + } + ] + }, + { + "id": 4, + "title": "Total sync duration (netbird_sync, ms) \u2014 reference", + "type": "timeseries", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 21 + }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "custom": { + "drawStyle": "points", + "pointSize": 5 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "max", + "mean" + ] + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> keep(columns: [\"_time\", \"_value\", \"version\"])\n |> group(columns: [\"version\"])" + } + ] + } + ] +} \ No newline at end of file diff --git a/client/internal/metrics/infra/ingest/main.go b/client/internal/metrics/infra/ingest/main.go index a5031a873..91405b85f 100644 --- a/client/internal/metrics/infra/ingest/main.go +++ b/client/internal/metrics/infra/ingest/main.go @@ -19,7 +19,7 @@ const ( defaultListenAddr = ":8087" defaultInfluxDBURL = "http://influxdb:8086/api/v2/write?org=netbird&bucket=metrics&precision=ns" maxBodySize = 50 * 1024 * 1024 // 50 MB max request body - maxDurationSeconds = 300.0 // reject any duration field > 5 minutes + maxDurationSeconds = 86400.0 // reject any duration field > 24 hours peerIDLength = 16 // truncated SHA-256: 8 bytes = 16 hex chars maxTagValueLength = 64 // reject tag values longer than this ) @@ -59,6 +59,19 @@ var allowedMeasurements = map[string]measurementSpec{ "peer_id": true, }, }, + "netbird_sync_phase": { + allowedFields: map[string]bool{ + "duration_seconds": true, + }, + allowedTags: map[string]bool{ + "deployment_type": true, + "version": true, + "os": true, + "arch": true, + "peer_id": true, + "phase": true, + }, + }, "netbird_login": { allowedFields: map[string]bool{ "duration_seconds": true, diff --git a/client/internal/metrics/infra/ingest/main_test.go b/client/internal/metrics/infra/ingest/main_test.go index bacaa4588..96287813e 100644 --- a/client/internal/metrics/infra/ingest/main_test.go +++ b/client/internal/metrics/infra/ingest/main_test.go @@ -53,14 +53,14 @@ func TestValidateLine_NegativeValue(t *testing.T) { } func TestValidateLine_DurationTooLarge(t *testing.T) { - line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=999 1234567890` + line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=100000 1234567890` err := validateLine(line) require.Error(t, err) assert.Contains(t, err.Error(), "too large") } func TestValidateLine_TotalSecondsTooLarge(t *testing.T) { - line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abc,connection_pair_id=pair total_seconds=500 1234567890` + line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abc,connection_pair_id=pair total_seconds=100000 1234567890` err := validateLine(line) require.Error(t, err) assert.Contains(t, err.Error(), "too large") diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go index 4ebb43496..cfe477107 100644 --- a/client/internal/metrics/metrics.go +++ b/client/internal/metrics/metrics.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "sync" + "sync/atomic" "time" log "github.com/sirupsen/logrus" @@ -56,6 +57,9 @@ type metricsImplementation interface { // RecordSyncDuration records how long it took to process a sync message RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration) + // RecordSyncPhase records how long a single sub-phase of sync processing took + RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration) + // RecordLoginDuration records how long the login to management took RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool) @@ -72,7 +76,7 @@ type ClientMetrics struct { agentInfo AgentInfo mu sync.RWMutex - push *Push + push atomic.Pointer[Push] pushMu sync.Mutex wg sync.WaitGroup pushCancel context.CancelFunc @@ -127,6 +131,18 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du c.impl.RecordSyncDuration(ctx, agentInfo, duration) } +// RecordSyncPhase records the duration of a single sub-phase of sync processing +func (c *ClientMetrics) RecordSyncPhase(ctx context.Context, phase string, duration time.Duration) { + if c == nil { + return + } + c.mu.RLock() + agentInfo := c.agentInfo + c.mu.RUnlock() + + c.impl.RecordSyncPhase(ctx, agentInfo, phase, duration) +} + // RecordLoginDuration records how long the login to management server took func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) { if c == nil { @@ -152,10 +168,7 @@ func (c *ClientMetrics) UpdateAgentInfo(agentInfo AgentInfo, publicKey string) { c.agentInfo = agentInfo c.mu.Unlock() - c.pushMu.Lock() - push := c.push - c.pushMu.Unlock() - if push != nil { + if push := c.push.Load(); push != nil { push.SetPeerID(agentInfo.peerID) } } @@ -169,7 +182,7 @@ func (c *ClientMetrics) Export(w io.Writer) error { return c.impl.Export(w) } -// StartPush starts periodic pushing of metrics with the given configuration +// StartPush starts periodic pushing of metrics with the given configuration. // Precedence: PushConfig.ServerAddress > remote config server_url func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) { if c == nil { @@ -179,11 +192,58 @@ func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) { c.pushMu.Lock() defer c.pushMu.Unlock() - if c.push != nil { + if c.push.Load() != nil { log.Warnf("metrics push already running") return } + c.startPushLocked(ctx, config) +} + +// StopPush stops the periodic metrics push. +func (c *ClientMetrics) StopPush() { + if c == nil { + return + } + c.pushMu.Lock() + defer c.pushMu.Unlock() + + c.stopPushLocked() +} + +// UpdatePushFromMgm updates metrics push based on management server configuration. +// If NB_METRICS_PUSH_ENABLED is explicitly set (true or false), management config is ignored. +// When unset, management controls whether push is enabled. +func (c *ClientMetrics) UpdatePushFromMgm(ctx context.Context, enabled bool) { + if c == nil { + return + } + + if isMetricsPushEnvSet() { + log.Debugf("ignoring management config, env var is explicitly set: %s", EnvMetricsPushEnabled) + return + } + + c.pushMu.Lock() + defer c.pushMu.Unlock() + + if enabled { + if c.push.Load() != nil { + return + } + log.Infof("enabled metrics push by management") + c.startPushLocked(ctx, PushConfigFromEnv()) + } else { + if c.push.Load() == nil { + return + } + log.Infof("disabled metrics push by management") + c.stopPushLocked() + } +} + +// startPushLocked starts push. Caller must hold pushMu. +func (c *ClientMetrics) startPushLocked(ctx context.Context, config PushConfig) { c.mu.RLock() agentVersion := c.agentInfo.Version peerID := c.agentInfo.peerID @@ -199,26 +259,23 @@ func (c *ClientMetrics) StartPush(ctx context.Context, config PushConfig) { ctx, cancel := context.WithCancel(ctx) c.pushCancel = cancel + c.push.Store(push) c.wg.Add(1) go func() { defer c.wg.Done() push.Start(ctx) + c.push.CompareAndSwap(push, nil) }() - c.push = push } -func (c *ClientMetrics) StopPush() { - if c == nil { - return - } - c.pushMu.Lock() - defer c.pushMu.Unlock() - if c.push == nil { +// stopPushLocked stops push. Caller must hold pushMu. +func (c *ClientMetrics) stopPushLocked() { + if c.push.Load() == nil { return } c.pushCancel() c.wg.Wait() - c.push = nil + c.push.Store(nil) } diff --git a/client/internal/metrics/push_test.go b/client/internal/metrics/push_test.go index 20a509da1..43c1b2c06 100644 --- a/client/internal/metrics/push_test.go +++ b/client/internal/metrics/push_test.go @@ -70,6 +70,9 @@ func (m *mockMetrics) RecordConnectionStages(_ context.Context, _ AgentInfo, _ s func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time.Duration) { } +func (m *mockMetrics) RecordSyncPhase(_ context.Context, _ AgentInfo, _ string, _ time.Duration) { +} + func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) { } diff --git a/client/internal/mobile_dependency.go b/client/internal/mobile_dependency.go index 310d61a25..0234432b1 100644 --- a/client/internal/mobile_dependency.go +++ b/client/internal/mobile_dependency.go @@ -11,12 +11,14 @@ import ( // MobileDependency collect all dependencies for mobile platform type MobileDependency struct { - // Android only - TunAdapter device.TunAdapter - IFaceDiscover stdnet.ExternalIFaceDiscover + // Android and iOS NetworkChangeListener listener.NetworkChangeListener - HostDNSAddresses []netip.AddrPort - DnsReadyListener dns.ReadyListener + + // Android only + TunAdapter device.TunAdapter + IFaceDiscover stdnet.ExternalIFaceDiscover + HostDNSAddresses []netip.AddrPort + DnsReadyListener dns.ReadyListener // iOS only DnsManager dns.IosDnsManager diff --git a/client/internal/netflow/logger/logger.go b/client/internal/netflow/logger/logger.go index 8f8e68784..deb38bc4d 100644 --- a/client/internal/netflow/logger/logger.go +++ b/client/internal/netflow/logger/logger.go @@ -27,7 +27,7 @@ type Logger struct { wgIfaceNetV6 netip.Prefix dnsCollection atomic.Bool exitNodeCollection atomic.Bool - Store types.Store + Store types.AggregatingStore } func New(statusRecorder *peer.Status, wgIfaceIPNet, wgIfaceIPNetV6 netip.Prefix) *Logger { @@ -35,7 +35,7 @@ func New(statusRecorder *peer.Status, wgIfaceIPNet, wgIfaceIPNetV6 netip.Prefix) statusRecorder: statusRecorder, wgIfaceNet: wgIfaceIPNet, wgIfaceNetV6: wgIfaceIPNetV6, - Store: store.NewMemoryStore(), + Store: store.NewAggregatingMemoryStore(), } } @@ -125,6 +125,10 @@ func (l *Logger) stop() { l.mux.Unlock() } +func (l *Logger) ResetAggregationWindow() types.FlowEventAggregator { + return l.Store.ResetAggregationWindow() +} + func (l *Logger) GetEvents() []*types.Event { return l.Store.GetEvents() } diff --git a/client/internal/netflow/manager.go b/client/internal/netflow/manager.go index eff083dbf..43d61b771 100644 --- a/client/internal/netflow/manager.go +++ b/client/internal/netflow/manager.go @@ -9,12 +9,14 @@ import ( "sync" "time" + "github.com/cenkalti/backoff/v4" "github.com/google/uuid" log "github.com/sirupsen/logrus" "google.golang.org/protobuf/types/known/timestamppb" "github.com/netbirdio/netbird/client/internal/netflow/conntrack" "github.com/netbirdio/netbird/client/internal/netflow/logger" + "github.com/netbirdio/netbird/client/internal/netflow/store" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/flow/client" @@ -23,14 +25,16 @@ import ( // Manager handles netflow tracking and logging type Manager struct { - mux sync.Mutex - shutdownWg sync.WaitGroup - logger nftypes.FlowLogger - flowConfig *nftypes.FlowConfig - conntrack nftypes.ConnTracker - receiverClient *client.GRPCClient - publicKey []byte - cancel context.CancelFunc + mux sync.Mutex + shutdownWg sync.WaitGroup + logger nftypes.FlowLogger + flowConfig *nftypes.FlowConfig + conntrack nftypes.ConnTracker + receiverClient *client.GRPCClient + eventsWithoutAcks nftypes.Store + publicKey []byte + cancel context.CancelFunc + retryInterval time.Duration } // NewManager creates a new netflow manager @@ -48,9 +52,11 @@ func NewManager(iface nftypes.IFaceMapper, publicKey []byte, statusRecorder *pee } return &Manager{ - logger: flowLogger, - conntrack: ct, - publicKey: publicKey, + logger: flowLogger, + conntrack: ct, + publicKey: publicKey, + retryInterval: time.Second, + eventsWithoutAcks: store.NewMemoryStore(), } } @@ -66,6 +72,7 @@ func (m *Manager) needsNewClient(previous *nftypes.FlowConfig) bool { } // enableFlow starts components for flow tracking +// must be called under m.mux lock func (m *Manager) enableFlow(previous *nftypes.FlowConfig) error { // first make sender ready so events don't pile up if m.needsNewClient(previous) { @@ -85,6 +92,7 @@ func (m *Manager) enableFlow(previous *nftypes.FlowConfig) error { return nil } +// must be called under m.mux lock func (m *Manager) resetClient() error { if m.receiverClient != nil { if err := m.receiverClient.Close(); err != nil { @@ -107,14 +115,19 @@ func (m *Manager) resetClient() error { ctx, cancel := context.WithCancel(context.Background()) m.cancel = cancel - m.shutdownWg.Add(2) + m.shutdownWg.Add(3) + flowConfigInterval := m.flowConfig.Interval go func() { defer m.shutdownWg.Done() - m.receiveACKs(ctx, flowClient) + m.receiveACKs(ctx, flowClient, flowConfigInterval) }() go func() { defer m.shutdownWg.Done() - m.startSender(ctx) + m.startSender(ctx, flowConfigInterval) + }() + go func() { + defer m.shutdownWg.Done() + m.startRetries(ctx, flowConfigInterval) }() return nil @@ -198,8 +211,8 @@ func (m *Manager) GetLogger() nftypes.FlowLogger { return m.logger } -func (m *Manager) startSender(ctx context.Context) { - ticker := time.NewTicker(m.flowConfig.Interval) +func (m *Manager) startSender(ctx context.Context, flowConfigInterval time.Duration) { + ticker := time.NewTicker(flowConfigInterval) defer ticker.Stop() for { @@ -207,27 +220,29 @@ func (m *Manager) startSender(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - events := m.logger.GetEvents() + collectedEvents := m.logger.ResetAggregationWindow() + events := collectedEvents.GetAggregatedEvents() for _, event := range events { + m.eventsWithoutAcks.StoreEvent(event) if err := m.send(event); err != nil { log.Errorf("failed to send flow event to server: %v", err) - continue + } else { + log.Tracef("sent flow event: %s", event.ID) } - log.Tracef("sent flow event: %s", event.ID) } } } } -func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient) { - err := client.Receive(ctx, m.flowConfig.Interval, func(ack *proto.FlowEventAck) error { +func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient, flowConfigInterval time.Duration) { + err := client.Receive(ctx, flowConfigInterval, func(ack *proto.FlowEventAck) error { id, err := uuid.FromBytes(ack.EventId) if err != nil { log.Warnf("failed to convert ack event id to uuid: %v", err) return nil } log.Tracef("received flow event ack: %s", id) - m.logger.DeleteEvents([]uuid.UUID{id}) + m.eventsWithoutAcks.DeleteEvents([]uuid.UUID{id}) return nil }) @@ -236,6 +251,51 @@ func (m *Manager) receiveACKs(ctx context.Context, client *client.GRPCClient) { } } +// We effectively never drop events (see MaxInterval), which makes eventsWithoutAcks unbounded. +// We may want to limit the max size of the store, and start dropping oldest events when the threshold is reached. +func (m *Manager) startRetries(ctx context.Context, flowConfigInterval time.Duration) { + timer := time.NewTimer(m.retryInterval) + retryBackoff := backoff.WithContext(&backoff.ExponentialBackOff{ + InitialInterval: 1 * time.Second, + RandomizationFactor: 0.5, + Multiplier: 1.7, + MaxInterval: flowConfigInterval / 2, + MaxElapsedTime: 3 * 30 * 24 * time.Hour, // 3 months + Stop: backoff.Stop, + Clock: backoff.SystemClock, + }, ctx) + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + resetBackoff := true + for _, e := range m.eventsWithoutAcks.GetEvents() { + if e.Timestamp.Add(time.Second).After(time.Now()) { + // grace period on retries to avoid early retries + // do not retry if the event is less than 1 sec old + continue + } + if err := m.send(e); err != nil { + if nextBackoff := retryBackoff.NextBackOff(); nextBackoff != backoff.Stop { + timer = time.NewTimer(nextBackoff) + resetBackoff = false + } else { + resetBackoff = true // we exhausted retries, reset retry loop + } + break + } + } + if resetBackoff { // use regular retry interval in absence of network errors + retryBackoff.Reset() + timer = time.NewTimer(m.retryInterval) + } + } + } +} + func (m *Manager) send(event *nftypes.Event) error { m.mux.Lock() client := m.receiverClient @@ -250,9 +310,11 @@ func (m *Manager) send(event *nftypes.Event) error { func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent { protoEvent := &proto.FlowEvent{ - EventId: event.ID[:], - Timestamp: timestamppb.New(event.Timestamp), - PublicKey: publicKey, + EventId: event.ID[:], + Timestamp: timestamppb.New(event.Timestamp), + PublicKey: publicKey, + WindowStart: timestamppb.New(event.WindowStart), + WindowEnd: timestamppb.New(event.WindowEnd), FlowFields: &proto.FlowFields{ FlowId: event.FlowID[:], RuleId: event.RuleID, @@ -267,6 +329,9 @@ func toProtoEvent(publicKey []byte, event *nftypes.Event) *proto.FlowEvent { TxBytes: event.TxBytes, SourceResourceId: event.SourceResourceID, DestResourceId: event.DestResourceID, + NumOfStarts: event.NumOfStarts, + NumOfEnds: event.NumOfEnds, + NumOfDrops: event.NumOfDrops, }, } diff --git a/client/internal/netflow/manager_integration_test.go b/client/internal/netflow/manager_integration_test.go new file mode 100644 index 000000000..9029bdda2 --- /dev/null +++ b/client/internal/netflow/manager_integration_test.go @@ -0,0 +1,291 @@ +package netflow + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "slices" + "testing" + "time" + + "github.com/google/uuid" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/netflow/types" + "github.com/netbirdio/netbird/flow/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "google.golang.org/grpc" +) + +type testServer struct { + proto.UnimplementedFlowServiceServer + events chan *proto.FlowEvent + acks chan *proto.FlowEventAck + grpcSrv *grpc.Server + addr string + handlerDone chan struct{} // signaled each time Events() exits + handlerStarted chan struct{} // signaled each time Events() begins +} + +func newTestServer(t *testing.T) *testServer { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + s := &testServer{ + events: make(chan *proto.FlowEvent, 100), + acks: make(chan *proto.FlowEventAck, 100), + grpcSrv: grpc.NewServer(), + addr: listener.Addr().String(), + handlerDone: make(chan struct{}, 10), + handlerStarted: make(chan struct{}, 10), + } + + proto.RegisterFlowServiceServer(s.grpcSrv, s) + + go func() { + if err := s.grpcSrv.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) { + t.Logf("server error: %v", err) + } + }() + + t.Cleanup(func() { + s.grpcSrv.Stop() + }) + + return s +} + +func (s *testServer) Events(stream proto.FlowService_EventsServer) error { + defer func() { + select { + case s.handlerDone <- struct{}{}: + default: + } + }() + + err := stream.Send(&proto.FlowEventAck{IsInitiator: true}) + if err != nil { + return err + } + + select { + case s.handlerStarted <- struct{}{}: + default: + } + + ctx, cancel := context.WithCancel(stream.Context()) + defer cancel() + + go func() { + defer cancel() + for { + event, err := stream.Recv() + if err != nil { + return + } + + if !event.IsInitiator { + select { + case s.events <- event: + case <-ctx.Done(): + return + } + } + } + }() + + for { + select { + case ack := <-s.acks: + if err := stream.Send(ack); err != nil { + return err + } + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func TestSendEventReceiveAck(t *testing.T) { + _, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + + server := newTestServer(t) + manager := createManager(t, server.addr, 60*time.Second) // set high to prevent retries in this test + defer manager.Close() + + assert.Eventually(t, func() bool { + select { + case <-server.handlerStarted: + return true + default: + return false + } + }, 3*time.Second, 100*time.Millisecond) + + event1 := types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Direction: types.Ingress, + DestIP: ipAddr("172.16.1.2"), + DestPort: 2345, + Protocol: 6, + } + manager.logger.StoreEvent(event1) + event2 := types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Direction: types.Ingress, + DestIP: ipAddr("172.16.1.1"), + DestPort: 1234, + Protocol: 6, + } + manager.logger.StoreEvent(event2) + + // verify the server received logged events + serverSideEvents := make([]*proto.FlowEvent, 0) + assert.Eventually(t, func() bool { + select { + case event := <-server.events: + serverSideEvents = append(serverSideEvents, event) + if len(serverSideEvents) == 2 { + return true + } + default: + if len(serverSideEvents) == 2 { + return true + } + } + return false + }, 5*time.Second, 100*time.Millisecond) + + serverSideFlowIds := make([]uuid.UUID, 0, 2) + slices.Values(serverSideEvents)(func(e *proto.FlowEvent) bool { + id, err := uuid.FromBytes(e.FlowFields.FlowId) + assert.NoError(t, err) + serverSideFlowIds = append(serverSideFlowIds, id) + return true + }) + assert.ElementsMatch(t, []uuid.UUID{event1.FlowID, event2.FlowID}, serverSideFlowIds) + + // verify the manager tracks un-acked events + unackedEvents := manager.eventsWithoutAcks.GetEvents() + assert.Len(t, unackedEvents, 2) + flowIds := make([]uuid.UUID, 0) + slices.Values(unackedEvents)(func(e *types.Event) bool { + flowIds = append(flowIds, e.FlowID) + return true + }) + assert.ElementsMatch(t, flowIds, []uuid.UUID{event1.FlowID, event2.FlowID}) +} + +// verify handling of retries: +// - unacked events are retried +// - when acks arrive, events are removed from the un-acked event tracker +func TestRetryEvents(t *testing.T) { + _, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + + server := newTestServer(t) + manager := createManager(t, server.addr, time.Second) // set low to start retries sooner + defer manager.Close() + + assert.Eventually(t, func() bool { + select { + case <-server.handlerStarted: + return true + default: + return false + } + }, 3*time.Second, 100*time.Millisecond) + + event1 := types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Direction: types.Ingress, + DestIP: ipAddr("172.16.1.2"), + DestPort: 2345, + Protocol: 6, + } + manager.logger.StoreEvent(event1) + event2 := types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Direction: types.Ingress, + DestIP: ipAddr("172.16.1.1"), + DestPort: 1234, + Protocol: 6, + } + manager.logger.StoreEvent(event2) + + // verify the server received retries of logged events + serverSideEvents := make([]*proto.FlowEvent, 0) + func() { + c := time.After(2500 * time.Millisecond) + for { + select { + case event := <-server.events: + serverSideEvents = append(serverSideEvents, event) + case <-c: + return + } + } + }() + assert.True(t, len(serverSideEvents) > 2) // must see retries + + uniqueServerSideEvents := make(map[uuid.UUID]*proto.FlowEvent) + slices.Values(serverSideEvents)(func(e *proto.FlowEvent) bool { + id, err := uuid.FromBytes(e.FlowFields.FlowId) + assert.NoError(t, err) + uniqueServerSideEvents[id] = e + return true + }) + assert.Contains(t, uniqueServerSideEvents, event1.FlowID) + assert.Contains(t, uniqueServerSideEvents, event2.FlowID) + + // ack events + server.acks <- &proto.FlowEventAck{EventId: uniqueServerSideEvents[event1.FlowID].EventId} + server.acks <- &proto.FlowEventAck{EventId: uniqueServerSideEvents[event2.FlowID].EventId} + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + unackedEvents := manager.eventsWithoutAcks.GetEvents() + assert.Empty(c, unackedEvents) + + }, 3*time.Second, 100*time.Millisecond) +} + +func createManager(t *testing.T, serverAddr string, retryInterval time.Duration) *Manager { + t.Helper() + + mockIFace := &mockIFaceMapper{ + address: wgaddr.Address{ + Network: netip.MustParsePrefix("192.168.1.1/32"), + }, + isUserspaceBind: true, + } + + publicKey := []byte("test-public-key") + manager := NewManager(mockIFace, publicKey, nil) + manager.retryInterval = retryInterval + + initialConfig := &types.FlowConfig{ + Enabled: true, + URL: fmt.Sprintf("http://%s", serverAddr), + TokenPayload: "initial-payload", + TokenSignature: "initial-signature", + Interval: 500 * time.Millisecond, + } + + err := manager.Update(initialConfig) + require.NoError(t, err) + + return manager +} + +func ipAddr(a string) netip.Addr { + addr, _ := netip.ParseAddr(a) + return addr +} diff --git a/client/internal/netflow/store/event_aggregation_test.go b/client/internal/netflow/store/event_aggregation_test.go new file mode 100644 index 000000000..8abe0d162 --- /dev/null +++ b/client/internal/netflow/store/event_aggregation_test.go @@ -0,0 +1,365 @@ +package store + +import ( + "math/rand" + "net/netip" + "testing" + "time" + + "github.com/google/uuid" + "github.com/netbirdio/netbird/client/internal/netflow/types" + "github.com/stretchr/testify/assert" +) + +var random = rand.New(rand.NewSource(time.Now().UnixNano())) + +func TestFlowAggregation(t *testing.T) { + var protocols = []types.Protocol{types.ICMP, types.ICMPv6, types.TCP, types.UDP} + var tests = []struct { + description string + addresses [][]netip.Addr + dstPort uint16 + eventTypes []types.Type + }{ + { + description: "start and stop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart, types.TypeEnd}, + }, + { + description: "start and drop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart, types.TypeDrop}, + }, + { + description: "start only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart}, + }, + { + description: "drop only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeDrop}, + }} + + for _, protocol := range protocols { + for _, tt := range tests { + t.Run(tt.description+" "+protocol.String(), func(t *testing.T) { + store := NewAggregatingMemoryStore() + store.WindowEnd = time.Now().Add(5 * time.Second) + + allExpected := make([]*types.Event, 0) + + for _, srcAndDst := range tt.addresses { + inEvents, expected := generateEvents(srcAndDst[0], srcAndDst[1], tt.dstPort, tt.eventTypes, protocol, types.Ingress, 0, store.WindowStart, store.WindowEnd) + for _, e := range inEvents { + store.StoreEvent(e) + } + allExpected = append(allExpected, expected) + } + + events := store.GetAggregatedEvents() + assert.ElementsMatch(t, events, allExpected) + }) + } + } +} + +func TestIcmpEventAggregation(t *testing.T) { + var protocols = []types.Protocol{types.ICMP, types.ICMPv6} + var icmpTypes = []uint8{1, 2, 3} + + var tests = []struct { + description string + addresses [][]netip.Addr + eventTypes []types.Type + }{ + { + description: "start and stop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}}, + eventTypes: []types.Type{types.TypeStart, types.TypeEnd}, + }, + { + description: "start and drop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}}, + eventTypes: []types.Type{types.TypeStart, types.TypeDrop}, + }, + { + description: "start only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}}, + eventTypes: []types.Type{types.TypeStart}, + }, + { + description: "drop only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}}, + eventTypes: []types.Type{types.TypeDrop}, + }} + + for _, protocol := range protocols { + for _, tt := range tests { + t.Run(tt.description+" "+protocol.String(), func(t *testing.T) { + store := NewAggregatingMemoryStore() + store.WindowEnd = time.Now().Add(5 * time.Second) + + allExpected := make([]*types.Event, 0) + for _, icmpType := range icmpTypes { + events, expected := generateEvents(tt.addresses[0][0], tt.addresses[0][1], 0, tt.eventTypes, protocol, types.Ingress, icmpType, store.WindowStart, store.WindowEnd) + for _, e := range events { + store.StoreEvent(e) + } + allExpected = append(allExpected, expected) + } + aggregatedEvents := store.GetAggregatedEvents() + assert.Len(t, aggregatedEvents, len(allExpected)) + assert.ElementsMatch(t, aggregatedEvents, allExpected) + }) + } + } +} + +func TestFlowAggregationOfUnknownProtocols(t *testing.T) { + var tests = []struct { + description string + addresses [][]netip.Addr + dstPort uint16 + eventTypes []types.Type + }{ + { + description: "start and stop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart, types.TypeEnd}, + }, + { + description: "start and drop", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart, types.TypeDrop}, + }, + { + description: "start only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeStart}, + }, + { + description: "drop only", + addresses: [][]netip.Addr{{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2.2.2.2")}, {netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2.2.2.2")}}, + dstPort: uint16(random.Uint32() >> 16), + eventTypes: []types.Type{types.TypeDrop}, + }} + + for _, tt := range tests { + t.Run(tt.description+" "+types.ProtocolUnknown.String(), func(t *testing.T) { + store := NewAggregatingMemoryStore() + store.WindowEnd = time.Now().Add(5 * time.Second) + + allExpected := make([]*types.Event, 0) + + for _, srcAndDst := range tt.addresses { + inEvents, expected := generateEventsForUnknownProtocol(srcAndDst[0], srcAndDst[1], tt.dstPort, tt.eventTypes, types.ProtocolUnknown, types.Ingress, store.WindowStart, store.WindowEnd) + for _, e := range inEvents { + store.StoreEvent(e) + } + allExpected = append(allExpected, expected...) + } + + events := store.GetAggregatedEvents() + assert.ElementsMatch(t, events, allExpected) + }) + } +} + +func TestResetAggregationWindow(t *testing.T) { + now := time.Now() + nowFunc := func() time.Time { return now } + store := NewAggregatingMemoryStoreWithTimeFunc(nowFunc) + store.StoreEvent(&types.Event{ + ID: uuid.New(), + Timestamp: time.Now(), + EventFields: types.EventFields{ + FlowID: uuid.New(), + Type: types.TypeStart, + Protocol: types.TCP, + RuleID: []byte("rule-id-1"), + Direction: types.Ingress, + SourceIP: netip.MustParseAddr("1.1.1.1"), + SourcePort: 1234, + DestIP: netip.MustParseAddr("2.2.2.2"), + DestPort: 5678, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: random.Uint64(), + TxPackets: random.Uint64(), + RxBytes: random.Uint64(), + TxBytes: random.Uint64(), + }, + }) + + now = now.Add(1 * time.Second) + reset := store.ResetAggregationWindow() + previousEvents, ok := reset.(*AggregatingMemory) + assert.True(t, ok) + assert.NotEqual(t, previousEvents.WindowStart, store.WindowStart) + assert.Equal(t, previousEvents.WindowEnd, store.WindowStart) + assert.NotEmpty(t, previousEvents.events) + assert.Empty(t, store.events) +} + +func generateEvents(srcIp, dstIp netip.Addr, dstPort uint16, eventTypes []types.Type, protocol types.Protocol, + direction types.Direction, icmpType uint8, windowStart, windowEnd time.Time) ([]*types.Event, *types.Event) { + var rxPackets, txPackets, rxBytes, txBytes uint64 + inEvents := make([]*types.Event, 0) + ts := time.Now() + flowId := uuid.New() + srcPort := uint16(random.Uint32() >> 16) + + for idx, eventType := range eventTypes { + e := &types.Event{ + ID: uuid.New(), + Timestamp: ts.Add(time.Duration(idx) * time.Second), + EventFields: types.EventFields{ + FlowID: flowId, + Type: eventType, + Protocol: protocol, + RuleID: []byte("rule-id-1"), + Direction: direction, + SourceIP: srcIp, + SourcePort: srcPort, + DestIP: dstIp, + DestPort: dstPort, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: random.Uint64(), + TxPackets: random.Uint64(), + RxBytes: random.Uint64(), + TxBytes: random.Uint64(), + }} + rxBytes += e.RxBytes + txBytes += e.TxBytes + rxPackets += e.RxPackets + txPackets += e.TxPackets + inEvents = append(inEvents, e) + if protocol == types.ICMP || protocol == types.ICMPv6 { + e.ICMPType = icmpType + } + } + + var start, end, drop uint64 + for _, eventType := range eventTypes { + switch eventType { + case types.TypeStart: + start += 1 + case types.TypeDrop: + drop += 1 + case types.TypeEnd: + end += 1 + } + } + aggregatedEvent := &types.Event{ + ID: inEvents[0].ID, + Timestamp: inEvents[0].Timestamp, + WindowStart: windowStart, + WindowEnd: windowEnd, + EventFields: types.EventFields{ + FlowID: flowId, + Type: types.TypeUnknown, + Protocol: inEvents[0].Protocol, + RuleID: []byte("rule-id-1"), + Direction: inEvents[0].Direction, + SourceIP: srcIp, + SourcePort: srcPort, + DestIP: dstIp, + DestPort: dstPort, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: rxPackets, + TxPackets: txPackets, + RxBytes: rxBytes, + TxBytes: txBytes, + NumOfStarts: start, + NumOfEnds: end, + NumOfDrops: drop, + }} + if protocol == types.ICMP || protocol == types.ICMPv6 { + aggregatedEvent.ICMPType = icmpType + } + + return inEvents, aggregatedEvent +} + +func generateEventsForUnknownProtocol(srcIp, dstIp netip.Addr, dstPort uint16, eventTypes []types.Type, protocol types.Protocol, + direction types.Direction, windowStart, windowEnd time.Time) ([]*types.Event, []*types.Event) { + inEvents := make([]*types.Event, 0) + expectedEvents := make([]*types.Event, 0) + + ts := time.Now() + flowId := uuid.New() + srcPort := uint16(random.Uint32() >> 16) + + for idx, eventType := range eventTypes { + e := &types.Event{ + ID: uuid.New(), + Timestamp: ts.Add(time.Duration(idx) * time.Second), + EventFields: types.EventFields{ + FlowID: flowId, + Type: eventType, + Protocol: protocol, + RuleID: []byte("rule-id-1"), + Direction: direction, + SourceIP: srcIp, + SourcePort: srcPort, + DestIP: dstIp, + DestPort: dstPort, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: random.Uint64(), + TxPackets: random.Uint64(), + RxBytes: random.Uint64(), + TxBytes: random.Uint64(), + }} + inEvents = append(inEvents, e) + + var start, end, drop uint64 + switch eventType { + case types.TypeStart: + start = 1 + case types.TypeDrop: + drop = 1 + case types.TypeEnd: + end = 1 + } + + expectedEvents = append(expectedEvents, &types.Event{ + ID: e.ID, + Timestamp: e.Timestamp, + WindowStart: windowStart, + WindowEnd: windowEnd, + EventFields: types.EventFields{ + FlowID: flowId, + Type: types.TypeUnknown, + Protocol: e.Protocol, + RuleID: []byte("rule-id-1"), + Direction: e.Direction, + SourceIP: srcIp, + SourcePort: srcPort, + DestIP: dstIp, + DestPort: dstPort, + SourceResourceID: []byte("source-resource-id"), + DestResourceID: []byte("dest-resource-id"), + RxPackets: e.RxPackets, + TxPackets: e.TxPackets, + RxBytes: e.RxBytes, + TxBytes: e.TxBytes, + NumOfStarts: start, + NumOfEnds: end, + NumOfDrops: drop, + }}) + } + + return inEvents, expectedEvents +} diff --git a/client/internal/netflow/store/memory.go b/client/internal/netflow/store/memory.go index a44505e96..dfe764032 100644 --- a/client/internal/netflow/store/memory.go +++ b/client/internal/netflow/store/memory.go @@ -1,10 +1,15 @@ package store import ( + "maps" + "math/rand" + v2 "math/rand/v2" + "net/netip" + "slices" "sync" + "time" "github.com/google/uuid" - "github.com/netbirdio/netbird/client/internal/netflow/types" ) @@ -19,6 +24,14 @@ type Memory struct { events map[uuid.UUID]*types.Event } +type AggregatingMemory struct { + Memory + WindowStart time.Time + WindowEnd time.Time + rnd *v2.PCG + nowFunc func() time.Time +} + func (m *Memory) StoreEvent(event *types.Event) { m.mux.Lock() defer m.mux.Unlock() @@ -48,3 +61,104 @@ func (m *Memory) DeleteEvents(ids []uuid.UUID) { delete(m.events, id) } } + +func NewAggregatingMemoryStore() *AggregatingMemory { + return NewAggregatingMemoryStoreWithTimeFunc(defaultNowFunc) +} + +// used in tests when deterministic (less random) time intervals are required +func NewAggregatingMemoryStoreWithTimeFunc(nowFunc func() time.Time) *AggregatingMemory { + return &AggregatingMemory{WindowStart: nowFunc(), Memory: Memory{events: make(map[uuid.UUID]*types.Event)}, nowFunc: nowFunc, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())} +} + +func (am *AggregatingMemory) ResetAggregationWindow() types.FlowEventAggregator { + am.mux.Lock() + defer am.mux.Unlock() + + now := am.nowFunc() + toret := AggregatingMemory{WindowStart: am.WindowStart, WindowEnd: now, Memory: Memory{events: am.events}, rnd: v2.NewPCG(rand.Uint64(), rand.Uint64())} + + am.events = make(map[uuid.UUID]*types.Event) + am.WindowStart = now + + return &toret +} + +type aggregationKey struct { + srcAddr netip.Addr + destAddr netip.Addr + destPort uint16 + direction int + protocol uint8 + icmpType uint8 + unique uint64 // used to prevent aggregation on non icmp/udp/tcp events +} + +func (am *AggregatingMemory) GetAggregatedEvents() []*types.Event { + am.mux.Lock() + defer am.mux.Unlock() + + aggregated := make(map[aggregationKey]*types.Event) + for _, v := range am.events { + lookupKey := aggregationKey{srcAddr: v.SourceIP, destAddr: v.DestIP, destPort: v.DestPort, direction: int(v.Direction), protocol: uint8(v.Protocol), icmpType: v.ICMPType} + if _, ok := aggregated[lookupKey]; !ok { + event := v.Clone() + + switch event.Type { + case types.TypeStart: + event.NumOfStarts += 1 + case types.TypeDrop: + event.NumOfDrops += 1 + case types.TypeEnd: + event.NumOfEnds += 1 + } + event.Type = types.TypeUnknown + + // Please note that ICMPCode field isn't propagated by the manager (see flow/proto/flow.pb.go, FlowFields struct) + // so the field value in an icmp event in the "aggregated" doesn't matter + + event.WindowStart = am.WindowStart + event.WindowEnd = am.WindowEnd + + if event.Protocol != types.ICMP && event.Protocol != types.ICMPv6 && event.Protocol != types.UDP && event.Protocol != types.TCP { + lookupKey.unique = am.rnd.Uint64() // to make the lookup key unique so we don't aggregate on it + } + + aggregated[lookupKey] = event + continue + } + + aggregatedEvent := aggregated[lookupKey] + if aggregatedEvent.Protocol != types.ICMP && aggregatedEvent.Protocol != types.ICMPv6 && aggregatedEvent.Protocol != types.UDP && aggregatedEvent.Protocol != types.TCP { + continue // we don't aggregate this type of events; shouldn't ever get here + } + + // track the number of connections, duration?, open and close events? + aggregatedEvent.RxBytes += v.RxBytes + aggregatedEvent.RxPackets += v.RxPackets + aggregatedEvent.TxBytes += v.TxBytes + aggregatedEvent.TxPackets += v.TxPackets + switch v.Type { + case types.TypeStart: + aggregatedEvent.NumOfStarts += 1 + case types.TypeDrop: + aggregatedEvent.NumOfDrops += 1 + case types.TypeEnd: + aggregatedEvent.NumOfEnds += 1 + } + if aggregatedEvent.Timestamp.Compare(v.Timestamp) > 0 { + aggregatedEvent.Timestamp = v.Timestamp + aggregatedEvent.ID = v.ID + aggregatedEvent.SourcePort = v.SourcePort + } + if len(aggregatedEvent.RuleID) == 0 && len(v.RuleID) != 0 { + aggregatedEvent.RuleID = slices.Clone(v.RuleID) + } + } + + return slices.Collect(maps.Values(aggregated)) // could return an iterator instead here +} + +func defaultNowFunc() time.Time { + return time.Now() +} diff --git a/client/internal/netflow/types/types.go b/client/internal/netflow/types/types.go index 3f7d0d0ad..ccb2da66b 100644 --- a/client/internal/netflow/types/types.go +++ b/client/internal/netflow/types/types.go @@ -2,6 +2,7 @@ package types import ( "net/netip" + "slices" "strconv" "time" @@ -69,8 +70,10 @@ const ( ) type Event struct { - ID uuid.UUID - Timestamp time.Time + ID uuid.UUID + Timestamp time.Time + WindowStart time.Time + WindowEnd time.Time EventFields } @@ -92,6 +95,17 @@ type EventFields struct { TxPackets uint64 RxBytes uint64 TxBytes uint64 + NumOfStarts uint64 + NumOfEnds uint64 + NumOfDrops uint64 +} + +func (e *Event) Clone() *Event { + toret := *e + toret.RuleID = slices.Clone(e.RuleID) + toret.SourceResourceID = slices.Clone(e.SourceResourceID) + toret.DestResourceID = slices.Clone(e.DestResourceID) + return &toret } type FlowConfig struct { @@ -114,13 +128,15 @@ type FlowManager interface { GetLogger() FlowLogger } +type FlowEventAggregator interface { + ResetAggregationWindow() FlowEventAggregator + GetAggregatedEvents() []*Event +} + type FlowLogger interface { + ResetAggregationWindow() FlowEventAggregator // StoreEvent stores a flow event StoreEvent(flowEvent EventFields) - // GetEvents returns all stored events - GetEvents() []*Event - // DeleteEvents deletes events from the store - DeleteEvents([]uuid.UUID) // Close closes the logger Close() // Enable enables the flow logger receiver @@ -140,6 +156,11 @@ type Store interface { Close() } +type AggregatingStore interface { + FlowEventAggregator + Store +} + // ConnTracker defines the interface for connection tracking functionality type ConnTracker interface { // Start begins tracking connections by listening for conntrack events. diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index 79a513956..b84b05671 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -6,6 +6,7 @@ import ( "net" "net/netip" "runtime" + "slices" "sync" "time" @@ -25,10 +26,16 @@ import ( "github.com/netbirdio/netbird/client/internal/portforward" "github.com/netbirdio/netbird/client/internal/rosenpass" "github.com/netbirdio/netbird/client/internal/stdnet" + "github.com/netbirdio/netbird/client/netstate" "github.com/netbirdio/netbird/route" relayClient "github.com/netbirdio/netbird/shared/relay/client" ) +// wgTimeoutEscalationThreshold is the number of consecutive WireGuard +// handshake timeouts after which the rosenpass state for the peer is +// considered desynced and gets reset. +const wgTimeoutEscalationThreshold = 3 + // MetricsRecorder is an interface for recording peer connection metrics type MetricsRecorder interface { RecordConnectionStages( @@ -87,6 +94,10 @@ type ConnConfig struct { // ICEConfig ICE protocol configuration ICEConfig icemaker.Config + + // NetworkState gates the reconnection guard on OS-reported network + // availability; nil disables gating. + NetworkState *netstate.State } type Conn struct { @@ -117,6 +128,9 @@ type Conn struct { wgWatcher *WGWatcher wgWatcherWg sync.WaitGroup wgWatcherCancel context.CancelFunc + // wgTimeouts counts consecutive WireGuard handshake timeouts without a + // successful handshake in between. Guarded by mu. + wgTimeouts int // used to store the remote Rosenpass key for Relayed connection in case of connection update from ice rosenpassRemoteKey []byte @@ -136,6 +150,39 @@ type Conn struct { // Connection stage timestamps for metrics metricsRecorder MetricsRecorder metricsStages *MetricsStages + + // pendingFirstPacket is the lazyconn-captured handshake init, replayed once the real + // transport is up. + pendingFirstPacket []byte +} + +// injectPendingFirstPacket replays the captured handshake through the proxy if present, else +// directly through the ICE conn. The packet is cleared only after a successful write, so a failed +// or transport-less attempt leaves it available for a later reinjection. Caller must hold conn.mu. +func (conn *Conn) injectPendingFirstPacket(proxy wgproxy.Proxy, directConn net.Conn) { + pkt := conn.pendingFirstPacket + if len(pkt) == 0 { + return + } + + switch { + case proxy != nil: + if err := proxy.InjectPacket(pkt); err != nil { + conn.Log.Debugf("failed to reinject captured first packet via proxy: %v", err) + return + } + case directConn != nil: + if _, err := directConn.Write(pkt); err != nil { + conn.Log.Debugf("failed to reinject captured first packet via direct conn: %v", err) + return + } + default: + conn.Log.Debugf("no transport available to reinject captured first packet") + return + } + + conn.pendingFirstPacket = nil + conn.Log.Debugf("reinjected captured first packet (%d bytes)", len(pkt)) } // NewConn creates a new not opened Conn to the remote peer. @@ -161,7 +208,6 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) { statusICE: worker.NewAtomicStatus(), dumpState: dumpState, endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)), - wgWatcher: NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState), metricsRecorder: services.MetricsRecorder, } @@ -172,6 +218,16 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) { // It will try to establish a connection using ICE and in parallel with relay. The higher priority connection type will // be used. func (conn *Conn) Open(engineCtx context.Context) error { + return conn.open(engineCtx, nil) +} + +// OpenWithFirstPacket opens the connection like Open and stashes firstPacket to be replayed once +// the real transport is established. The packet is retained only on a successful open. +func (conn *Conn) OpenWithFirstPacket(engineCtx context.Context, firstPacket []byte) error { + return conn.open(engineCtx, firstPacket) +} + +func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error { conn.mu.Lock() defer conn.mu.Unlock() @@ -203,7 +259,7 @@ func (conn *Conn) Open(engineCtx context.Context) error { conn.handshaker.AddICEListener(conn.workerICE.OnNewOffer) } - conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher) + conn.guard = guard.NewGuard(conn.Log, conn.isConnectedOnAllWay, conn.config.Timeout, conn.srWatcher, conn.config.NetworkState) conn.wg.Add(1) go func() { @@ -227,6 +283,9 @@ func (conn *Conn) Open(engineCtx context.Context) error { defer conn.wg.Done() conn.guard.Start(conn.ctx, conn.onGuardEvent) }() + if len(firstPacket) > 0 { + conn.pendingFirstPacket = slices.Clone(firstPacket) + } conn.opened = true return nil } @@ -253,6 +312,8 @@ func (conn *Conn) Close(signalToRemote bool) { if conn.wgWatcherCancel != nil { conn.wgWatcherCancel() + conn.wgWatcher = nil + conn.wgWatcherCancel = nil } conn.workerRelay.CloseConn() if conn.workerICE != nil { @@ -384,7 +445,7 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn conn.dumpState.NewLocalProxy() wgProxy, err = conn.newProxy(iceConnInfo.RemoteConn) if err != nil { - conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err) + conn.Log.Errorf("failed to add relayed net.Conn to local proxy: %v", err) return } ep = wgProxy.EndpointAddr() @@ -423,6 +484,8 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn conn.wgProxyRelay.RedirectAs(ep) } + conn.injectPendingFirstPacket(wgProxy, iceConnInfo.RemoteConn) + conn.currentConnPriority = priority conn.statusICE.SetConnected() conn.updateIceState(iceConnInfo, updateTime) @@ -546,6 +609,8 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) { wgConfigWorkaround() + conn.injectPendingFirstPacket(wgProxy, nil) + conn.rosenpassRemoteKey = rci.rosenpassPubKey conn.currentConnPriority = conntype.Relay conn.statusRelay.SetConnected() @@ -612,11 +677,12 @@ func (conn *Conn) onGuardEvent() { } } -func (conn *Conn) onWGDisconnected() { +func (conn *Conn) onWGDisconnected(watcherCtx context.Context) { conn.mu.Lock() defer conn.mu.Unlock() - if conn.ctx.Err() != nil { + // watcherCtx guards against a stale watcher tearing down a connection that already superseded it. + if conn.ctx.Err() != nil || watcherCtx.Err() != nil { return } @@ -632,6 +698,29 @@ func (conn *Conn) onWGDisconnected() { default: conn.Log.Debugf("No active connection to close on WG timeout") } + + conn.escalateWGTimeoutLocked() +} + +// escalateWGTimeoutLocked resets the peer's rosenpass state after repeated +// handshake timeouts. With rosenpass enabled, persistent timeouts mean the +// preshared keys have desynced; the renewal exchange runs over the dead +// tunnel and cannot resync them. Reporting the peer disconnected drops its +// rosenpass state, so the next connection configuration programs the +// rendezvous key and the tunnel can bootstrap again. Callers must hold mu. +func (conn *Conn) escalateWGTimeoutLocked() { + if conn.config.RosenpassConfig.PubKey == nil { + return + } + + conn.wgTimeouts++ + if conn.wgTimeouts < wgTimeoutEscalationThreshold || conn.onDisconnected == nil { + return + } + conn.wgTimeouts = 0 + + conn.Log.Warnf("%d consecutive WireGuard handshake timeouts, resetting rosenpass state for peer", wgTimeoutEscalationThreshold) + conn.onDisconnected(conn.config.WgConfig.RemoteKey) } func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte, updateTime time.Time) { @@ -751,23 +840,39 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) { }) } +// enableWgWatcherIfNeeded starts a fresh watcher instance per connection attempt, so its +// lifecycle stays bound to conn.mu and enable/disable can't race an old goroutine's shutdown. +// Caller must hold conn.mu. func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) { - if !conn.wgWatcher.IsEnabled() { - wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx) - conn.wgWatcherCancel = wgWatcherCancel - conn.wgWatcherWg.Add(1) - go func() { - defer conn.wgWatcherWg.Done() - conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess) - }() + if conn.wgWatcher != nil { + return } + + watcher := NewWGWatcher(conn.Log, conn.config.WgConfig.WgInterface, conn.config.Key, conn.dumpState) + watcher.PrepareInitialHandshake() + + wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx) + conn.wgWatcher = watcher + conn.wgWatcherCancel = wgWatcherCancel + + conn.wgWatcherWg.Add(1) + go func() { + defer conn.wgWatcherWg.Done() + onDisconnected := func() { conn.onWGDisconnected(wgWatcherCtx) } + watcher.EnableWgWatcher(wgWatcherCtx, enabledTime, onDisconnected, conn.onWGHandshakeSuccess, conn.onWGCheckSuccess) + }() } +// disableWgWatcherIfNeeded cancels and drops the watcher once no transport is active. It never +// waits for the goroutine: the timeout path reentrantly calls back here under conn.mu, so +// blocking would deadlock. Caller must hold conn.mu. func (conn *Conn) disableWgWatcherIfNeeded() { - if conn.currentConnPriority == conntype.None && conn.wgWatcherCancel != nil { - conn.wgWatcherCancel() - conn.wgWatcherCancel = nil + if conn.currentConnPriority != conntype.None || conn.wgWatcher == nil { + return } + conn.wgWatcherCancel() + conn.wgWatcher = nil + conn.wgWatcherCancel = nil } func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) { @@ -778,9 +883,8 @@ func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) { } wgProxy := conn.config.WgConfig.WgInterface.GetProxy() - if err := wgProxy.AddTurnConn(conn.ctx, udpAddr, remoteConn); err != nil { - conn.Log.Errorf("failed to add turn net.Conn to local proxy: %v", err) - return nil, err + if err := wgProxy.AddRelayedConn(conn.ctx, udpAddr, remoteConn); err != nil { + return nil, fmt.Errorf("add relayed conn to proxy: %w", err) } return wgProxy, nil } @@ -790,7 +894,9 @@ func (conn *Conn) resetEndpoint() { return } conn.Log.Infof("reset wg endpoint") - conn.wgWatcher.Reset() + if conn.wgWatcher != nil { + conn.wgWatcher.Reset() + } if err := conn.endpointUpdater.RemoveEndpointAddress(); err != nil { conn.Log.Warnf("failed to remove endpoint address before update: %v", err) } @@ -839,6 +945,15 @@ func (conn *Conn) onWGHandshakeSuccess(when time.Time) { conn.recordConnectionMetrics() } +// onWGCheckSuccess is called for every watcher check that observed a fresh +// handshake, including handshakes of connections that were already up when +// the watcher started. +func (conn *Conn) onWGCheckSuccess() { + conn.mu.Lock() + conn.wgTimeouts = 0 + conn.mu.Unlock() +} + // recordConnectionMetrics records connection stage timestamps as metrics func (conn *Conn) recordConnectionMetrics() { if conn.metricsRecorder == nil { @@ -850,12 +965,9 @@ func (conn *Conn) recordConnectionMetrics() { priority := conn.currentConnPriority conn.mu.Unlock() - var connType metrics.ConnectionType - switch priority { - case conntype.Relay: - connType = metrics.ConnectionTypeRelay - default: - connType = metrics.ConnectionTypeICE + connType := metricsConnType(priority) + if connType == metrics.ConnectionTypeUnknown { + return } // Record metrics with timestamps - duration calculation happens in metrics package @@ -956,3 +1068,16 @@ func boolToConnStatus(connected bool) guard.ConnStatus { } return guard.ConnStatusDisconnected } + +func metricsConnType(priority conntype.ConnPriority) metrics.ConnectionType { + switch priority { + case conntype.Relay: + return metrics.ConnectionTypeRelay + case conntype.ICETurn: + return metrics.ConnectionTypeICETurn + case conntype.ICEP2P: + return metrics.ConnectionTypeICEP2P + default: + return metrics.ConnectionTypeUnknown + } +} diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go index 59216b647..b709d5e40 100644 --- a/client/internal/peer/conn_test.go +++ b/client/internal/peer/conn_test.go @@ -7,9 +7,12 @@ import ( "testing" "time" + log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/internal/metrics" + "github.com/netbirdio/netbird/client/internal/peer/conntype" "github.com/netbirdio/netbird/client/internal/peer/dispatcher" "github.com/netbirdio/netbird/client/internal/peer/guard" "github.com/netbirdio/netbird/client/internal/peer/ice" @@ -304,3 +307,114 @@ func TestConn_presharedKey_RosenpassManaged(t *testing.T) { t.Fatalf("expected non-nil presharedKey before Rosenpass manages PSK") } } + +func newWGTimeoutTestConn(rosenpassEnabled bool, disconnected *[]string) *Conn { + cfg := ConnConfig{ + Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + WgConfig: WgConfig{RemoteKey: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU="}, + } + if rosenpassEnabled { + cfg.RosenpassConfig = RosenpassConfig{PubKey: []byte("dummykey")} + } + + conn := &Conn{ + ctx: context.Background(), + config: cfg, + Log: log.WithField("peer", cfg.Key), + metricsStages: &MetricsStages{}, + } + conn.SetOnDisconnected(func(remotePeer string) { + *disconnected = append(*disconnected, remotePeer) + }) + return conn +} + +// TestConn_onWGDisconnected_EscalatesToRosenpassReset: repeated handshake +// timeouts with rosenpass enabled mean the preshared keys have desynced. The +// renewal exchange runs over the dead tunnel and cannot resync them, so after +// wgTimeoutEscalationThreshold consecutive timeouts the conn must report the +// peer disconnected, dropping its rosenpass state so the next configuration +// programs the rendezvous key. +func TestConn_onWGDisconnected_EscalatesToRosenpassReset(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(true, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected(conn.ctx) + } + assert.Empty(t, disconnected, "escalation must not fire below the threshold") + + conn.onWGDisconnected(conn.ctx) + assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected, + "reaching the threshold must report the peer disconnected once") + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected(conn.ctx) + } + assert.Len(t, disconnected, 1, "escalation must restart counting after firing") + + conn.onWGDisconnected(conn.ctx) + assert.Len(t, disconnected, 2, "continued timeouts must escalate again") +} + +// TestConn_onWGDisconnected_CheckSuccessResetsEscalation: a successful +// handshake between timeouts means the tunnel recovered; the counter must +// start over. +func TestConn_onWGDisconnected_CheckSuccessResetsEscalation(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(true, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected(conn.ctx) + } + conn.onWGCheckSuccess() + + for i := 0; i < wgTimeoutEscalationThreshold-1; i++ { + conn.onWGDisconnected(conn.ctx) + } + assert.Empty(t, disconnected, "handshake success must reset the timeout count") +} + +// TestConn_onWGDisconnected_NoEscalationWithoutRosenpass: without rosenpass +// there is no per-peer key state to reset; repeated timeouts must not report +// disconnects. +func TestConn_onWGDisconnected_NoEscalationWithoutRosenpass(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(false, &disconnected) + + for i := 0; i < wgTimeoutEscalationThreshold*3; i++ { + conn.onWGDisconnected(conn.ctx) + } + assert.Empty(t, disconnected, "escalation must be limited to rosenpass connections") +} + +func TestMetricsConnType(t *testing.T) { + tests := []struct { + name string + priority conntype.ConnPriority + expected metrics.ConnectionType + }{ + {"relay", conntype.Relay, metrics.ConnectionTypeRelay}, + {"ice over turn is relayed, not p2p", conntype.ICETurn, metrics.ConnectionTypeICETurn}, + {"direct p2p", conntype.ICEP2P, metrics.ConnectionTypeICEP2P}, + {"unset priority is unknown, not p2p", conntype.None, metrics.ConnectionTypeUnknown}, + {"unrecognised priority is unknown", conntype.ConnPriority(99), metrics.ConnectionTypeUnknown}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, metricsConnType(tc.priority)) + }) + } +} + +func TestMetricsConnType_RelayedMatchesIsRelayed(t *testing.T) { + for _, priority := range []conntype.ConnPriority{conntype.None, conntype.Relay, conntype.ICETurn, conntype.ICEP2P} { + conn := &Conn{currentConnPriority: priority} + tag := metricsConnType(priority) + relayedTag := tag == metrics.ConnectionTypeRelay || tag == metrics.ConnectionTypeICETurn + assert.Equal(t, conn.isRelayed(), relayedTag, + "priority %s: isRelayed and the %q metric tag must agree", priority, tag) + } +} diff --git a/client/internal/peer/guard/guard.go b/client/internal/peer/guard/guard.go index 2e5efbcc5..68d77d318 100644 --- a/client/internal/peer/guard/guard.go +++ b/client/internal/peer/guard/guard.go @@ -6,6 +6,8 @@ import ( "github.com/cenkalti/backoff/v4" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/netstate" ) // ConnStatus represents the connection state as seen by the guard. @@ -31,20 +33,26 @@ type connStatusFunc func() ConnStatus // - Relayed connection disconnected // - ICE candidate changes type Guard struct { - log *log.Entry - isConnectedOnAllWay connStatusFunc - timeout time.Duration - srWatcher *SRWatcher + log *log.Entry + isConnectedOnAllWay connStatusFunc + timeout time.Duration + srWatcher *SRWatcher + // netState gates reconnect attempts on OS-reported network availability; + // nil disables gating. + netState *netstate.State relayedConnDisconnected chan struct{} iCEConnDisconnected chan struct{} } -func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher) *Guard { +// NewGuard creates a reconnection guard for a peer connection. A nil netState +// disables network availability gating. +func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher, netState *netstate.State) *Guard { return &Guard{ log: log, isConnectedOnAllWay: isConnectedFn, timeout: timeout, srWatcher: srWatcher, + netState: netState, relayedConnDisconnected: make(chan struct{}, 1), iCEConnDisconnected: make(chan struct{}, 1), } @@ -85,16 +93,27 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) { defer g.srWatcher.RemoveListener(srReconnectedChan) ticker := g.initialTicker(ctx) - defer ticker.Stop() + defer func() { + // If backoff.Ticker.send is blocked, context.Done will not close the Ticker goroutine. + // We have to explicitly call Stop, even if we use backoff.WithContext. + ticker.Stop() + }() tickerChannel := ticker.C iceState := &iceRetryState{log: g.log} defer iceState.reset() + netChanged := g.netState.Changed() + for { select { case <-tickerChannel: + // skip attempts while the OS reports no usable network; the + // netChanged case below resumes the loop once it returns + if !g.netState.IsOnline() { + continue + } switch g.isConnectedOnAllWay() { case ConnStatusConnected: // all good, nothing to do @@ -131,6 +150,23 @@ func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) { tickerChannel = ticker.C iceState.reset() + case <-netChanged: + // Re-arm for the next transition before acting on this one. + netChanged = g.netState.Changed() + if !g.netState.IsOnline() { + continue + } + // Ticks skipped while offline drove the backoff towards its + // maximum without ever attempting, and left the ICE budget + // frozen — possibly in hourly mode. Recover on our own so the + // peer does not depend on a signal or relay event that never + // comes when both stayed up across the outage. + g.log.Debugf("network is back, reset reconnection ticker") + ticker.Stop() + ticker = g.newReconnectTicker(ctx) + tickerChannel = ticker.C + iceState.reset() + case <-ctx.Done(): g.log.Debugf("context is done, stop reconnect loop") return diff --git a/client/internal/peer/guard/guard_leak_test.go b/client/internal/peer/guard/guard_leak_test.go new file mode 100644 index 000000000..3d82ec591 --- /dev/null +++ b/client/internal/peer/guard/guard_leak_test.go @@ -0,0 +1,92 @@ +package guard + +import ( + "context" + "runtime" + "strings" + "sync" + "testing" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/peer/ice" +) + +func newTestGuard(status connStatusFunc) *Guard { + srw := NewSRWatcher(nil, nil, nil, ice.Config{}) + return NewGuard(log.WithField("test", "guard"), status, 50*time.Millisecond, srw, nil) +} + +// countBackoffTickerGoroutines returns how many goroutines are currently sitting +// in backoff/v4.(*Ticker).run (a ticker goroutine that has not exited). +func countBackoffTickerGoroutines() int { + buf := make([]byte, 1<<25) // 32MB + n := runtime.Stack(buf, true) + return strings.Count(string(buf[:n]), "backoff/v4.(*Ticker).run") +} + +// TestGuard_ReconnectTicker_NoGoroutineLeakOnShutdown reproduces a observed +// leak: after a shutdown burst, ticker run/send goroutines stay parked +// forever even though every reconnect loop has exited. +func TestGuard_ReconnectTicker_NoGoroutineLeakOnShutdown(t *testing.T) { + before := countBackoffTickerGoroutines() + + const peers = 6000 + cancels := make([]context.CancelFunc, 0, peers) + var wg sync.WaitGroup + + // A status check slower than the tick cadence. This models the real + // isConnectedOnAllWay/callback doing work: while the loop is busy in the + // handler, the ticker fires the next tick and parks in send(), because + // send() never selects on ctx. + slowStatus := func() ConnStatus { + time.Sleep(70 * time.Millisecond) + return ConnStatusConnected + } + + for range peers { + g := newTestGuard(slowStatus) + ctx, cancel := context.WithCancel(context.Background()) + cancels = append(cancels, cancel) + wg.Add(1) + go func() { + defer wg.Done() + g.Start(ctx, func() {}) + }() + // Force the live ticker to be a newReconnectTicker. + g.SetRelayedConnDisconnected() + } + + // Let the replacement tickers get past their 800ms initial interval, so + // many are parked in send() waiting on the (slow) consumer when we tear + // everything down. + time.Sleep(1500 * time.Millisecond) + + // Shutdown burst: cancel every peer at once, like engine teardown. + for _, c := range cancels { + c() + } + + // Every reconnect loop must return + waitCh := make(chan struct{}) + go func() { wg.Wait(); close(waitCh) }() + select { + case <-waitCh: + case <-time.After(30 * time.Second): + t.Fatal("not all reconnect loops returned after ctx cancel") + } + + // Give any correctly-stopped ticker goroutines time to unwind. + for range 50 { + runtime.Gosched() + time.Sleep(10 * time.Millisecond) + } + + leaked := countBackoffTickerGoroutines() - before + t.Logf("backoff Ticker.run goroutines still parked after teardown of %d peers: %d", peers, leaked) + if leaked > 0 { + t.Errorf("LEAK: %d backoff ticker goroutines parked after all reconnect loops exited "+ + "(defer ticker.Stop() stops the initial ticker, not the live replacement)", leaked) + } +} diff --git a/client/internal/peer/guard/guard_netstate_test.go b/client/internal/peer/guard/guard_netstate_test.go new file mode 100644 index 000000000..2ab736428 --- /dev/null +++ b/client/internal/peer/guard/guard_netstate_test.go @@ -0,0 +1,107 @@ +package guard + +import ( + "context" + "sync/atomic" + "testing" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/peer/ice" + "github.com/netbirdio/netbird/client/netstate" +) + +// newTestGuardWithNetState builds a guard with a realistic MaxInterval: the +// backoff must be able to grow well past the outage, as it does in production +// where the timeout is seconds to minutes. +func newTestGuardWithNetState(status connStatusFunc, netState *netstate.State) *Guard { + srw := NewSRWatcher(nil, nil, nil, ice.Config{}) + return NewGuard(log.WithField("test", "guard"), status, 30*time.Second, srw, netState) +} + +// TestGuard_RecoversAfterOfflineToOnline covers a peer that stays disconnected +// across a network outage while neither signal nor relay reports an event — +// both stayed up, as on a short airplane mode toggle over Wi-Fi. +// +// Every tick taken while offline is skipped, but it still advances the +// exponential backoff, so by the time the network returns the next tick can be +// tens of seconds away. Without an explicit reaction to the transition the +// peer waits out that interval for a recovery that could start immediately. +func TestGuard_RecoversAfterOfflineToOnline(t *testing.T) { + netState := netstate.New() + + var attempts atomic.Int32 + g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Start from the reconnect ticker (800ms initial interval), the state a + // peer is in after it loses its connection. + go g.Start(ctx, func() { attempts.Add(1) }) + g.SetRelayedConnDisconnected() + + // Let the backoff climb: 0.8s, 1.6s, 3.2s, 6.4s ... every tick is skipped + // while offline, but each one doubles the wait for the next. + netState.Set(false) + time.Sleep(8 * time.Second) + + offlineAttempts := attempts.Load() + if offlineAttempts != 0 { + t.Fatalf("callback ran %d times while offline, want 0", offlineAttempts) + } + + netState.Set(true) + + // The next organic tick is now several seconds out, so anything within + // this window can only come from reacting to the transition itself. + pollCtx, stopPolling := context.WithTimeout(ctx, 2*time.Second) + defer stopPolling() + + select { + case <-pollCtx.Done(): + t.Fatal("peer was not retried within 2s of the network coming back, " + + "with neither a signal nor a relay event to fall back on") + case <-pollUntil(pollCtx, func() bool { return attempts.Load() > 0 }): + } +} + +// TestGuard_OfflineTransitionDoesNotRetry checks the other direction: going +// offline must not itself trigger an attempt. +func TestGuard_OfflineTransitionDoesNotRetry(t *testing.T) { + netState := netstate.New() + + var attempts atomic.Int32 + g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go g.Start(ctx, func() { attempts.Add(1) }) + + netState.Set(false) + time.Sleep(5 * time.Second) + + if got := attempts.Load(); got != 0 { + t.Fatalf("callback ran %d times after going offline, want 0", got) + } +} + +// pollUntil closes the returned channel once cond holds. It gives up when ctx +// is done, so the polling goroutine never outlives the test that started it. +func pollUntil(ctx context.Context, cond func() bool) <-chan struct{} { + done := make(chan struct{}) + go func() { + for { + if cond() { + close(done) + return + } + select { + case <-ctx.Done(): + return + case <-time.After(10 * time.Millisecond): + } + } + }() + return done +} diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go index 1d44096b6..6ecb2a947 100644 --- a/client/internal/peer/handshaker.go +++ b/client/internal/peer/handshaker.go @@ -81,14 +81,19 @@ type Handshaker struct { func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker { h := &Handshaker{ - log: log, - config: config, - signaler: signaler, - ice: ice, - relay: relay, - metricsStages: metricsStages, - remoteOffersCh: make(chan OfferAnswer), - remoteAnswerCh: make(chan OfferAnswer), + log: log, + config: config, + signaler: signaler, + ice: ice, + relay: relay, + metricsStages: metricsStages, + // Buffered by one so an offer or answer that arrives between Open launching + // the Listen goroutine and it reaching its receive is held rather than + // dropped. A peer activated by an incoming signal receives the remote's + // message in that window; an unbuffered channel skips it as "receiver not + // ready", and the connection cannot proceed until the remote re-sends. + remoteOffersCh: make(chan OfferAnswer, 1), + remoteAnswerCh: make(chan OfferAnswer, 1), } // assume remote supports ICE until we learn otherwise from received offers h.remoteICESupported.Store(ice != nil) @@ -162,29 +167,38 @@ func (h *Handshaker) SendOffer() error { return h.sendOffer() } -// OnRemoteOffer handles an offer from the remote peer and returns true if the message was accepted, false otherwise -// doesn't block, discards the message if connection wasn't ready +// OnRemoteOffer hands an offer to Listen without blocking, keeping only the most +// recent one if several arrive before Listen reads them. func (h *Handshaker) OnRemoteOffer(offer OfferAnswer) { - select { - case h.remoteOffersCh <- offer: - return - default: - h.log.Warnf("skipping remote offer message because receiver not ready") - // connection might not be ready yet to receive so we ignore the message - return - } + enqueueLatest(h.remoteOffersCh, offer) } -// OnRemoteAnswer handles an offer from the remote peer and returns true if the message was accepted, false otherwise -// doesn't block, discards the message if connection wasn't ready +// OnRemoteAnswer hands an answer to Listen without blocking, keeping only the most +// recent one if several arrive before Listen reads them. func (h *Handshaker) OnRemoteAnswer(answer OfferAnswer) { + enqueueLatest(h.remoteAnswerCh, answer) +} + +// enqueueLatest delivers msg on a one-slot channel without blocking. When the slot +// already holds an unread message the older one is discarded in favor of msg, so a +// message arriving before Listen starts reading is held rather than dropped, and +// the newest wins if several arrive first. Safe because there is a single producer +// (the engine loop): after draining the stale value the send always has room. +func enqueueLatest(ch chan OfferAnswer, msg OfferAnswer) { select { - case h.remoteAnswerCh <- answer: + case ch <- msg: return default: - // connection might not be ready yet to receive so we ignore the message - h.log.Warnf("skipping remote answer message because receiver not ready") - return + } + + select { + case <-ch: + default: + } + + select { + case ch <- msg: + default: } } @@ -195,14 +209,14 @@ func (h *Handshaker) sendOffer() error { } offer := h.buildOfferAnswer() - h.log.Infof("sending offer with serial: %s", offer.SessionIDString()) + h.log.Debugf("sending offer with serial: %s", offer.SessionIDString()) return h.signaler.SignalOffer(offer, h.config.Key) } func (h *Handshaker) sendAnswer() error { answer := h.buildOfferAnswer() - h.log.Infof("sending answer with serial: %s", answer.SessionIDString()) + h.log.Debugf("sending answer with serial: %s", answer.SessionIDString()) return h.signaler.SignalAnswer(answer, h.config.Key) } diff --git a/client/internal/peer/handshaker_test.go b/client/internal/peer/handshaker_test.go new file mode 100644 index 000000000..5e203d78b --- /dev/null +++ b/client/internal/peer/handshaker_test.go @@ -0,0 +1,63 @@ +package peer + +import ( + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func newTestHandshaker(t *testing.T) *Handshaker { + t.Helper() + // The tests exercise the answer path, whose Listen branch dispatches to the + // relay listener without sending an answer, so no signaler/ICE/relay is needed. + return NewHandshaker(log.WithField("test", t.Name()), ConnConfig{}, nil, nil, nil, nil) +} + +// TestHandshakerHoldsSignalArrivingBeforeListen covers the case where a peer is +// activated by an incoming signal: the remote's offer/answer arrives in the same +// step that opens the connection, before the Listen loop starts reading. The +// message must be held rather than dropped, or the connection cannot proceed until +// the remote re-sends. This is the path taken when an eager peer connects to a +// lazily-managed one. +func TestHandshakerHoldsSignalArrivingBeforeListen(t *testing.T) { + h := newTestHandshaker(t) + + processed := make(chan *OfferAnswer, 4) + h.AddRelayListener(func(o *OfferAnswer) { processed <- o }) + + // Delivered before Listen is reading, as when the peer is woken by the remote's + // signal and the message is delivered right after Open. + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 51820}) + + go h.Listen(t.Context()) + + select { + case <-processed: + case <-time.After(2 * time.Second): + assert.Fail(t, "remote-answer dispatch: signal delivered before Listen was ready was dropped") + } +} + +// TestHandshakerKeepsLatestSignalBeforeListen covers several signals arriving +// before Listen reads: the newest must win (matching the latest-offer contract), +// rather than the first being kept and later ones discarded. +func TestHandshakerKeepsLatestSignalBeforeListen(t *testing.T) { + h := newTestHandshaker(t) + + processed := make(chan *OfferAnswer, 4) + h.AddRelayListener(func(o *OfferAnswer) { processed <- o }) + + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 1111}) + h.OnRemoteAnswer(OfferAnswer{WgListenPort: 2222}) + + go h.Listen(t.Context()) + + select { + case got := <-processed: + assert.Equal(t, 2222, got.WgListenPort, "remote-answer dispatch: the latest queued signal should be processed") + case <-time.After(2 * time.Second): + assert.Fail(t, "remote-answer dispatch: queued signal was dropped") + } +} diff --git a/client/internal/peer/listener.go b/client/internal/peer/listener.go index c601fe534..2bb7fcf32 100644 --- a/client/internal/peer/listener.go +++ b/client/internal/peer/listener.go @@ -1,11 +1,40 @@ package peer +// ClientState identifies the client connection state delivered via +// Listener.OnStateChanged. +type ClientState int + +// Client states. The numeric values cross the gomobile boundary (the mobile +// bindings re-export them as integer constants), so they are a wire format: +// append new states at the end, never reorder or insert. +const ( + ClientStateDisconnected ClientState = iota + ClientStateConnected + ClientStateConnecting + ClientStateDisconnecting + // ClientStateNoNetwork is an overlay state: it is never stored as the + // last notification, only derived from ClientStateConnecting while the + // OS reports no usable network (see notifier.effectiveState). + ClientStateNoNetwork +) + // Listener is a callback type about the NetBird network connection state type Listener interface { + // OnStateChanged reports every client state transition. New states are + // delivered only through this callback; the per-state callbacks below + // are kept for compatibility and will be removed once all consumers + // have migrated. + OnStateChanged(state ClientState) + + // Deprecated: consume OnStateChanged instead. OnConnected() + // Deprecated: consume OnStateChanged instead. OnDisconnected() + // Deprecated: consume OnStateChanged instead. OnConnecting() + // Deprecated: consume OnStateChanged instead. OnDisconnecting() + OnAddressChanged(string, string) OnPeersListChanged(int) } diff --git a/client/internal/peer/notifier.go b/client/internal/peer/notifier.go index 8d1954fe5..1ee1d32ea 100644 --- a/client/internal/peer/notifier.go +++ b/client/internal/peer/notifier.go @@ -4,31 +4,64 @@ import ( "sync" ) -const ( - stateDisconnected = iota - stateConnected - stateConnecting - stateDisconnecting -) - type notifier struct { + // publishLock orders state publication: it is held across computing the + // effective state and handing it to the listener, so a transition cannot + // overtake a newer one and leave the listener on a stale state. + publishLock sync.Mutex serverStateLock sync.Mutex listenersLock sync.Mutex listener Listener currentClientState bool - lastNotification int + lastNotification ClientState lastNumberOfPeers int lastFqdnAddress string lastIPAddress string + networkAvailable bool } func newNotifier() *notifier { - return ¬ifier{} + return ¬ifier{ + networkAvailable: true, + } +} + +// effectiveState maps the computed state to what listeners should see: +// while the OS reports no usable network, "Connecting" would be a lie — +// connection attempts are suspended — so it is reported as NoNetwork. +// Caller must hold serverStateLock. +func (n *notifier) effectiveState(state ClientState) ClientState { + if !n.networkAvailable && state == ClientStateConnecting { + return ClientStateNoNetwork + } + return state +} + +// setNetworkAvailable records the OS network availability and re-notifies +// the listener when the flag flips the effective state (Connecting <-> +// NoNetwork). +func (n *notifier) setNetworkAvailable(available bool) { + n.publishLock.Lock() + defer n.publishLock.Unlock() + + n.serverStateLock.Lock() + if n.networkAvailable == available { + n.serverStateLock.Unlock() + return + } + previous := n.effectiveState(n.lastNotification) + n.networkAvailable = available + current := n.effectiveState(n.lastNotification) + n.serverStateLock.Unlock() + + if previous != current { + n.notify(current) + } } func (n *notifier) setListener(listener Listener) { n.serverStateLock.Lock() - lastNotification := n.lastNotification + lastNotification := n.effectiveState(n.lastNotification) numOfPeers := n.lastNumberOfPeers fqdnAddress := n.lastFqdnAddress address := n.lastIPAddress @@ -52,6 +85,9 @@ func (n *notifier) removeListener() { } func (n *notifier) updateServerStates(mgmState bool, signalState bool) { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() calculatedState := n.calculateState(mgmState, signalState) @@ -61,43 +97,54 @@ func (n *notifier) updateServerStates(mgmState bool, signalState bool) { } n.lastNotification = calculatedState + effective := n.effectiveState(calculatedState) n.serverStateLock.Unlock() - n.notify(calculatedState) + n.notify(effective) } func (n *notifier) clientStart() { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() n.currentClientState = true - n.lastNotification = stateConnecting + n.lastNotification = ClientStateConnecting + effective := n.effectiveState(ClientStateConnecting) n.serverStateLock.Unlock() - n.notify(stateConnecting) + n.notify(effective) } func (n *notifier) clientStop() { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() n.currentClientState = false - n.lastNotification = stateDisconnected + n.lastNotification = ClientStateDisconnected n.serverStateLock.Unlock() - n.notify(stateDisconnected) + n.notify(ClientStateDisconnected) } func (n *notifier) clientTearDown() { + n.publishLock.Lock() + defer n.publishLock.Unlock() + n.serverStateLock.Lock() n.currentClientState = false - n.lastNotification = stateDisconnecting + n.lastNotification = ClientStateDisconnecting n.serverStateLock.Unlock() - n.notify(stateDisconnecting) + n.notify(ClientStateDisconnecting) } -func (n *notifier) isServerStateChanged(newState int) bool { +func (n *notifier) isServerStateChanged(newState ClientState) bool { return n.lastNotification != newState } -func (n *notifier) notify(state int) { +func (n *notifier) notify(state ClientState) { n.listenersLock.Lock() listener := n.listener n.listenersLock.Unlock() @@ -109,20 +156,20 @@ func (n *notifier) notify(state int) { notifyListener(listener, state) } -func (n *notifier) calculateState(managementConn, signalConn bool) int { +func (n *notifier) calculateState(managementConn, signalConn bool) ClientState { if managementConn && signalConn { - return stateConnected + return ClientStateConnected } if !managementConn && !signalConn && !n.currentClientState { - return stateDisconnected + return ClientStateDisconnected } - if n.lastNotification == stateDisconnecting { - return stateDisconnecting + if n.lastNotification == ClientStateDisconnecting { + return ClientStateDisconnecting } - return stateConnecting + return ClientStateConnecting } func (n *notifier) peerListChanged(numOfPeers int) { @@ -159,15 +206,19 @@ func (n *notifier) localAddressChanged(fqdn, address string) { listener.OnAddressChanged(fqdn, address) } -func notifyListener(l Listener, state int) { +func notifyListener(l Listener, state ClientState) { + // legacy per-state callbacks; NoNetwork is delivered only via + // OnStateChanged below switch state { - case stateDisconnected: + case ClientStateDisconnected: l.OnDisconnected() - case stateConnected: + case ClientStateConnected: l.OnConnected() - case stateConnecting: + case ClientStateConnecting: l.OnConnecting() - case stateDisconnecting: + case ClientStateDisconnecting: l.OnDisconnecting() } + + l.OnStateChanged(state) } diff --git a/client/internal/peer/notifier_concurrent_test.go b/client/internal/peer/notifier_concurrent_test.go new file mode 100644 index 000000000..fcaaaad3b --- /dev/null +++ b/client/internal/peer/notifier_concurrent_test.go @@ -0,0 +1,108 @@ +package peer + +import ( + "sync" + "testing" + "time" +) + +type recordingListener struct { + mu sync.Mutex + states []ClientState + onState func(ClientState) +} + +func (l *recordingListener) OnStateChanged(state ClientState) { + l.mu.Lock() + l.states = append(l.states, state) + hook := l.onState + l.mu.Unlock() + + if hook != nil { + hook(state) + } +} + +func (l *recordingListener) last() (ClientState, bool) { + l.mu.Lock() + defer l.mu.Unlock() + if len(l.states) == 0 { + return 0, false + } + return l.states[len(l.states)-1], true +} + +func (l *recordingListener) snapshot() []ClientState { + l.mu.Lock() + defer l.mu.Unlock() + return append([]ClientState(nil), l.states...) +} + +func (l *recordingListener) OnConnected() {} +func (l *recordingListener) OnDisconnected() {} +func (l *recordingListener) OnConnecting() {} +func (l *recordingListener) OnDisconnecting() {} +func (l *recordingListener) OnAddressChanged(string, string) {} +func (l *recordingListener) OnPeersListChanged(int) {} + +// TestNotifier_ConcurrentAvailabilityFlipOrdersPublication holds the first +// transition inside the listener callback and flips availability again from +// another goroutine while it is parked. The second flip must not publish +// ahead of the one in flight, otherwise the listener ends up on a state the +// notifier already superseded. +func TestNotifier_ConcurrentAvailabilityFlipOrdersPublication(t *testing.T) { + n := newNotifier() + n.currentClientState = true + n.lastNotification = ClientStateConnecting + + entered := make(chan struct{}) + release := make(chan struct{}) + + l := &recordingListener{} + l.onState = func(state ClientState) { + if state != ClientStateNoNetwork { + return + } + l.mu.Lock() + l.onState = nil + l.mu.Unlock() + close(entered) + <-release + } + n.listener = l + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + n.setNetworkAvailable(false) + }() + + <-entered + + flipped := make(chan struct{}) + go func() { + defer close(flipped) + n.setNetworkAvailable(true) + }() + + select { + case <-flipped: + t.Fatal("the online transition published while the offline one was " + + "still in flight; publication is not serialized") + case <-time.After(200 * time.Millisecond): + } + + close(release) + <-flipped + wg.Wait() + + got, ok := l.last() + if !ok { + t.Fatal("listener never observed a state") + } + if got != ClientStateConnecting { + t.Fatalf("listener holds %v after the network came back, want Connecting; sequence: %v", + got, l.snapshot()) + } +} diff --git a/client/internal/peer/notifier_test.go b/client/internal/peer/notifier_test.go index 0b7722b0c..a73016b05 100644 --- a/client/internal/peer/notifier_test.go +++ b/client/internal/peer/notifier_test.go @@ -6,29 +6,32 @@ import ( ) type mocListener struct { - lastState int + lastState ClientState wg sync.WaitGroup peersWg sync.WaitGroup peers int } func (l *mocListener) OnConnected() { - l.lastState = stateConnected + l.lastState = ClientStateConnected l.wg.Done() } func (l *mocListener) OnDisconnected() { - l.lastState = stateDisconnected + l.lastState = ClientStateDisconnected l.wg.Done() } func (l *mocListener) OnConnecting() { - l.lastState = stateConnecting + l.lastState = ClientStateConnecting l.wg.Done() } func (l *mocListener) OnDisconnecting() { - l.lastState = stateDisconnecting + l.lastState = ClientStateDisconnecting l.wg.Done() } +func (l *mocListener) OnStateChanged(state ClientState) { + +} func (l *mocListener) OnAddressChanged(host, addr string) { } @@ -57,15 +60,15 @@ func Test_notifier_serverState(t *testing.T) { type scenario struct { name string - expected int + expected ClientState mgmState bool signalState bool } scenarios := []scenario{ - {"connected", stateConnected, true, true}, - {"mgm down", stateConnecting, false, true}, - {"signal down", stateConnecting, true, false}, - {"disconnected", stateDisconnected, false, false}, + {"connected", ClientStateConnected, true, true}, + {"mgm down", ClientStateConnecting, false, true}, + {"signal down", ClientStateConnecting, true, false}, + {"disconnected", ClientStateDisconnected, false, false}, } for _, tt := range scenarios { @@ -85,7 +88,7 @@ func Test_notifier_SetListener(t *testing.T) { listener.setPeersWaiter() n := newNotifier() - n.lastNotification = stateConnecting + n.lastNotification = ClientStateConnecting n.setListener(listener) listener.wait() listener.waitPeers() @@ -99,7 +102,7 @@ func Test_notifier_RemoveListener(t *testing.T) { listener.setWaiter() listener.setPeersWaiter() n := newNotifier() - n.lastNotification = stateConnecting + n.lastNotification = ClientStateConnecting n.setListener(listener) // setListener replays cached state on a goroutine; wait for both the state // and peers callbacks to finish so we don't race on listener.peers. diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 3e5c56dd2..24e3e7fac 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -7,6 +7,7 @@ import ( "net/netip" "slices" "sync" + "sync/atomic" "time" "github.com/google/uuid" @@ -191,22 +192,30 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState { // every private-service request) don't contend against each other. // Pure read methods take RLock; anything that mutates state takes Lock. type Status struct { - mux sync.RWMutex - peers map[string]State - ipToKey map[string]string - changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription - signalState bool - signalError error - managementState bool - managementError error - relayStates []relay.ProbeResult - localPeer LocalPeerState - offlinePeers []State - mgmAddress string - signalAddress string - notifier *notifier - rosenpassEnabled bool - rosenpassPermissive bool + mux sync.RWMutex + muxRelays sync.RWMutex + peers map[string]State + ipToKey map[string]string + changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription + signalState bool + signalError error + managementState bool + managementError error + relayStates []relay.ProbeResult + localPeer LocalPeerState + offlinePeers []State + mgmAddress string + signalAddress string + notifier *notifier + rosenpassEnabled bool + rosenpassPermissive bool + // sessionExpiresAt is the absolute UTC instant at which the peer's SSO + // session expires. Zero when the peer is not SSO-tracked or login + // expiration is disabled. Populated from management LoginResponse / + // SyncResponse and exposed via the daemon's Status / SubscribeStatus RPC + // so the UI can show remaining time without itself talking to mgm. + sessionExpiresAt time.Time + nsGroupStates []NSGroupState resolvedDomainsStates map[domain.Domain]ResolvedDomainInfo lazyConnectionEnabled bool @@ -222,6 +231,21 @@ type Status struct { eventStreams map[string]chan *proto.SystemEvent eventQueue *EventQueue + // stateChangeStreams fan-out connection-state changes (connected / + // disconnected / connecting / address change / peers list change) to + // every active SubscribeStatus gRPC stream. Each subscriber gets a + // buffered chan; the notifier non-blockingly pings them so a slow + // consumer can never stall the daemon. + stateChangeMux sync.Mutex + stateChangeStreams map[string]chan struct{} + + // networksRevision bumps whenever the routed-networks set or their + // selected state changes (driven by the route manager). Surfaced in the + // status snapshot so the UI can fingerprint on it and re-fetch + // ListNetworks only on a real change. Atomic so the snapshot builder can + // read it without taking mux. + networksRevision atomic.Uint64 + ingressGwMgr *ingressgw.Manager routeIDLookup routeIDLookup @@ -236,6 +260,7 @@ func NewRecorder(mgmAddress string) *Status { changeNotify: make(map[string]map[string]*StatusChangeSubscription), eventStreams: make(map[string]chan *proto.SystemEvent), eventQueue: NewEventQueue(eventQueueSize), + stateChangeStreams: make(map[string]chan struct{}), offlinePeers: make([]State, 0), notifier: newNotifier(), mgmAddress: mgmAddress, @@ -244,8 +269,8 @@ func NewRecorder(mgmAddress string) *Status { } func (d *Status) SetRelayMgr(manager *relayClient.Manager) { - d.mux.Lock() - defer d.mux.Unlock() + d.muxRelays.Lock() + defer d.muxRelays.Unlock() d.relayMgr = manager } @@ -400,6 +425,7 @@ func (d *Status) UpdatePeerState(receivedState State) error { if notifyRouter { d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } + d.notifyStateChange() return nil } @@ -425,6 +451,7 @@ func (d *Status) AddPeerStateRoute(peer string, route string, resourceId route.R // todo: consider to make sense of this notification or not d.notifier.peerListChanged(numPeers) + d.notifyStateChange() return nil } @@ -450,6 +477,7 @@ func (d *Status) RemovePeerStateRoute(peer string, route string) error { // todo: consider to make sense of this notification or not d.notifier.peerListChanged(numPeers) + d.notifyStateChange() return nil } @@ -499,6 +527,7 @@ func (d *Status) UpdatePeerICEState(receivedState State) error { if notifyRouter { d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } + d.notifyStateChange() return nil } @@ -535,6 +564,7 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error { if notifyRouter { d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } + d.notifyStateChange() return nil } @@ -570,6 +600,7 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error if notifyRouter { d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } + d.notifyStateChange() return nil } @@ -608,6 +639,7 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error { if notifyRouter { d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) } + d.notifyStateChange() return nil } @@ -701,6 +733,7 @@ func (d *Status) FinishPeerListModifications() { for _, rd := range dispatches { d.dispatchRouterPeers(rd.peerID, rd.snapshot) } + d.notifyStateChange() } func (d *Status) SubscribeToPeerStateChanges(ctx context.Context, peerID string) *StatusChangeSubscription { @@ -759,6 +792,36 @@ func (d *Status) UpdateLocalPeerState(localPeerState LocalPeerState) { d.mux.Unlock() d.notifier.localAddressChanged(fqdn, ip) + d.notifyStateChange() +} + +// SetSessionExpiresAt records the absolute UTC instant at which the peer's +// SSO session is set to expire. Pass the zero value to clear (e.g. when the +// management server stops publishing a deadline because login expiration was +// disabled or the peer is not SSO-tracked). Same-value updates are no-ops; +// real changes fan out via notifyStateChange so SubscribeStatus consumers +// pick up the new deadline on their next read. +func (d *Status) SetSessionExpiresAt(deadline time.Time) { + d.mux.Lock() + if d.sessionExpiresAt.Equal(deadline) { + d.mux.Unlock() + return + } + d.sessionExpiresAt = deadline + d.mux.Unlock() + d.notifyStateChange() +} + +// GetSessionExpiresAt returns the most recently recorded SSO session deadline, +// or the zero value when no deadline is tracked. A deadline in the past is +// returned as-is: it means the session has expired, and consumers (tray row, +// CLI status) render it as "expired" rather than hiding it — masking it as +// "none" would blank the UI at the exact moment it should say the session +// ended. +func (d *Status) GetSessionExpiresAt() time.Time { + d.mux.Lock() + defer d.mux.Unlock() + return d.sessionExpiresAt } // AddLocalPeerStateRoute adds a route to the local peer state @@ -827,11 +890,19 @@ func (d *Status) CleanLocalPeerState() { d.mux.Unlock() d.notifier.localAddressChanged(fqdn, ip) + d.notifyStateChange() } // MarkManagementDisconnected sets ManagementState to disconnected func (d *Status) MarkManagementDisconnected(err error) { d.mux.Lock() + // Health checks re-mark the same state on every probe; skip the fan-out + // when nothing actually changed so we don't flood SubscribeStatus + // consumers with identical snapshots. + if !d.managementState && errors.Is(d.managementError, err) { + d.mux.Unlock() + return + } d.managementState = false d.managementError = err mgm := d.managementState @@ -839,11 +910,16 @@ func (d *Status) MarkManagementDisconnected(err error) { d.mux.Unlock() d.notifier.updateServerStates(mgm, sig) + d.notifyStateChange() } // MarkManagementConnected sets ManagementState to connected func (d *Status) MarkManagementConnected() { d.mux.Lock() + if d.managementState && d.managementError == nil { + d.mux.Unlock() + return + } d.managementState = true d.managementError = nil mgm := d.managementState @@ -851,6 +927,7 @@ func (d *Status) MarkManagementConnected() { d.mux.Unlock() d.notifier.updateServerStates(mgm, sig) + d.notifyStateChange() } // UpdateSignalAddress update the address of the signal server @@ -884,6 +961,10 @@ func (d *Status) UpdateLazyConnection(enabled bool) { // MarkSignalDisconnected sets SignalState to disconnected func (d *Status) MarkSignalDisconnected(err error) { d.mux.Lock() + if !d.signalState && errors.Is(d.signalError, err) { + d.mux.Unlock() + return + } d.signalState = false d.signalError = err mgm := d.managementState @@ -891,11 +972,16 @@ func (d *Status) MarkSignalDisconnected(err error) { d.mux.Unlock() d.notifier.updateServerStates(mgm, sig) + d.notifyStateChange() } // MarkSignalConnected sets SignalState to connected func (d *Status) MarkSignalConnected() { d.mux.Lock() + if d.signalState && d.signalError == nil { + d.mux.Unlock() + return + } d.signalState = true d.signalError = nil mgm := d.managementState @@ -903,11 +989,12 @@ func (d *Status) MarkSignalConnected() { d.mux.Unlock() d.notifier.updateServerStates(mgm, sig) + d.notifyStateChange() } func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) { - d.mux.Lock() - defer d.mux.Unlock() + d.muxRelays.Lock() + defer d.muxRelays.Unlock() d.relayStates = relayResults } @@ -1018,24 +1105,26 @@ func (d *Status) GetSignalState() SignalState { // GetRelayStates returns the stun/turn/permanent relay states func (d *Status) GetRelayStates() []relay.ProbeResult { - d.mux.RLock() - defer d.mux.RUnlock() + d.muxRelays.RLock() if d.relayMgr == nil { - return d.relayStates + defer d.muxRelays.RUnlock() + return slices.Clone(d.relayStates) } + relayMgr := d.relayMgr // extend the list of stun, turn servers with the relay server connections relayStates := slices.Clone(d.relayStates) + d.muxRelays.RUnlock() - states := d.relayMgr.RelayStates() + states := relayMgr.RelayStates() if len(states) == 0 { // no relay connection tracked yet; surface configured servers as // unavailable with the real reconnect error when known err := relayClient.ErrRelayClientNotConnected - if connErr := d.relayMgr.RelayConnectError(); connErr != nil { + if connErr := relayMgr.RelayConnectError(); connErr != nil { err = connErr } - for _, r := range d.relayMgr.ServerURLs() { + for _, r := range relayMgr.ServerURLs() { relayStates = append(relayStates, relay.ProbeResult{ URI: r, Err: err, @@ -1107,16 +1196,25 @@ func (d *Status) GetFullStatus() FullStatus { // ClientStart will notify all listeners about the new service state func (d *Status) ClientStart() { d.notifier.clientStart() + d.notifyStateChange() } // ClientStop will notify all listeners about the new service state func (d *Status) ClientStop() { d.notifier.clientStop() + d.notifyStateChange() } // ClientTeardown will notify all listeners about the service is under teardown func (d *Status) ClientTeardown() { d.notifier.clientTearDown() + d.notifyStateChange() +} + +// SetNetworkAvailable records the OS-reported network availability; while +// unavailable, listeners see NoNetwork instead of Connecting. +func (d *Status) SetNetworkAvailable(available bool) { + d.notifier.setNetworkAvailable(available) } // SetConnectionListener set a listener to the notifier @@ -1258,6 +1356,79 @@ func (d *Status) GetEventHistory() []*proto.SystemEvent { return d.eventQueue.GetAll() } +// SubscribeToStateChanges hands back a channel that receives a tick on +// every connection-state change (connected / disconnected / connecting / +// address change / peers-list change). The channel is buffered to one +// pending tick so a coalesced burst still wakes the consumer exactly +// once. Pass the returned id to UnsubscribeFromStateChanges to detach. +func (d *Status) SubscribeToStateChanges() (string, <-chan struct{}) { + d.stateChangeMux.Lock() + defer d.stateChangeMux.Unlock() + + id := uuid.New().String() + ch := make(chan struct{}, 1) + d.stateChangeStreams[id] = ch + return id, ch +} + +// UnsubscribeFromStateChanges releases a SubscribeToStateChanges channel +// and closes it so any consumer goroutine selecting on the channel +// unblocks cleanly. +func (d *Status) UnsubscribeFromStateChanges(id string) { + d.stateChangeMux.Lock() + defer d.stateChangeMux.Unlock() + + if ch, ok := d.stateChangeStreams[id]; ok { + close(ch) + delete(d.stateChangeStreams, id) + } +} + +// notifyStateChange wakes every SubscribeToStateChanges subscriber. Drops +// the tick if a subscriber's buffer is full — by definition the consumer +// is already going to fetch the latest snapshot, so multiple pending ticks +// would be redundant. +func (d *Status) notifyStateChange() { + d.stateChangeMux.Lock() + defer d.stateChangeMux.Unlock() + + for _, ch := range d.stateChangeStreams { + select { + case ch <- struct{}{}: + default: + } + } +} + +// NotifyStateChange is the public wake-the-subscribers entry point used by +// callers that mutate state outside the peer recorder — most importantly +// the connect-state machine, which writes StatusNeedsLogin into the +// shared contextState (client/internal/state.go) without touching any +// recorder field. Without this push the SubscribeStatus stream stays on +// the previous snapshot until an unrelated peer/management/signal +// change happens to fire notifyStateChange, leaving the UI's status +// out of sync with the daemon. +func (d *Status) NotifyStateChange() { + d.notifyStateChange() +} + +// BumpNetworksRevision increments the routed-networks revision and wakes every +// SubscribeStatus subscriber. The route manager calls it when a network map +// changes the available routes or when a selection is applied — the peer +// status itself only records actively-routed (chosen) networks, so without +// this bump a candidate route appearing/disappearing would never reach the UI. +func (d *Status) BumpNetworksRevision() { + d.networksRevision.Add(1) + d.notifyStateChange() +} + +// GetNetworksRevision returns the current routed-networks revision, surfaced in +// the status snapshot so the UI can detect route/selection changes (see +// BumpNetworksRevision). +func (d *Status) GetNetworksRevision() uint64 { + return d.networksRevision.Load() +} + func (d *Status) SetWgIface(wgInterface WGIfaceStatus) { d.mux.Lock() defer d.mux.Unlock() diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go index 17ed47cd3..29404d413 100644 --- a/client/internal/peer/status_test.go +++ b/client/internal/peer/status_test.go @@ -314,3 +314,39 @@ func TestGetFullStatus(t *testing.T) { assert.Equal(t, signalState, fullStatus.SignalState, "signal status should be equal") assert.ElementsMatch(t, []State{peerState1, peerState2}, fullStatus.Peers, "peers states should match") } + +// notified reports whether a state-change tick is pending on ch, draining it. +func notified(ch <-chan struct{}) bool { + select { + case <-ch: + return true + default: + return false + } +} + +func TestMarkServerStateDoesNotNotifyWhenUnchanged(t *testing.T) { + status := NewRecorder("https://mgm") + _, ch := status.SubscribeToStateChanges() + + // First transition is a real change and must notify. + status.MarkManagementConnected() + require.True(t, notified(ch), "first connect should notify") + + // Re-marking the same state must not notify again. + status.MarkManagementConnected() + assert.False(t, notified(ch), "redundant connect should not notify") + + // Same for signal. + status.MarkSignalConnected() + require.True(t, notified(ch), "first signal connect should notify") + status.MarkSignalConnected() + assert.False(t, notified(ch), "redundant signal connect should not notify") + + // A genuine change (disconnect with an error) notifies again. + err := errors.New("boom") + status.MarkManagementDisconnected(err) + require.True(t, notified(ch), "disconnect should notify") + status.MarkManagementDisconnected(err) + assert.False(t, notified(ch), "redundant disconnect should not notify") +} diff --git a/client/internal/peer/wg_watcher.go b/client/internal/peer/wg_watcher.go index 805a6f24a..39e3d3264 100644 --- a/client/internal/peer/wg_watcher.go +++ b/client/internal/peer/wg_watcher.go @@ -3,7 +3,6 @@ package peer import ( "context" "fmt" - "sync" "time" log "github.com/sirupsen/logrus" @@ -24,14 +23,16 @@ type WGInterfaceStater interface { GetStats() (map[string]configurer.WGStats, error) } +// WGWatcher is single-shot: one instance per connection attempt, run once, then discarded. +// Lifecycle is owned by Conn under conn.mu, so it keeps no "enabled" state to go stale. type WGWatcher struct { log *log.Entry wgIfaceStater WGInterfaceStater peerKey string stateDump *stateDump - enabled bool - muEnabled sync.RWMutex + // initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently. + initialHandshake time.Time resetCh chan struct{} } @@ -46,36 +47,23 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin } } -// EnableWgWatcher starts the WireGuard watcher. If it is already enabled, it will return immediately and do nothing. -// The watcher runs until ctx is cancelled. Caller is responsible for context lifecycle management. -func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) { - w.muEnabled.Lock() - if w.enabled { - w.muEnabled.Unlock() - return - } - +// PrepareInitialHandshake reads the peer's current WireGuard handshake time. It must be +// called before the peer is (re)configured on the WireGuard interface, so the captured +// baseline reflects the state prior to this connection attempt instead of racing with +// that configuration. +func (w *WGWatcher) PrepareInitialHandshake() { w.log.Debugf("enable WireGuard watcher") - w.enabled = true - w.muEnabled.Unlock() - - initialHandshake, err := w.wgState() - if err != nil { - w.log.Warnf("failed to read initial wg stats: %v", err) - } - - w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, initialHandshake) - - w.muEnabled.Lock() - w.enabled = false - w.muEnabled.Unlock() + handshake, _ := w.wgState() + w.initialHandshake = handshake } -// IsEnabled returns true if the WireGuard watcher is currently enabled -func (w *WGWatcher) IsEnabled() bool { - w.muEnabled.RLock() - defer w.muEnabled.RUnlock() - return w.enabled +// EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by +// PrepareInitialHandshake. The watcher runs until ctx is cancelled. Caller is responsible +// for context lifecycle management. onHandshakeSuccessFn is called only for the first +// handshake observed by this run, onCheckSuccessFn for every check that observed a fresh +// handshake, including the first. +func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func()) { + w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, onCheckSuccessFn, enabledTime, w.initialHandshake) } // Reset signals the watcher that the WireGuard peer has been reset and a new @@ -88,7 +76,7 @@ func (w *WGWatcher) Reset() { } // wgStateCheck help to check the state of the WireGuard handshake and relay connection -func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), enabledTime time.Time, initialHandshake time.Time) { +func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), onCheckSuccessFn func(), enabledTime time.Time, initialHandshake time.Time) { w.log.Infof("WireGuard watcher started") timer := time.NewTimer(wgHandshakeOvertime) @@ -101,17 +89,25 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn case <-timer.C: handshake, ok := w.handshakeCheck(lastHandshake) if !ok { + // early ctx cancel check return + if ctx.Err() != nil { + return + } onDisconnectedFn() return } if lastHandshake.IsZero() { elapsed := calcElapsed(enabledTime, *handshake) w.log.Infof("first wg handshake detected within: %.2fsec, (%s)", elapsed, handshake) - if onHandshakeSuccessFn != nil { + if onHandshakeSuccessFn != nil && ctx.Err() == nil { onHandshakeSuccessFn(*handshake) } } + if onCheckSuccessFn != nil && ctx.Err() == nil { + onCheckSuccessFn() + } + lastHandshake = *handshake resetTime := time.Until(handshake.Add(checkPeriod)) @@ -142,9 +138,9 @@ func (w *WGWatcher) handshakeCheck(lastHandshake time.Time) (*time.Time, bool) { w.log.Tracef("previous handshake, handshake: %v, %v", lastHandshake, handshake) - // the current know handshake did not change + // the current known handshake did not change if handshake.Equal(lastHandshake) { - w.log.Warnf("WireGuard handshake timed out: %v", handshake) + w.log.Warnf("WireGuard handshake not updated: %v", handshake) return nil, false } diff --git a/client/internal/peer/wg_watcher_test.go b/client/internal/peer/wg_watcher_test.go index 3ce91cd46..6a5a9acfe 100644 --- a/client/internal/peer/wg_watcher_test.go +++ b/client/internal/peer/wg_watcher_test.go @@ -23,6 +23,72 @@ func (m *MocWgIface) disconnect() { m.stop = true } +type mockHandshakeStats struct { + mu sync.Mutex + handshake time.Time +} + +func (m *mockHandshakeStats) GetStats() (map[string]configurer.WGStats, error) { + m.mu.Lock() + defer m.mu.Unlock() + return map[string]configurer.WGStats{"": {LastHandshake: m.handshake}}, nil +} + +func (m *mockHandshakeStats) advance() { + m.mu.Lock() + defer m.mu.Unlock() + m.handshake = time.Now() +} + +// TestWGWatcher_CheckSuccessCallback: onCheckSuccessFn must fire for a fresh +// handshake even when the watcher started with an existing handshake baseline, +// the case where onHandshakeSuccessFn stays silent. +func TestWGWatcher_CheckSuccessCallback(t *testing.T) { + // checkPeriod bounds how stale a handshake may be before the watcher treats it + // as a suspended-machine timeout. The first check fires after wgHandshakeOvertime, + // so keep checkPeriod well above any scheduling jitter to avoid a false timeout + // converting the expected success into a disconnect on a loaded runner. + checkPeriod = 1 * time.Minute + wgHandshakeOvertime = 1 * time.Second + + mlog := log.WithField("peer", "tet") + // Use an old baseline so advance() yields a strictly newer handshake even on + // platforms with coarse clock resolution (Windows), where two time.Now() calls + // microseconds apart can return the same instant and read as a timed-out handshake. + stats := &mockHandshakeStats{handshake: time.Now().Add(-time.Hour)} + watcher := NewWGWatcher(mlog, stats, "", newStateDump("peer", mlog, &Status{})) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + watcher.PrepareInitialHandshake() + + firstHandshake := make(chan struct{}, 1) + checkSuccess := make(chan struct{}, 1) + go watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) { + firstHandshake <- struct{}{} + }, func() { + select { + case checkSuccess <- struct{}{}: + default: + } + }) + + stats.advance() + + select { + case <-checkSuccess: + case <-time.After(10 * time.Second): + t.Errorf("timeout waiting for check success callback") + } + + select { + case <-firstHandshake: + t.Errorf("first-handshake callback must not fire for a non-zero baseline") + default: + } +} + func TestWGWatcher_EnableWgWatcher(t *testing.T) { checkPeriod = 5 * time.Second wgHandshakeOvertime = 1 * time.Second @@ -34,13 +100,15 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + watcher.PrepareInitialHandshake() + onDisconnected := make(chan struct{}, 1) go watcher.EnableWgWatcher(ctx, time.Now(), func() { mlog.Infof("onDisconnectedFn") onDisconnected <- struct{}{} }, func(when time.Time) { mlog.Infof("onHandshakeSuccess: %v", when) - }) + }, nil) // wait for initial reading time.Sleep(2 * time.Second) @@ -62,11 +130,13 @@ func TestWGWatcher_ReEnable(t *testing.T) { watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{})) ctx, cancel := context.WithCancel(context.Background()) + watcher.PrepareInitialHandshake() + wg := &sync.WaitGroup{} wg.Add(1) go func() { defer wg.Done() - watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}) + watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {}, nil) }() cancel() @@ -76,10 +146,12 @@ func TestWGWatcher_ReEnable(t *testing.T) { ctx, cancel = context.WithCancel(context.Background()) defer cancel() + watcher.PrepareInitialHandshake() + onDisconnected := make(chan struct{}, 1) go watcher.EnableWgWatcher(ctx, time.Now(), func() { onDisconnected <- struct{}{} - }, func(when time.Time) {}) + }, func(when time.Time) {}, nil) time.Sleep(2 * time.Second) mocWgIface.disconnect() diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index b1aa3e0f9..83cac13f5 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -255,8 +255,8 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent return } - w.log.Debugf("turn agent dial") - remoteConn, err := w.turnAgentDial(ctx, agent, remoteOfferAnswer) + w.log.Debugf("agent dial") + remoteConn, err := w.agentDial(ctx, agent, remoteOfferAnswer) if err != nil { w.log.Debugf("failed to dial the remote peer: %s", err) w.closeAgent(agent, w.agentDialerCancel) @@ -389,6 +389,17 @@ func (w *WorkerICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) { return } + // A forwarded candidate only makes sense for an IPv4 mapping, which + // translates a port on the gateway's address. An IPv6 pinhole translates + // nothing: it unblocks the address ICE already gathers as a host candidate, + // so there is no second address to advertise. Injecting one here would also + // paste an IPv6 address onto whichever server-reflexive candidate arrived + // first, which is usually IPv4. + if mapping.ExternalIP != nil && mapping.ExternalIP.To4() == nil { + w.log.Debugf("skipping port-forwarded candidate: %s mapping is IPv6-only", mapping.NATType) + return + } + w.muxAgent.Lock() if w.portForwardAttempted { w.muxAgent.Unlock() @@ -517,8 +528,8 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia w.logSuccessfulPaths(agent) return case ice.ConnectionStateFailed, ice.ConnectionStateDisconnected, ice.ConnectionStateClosed: - // ice.ConnectionStateClosed happens when we recreate the agent. For the P2P to TURN switch important to - // notify the conn.onICEStateDisconnected changes to update the current used priority + // ice.ConnectionStateClosed happens when we recreate the agent. The P2P to relay switch requires + // notifying conn.onICEStateDisconnected so it can update the currently used priority. sessionChanged := w.closeAgent(agent, dialerCancel) @@ -532,7 +543,7 @@ func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dia } } -func (w *WorkerICE) turnAgentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) { +func (w *WorkerICE) agentDial(ctx context.Context, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) (*ice.Conn, error) { if isController(w.config) { return agent.Dial(ctx, remoteOfferAnswer.IceCredentials.UFrag, remoteOfferAnswer.IceCredentials.Pwd) } else { diff --git a/client/internal/peerstore/store.go b/client/internal/peerstore/store.go index 099fe4528..112caa101 100644 --- a/client/internal/peerstore/store.go +++ b/client/internal/peerstore/store.go @@ -88,11 +88,24 @@ func (s *Store) PeerConnOpen(ctx context.Context, pubKey string) { if !ok { return } - // this can be blocked because of the connect open limiter semaphore if err := p.Open(ctx); err != nil { p.Log.Errorf("failed to open peer connection: %v", err) } +} +// PeerConnOpenWithFirstPacket opens the peer connection and stashes a first packet to be +// reinjected once the real transport is established. +func (s *Store) PeerConnOpenWithFirstPacket(ctx context.Context, pubKey string, firstPacket []byte) { + s.peerConnsMu.RLock() + defer s.peerConnsMu.RUnlock() + + p, ok := s.peerConns[pubKey] + if !ok { + return + } + if err := p.OpenWithFirstPacket(ctx, firstPacket); err != nil { + p.Log.Errorf("failed to open peer connection: %v", err) + } } func (s *Store) PeerConnIdle(pubKey string) { diff --git a/client/internal/portforward/manager.go b/client/internal/portforward/manager.go index b0680160c..7d5a4cb9e 100644 --- a/client/internal/portforward/manager.go +++ b/client/internal/portforward/manager.go @@ -10,10 +10,8 @@ import ( "sync" "time" - "github.com/libp2p/go-nat" + "github.com/netbirdio/go-nat" log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal/portforward/pcp" ) const ( @@ -168,6 +166,11 @@ func (m *Manager) setup(ctx context.Context) (nat.NAT, *Mapping, error) { if err != nil { return nil, nil, fmt.Errorf("create port mapping: %w", err) } + + // Only meaningful once a mapping has been attempted: that is what opens the + // pinhole and records its outcome. + logIPv6Pinhole(gateway) + return gateway, mapping, nil } @@ -265,7 +268,9 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b return false } - pcpNAT, ok := gateway.(*pcp.NAT) + // Assert on the interface, not on a concrete type: a dual-stack gateway is + // a wrapper around the IPv4 NAT, so a type assertion misses it. + checker, ok := gateway.(nat.HealthChecker) if !ok { return false } @@ -273,7 +278,7 @@ func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) b ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - epoch, serverRestarted, err := pcpNAT.CheckServerHealth(ctx) + epoch, serverRestarted, err := checker.CheckServerHealth(ctx) if err != nil { log.Debugf("PCP health check failed: %v", err) return false @@ -340,3 +345,18 @@ func (m *Manager) startTearDown(ctx context.Context) { func isPermanentLeaseRequired(err error) bool { return err != nil && upnpErrPermanentLeaseOnly.MatchString(err.Error()) } + +// logIPv6Pinhole reports the outcome of the IPv6 pinhole. Pinholes are best +// effort and never fail a mapping on their own, so this is the only way to see +// whether one was actually opened. +func logIPv6Pinhole(gateway nat.NAT) { + reporter, ok := gateway.(nat.IPv6PinholeReporter) + if !ok { + return + } + if err := reporter.IPv6PinholeError(); err != nil { + log.Warnf("IPv6 pinhole: %v", err) + return + } + log.Infof("IPv6 pinhole open") +} diff --git a/client/internal/portforward/pcp/client.go b/client/internal/portforward/pcp/client.go deleted file mode 100644 index f6d243ef9..000000000 --- a/client/internal/portforward/pcp/client.go +++ /dev/null @@ -1,408 +0,0 @@ -package pcp - -import ( - "context" - "crypto/rand" - "errors" - "fmt" - "net" - "net/netip" - "sync" - "time" - - log "github.com/sirupsen/logrus" -) - -const ( - defaultTimeout = 3 * time.Second - responseBufferSize = 128 - - // RFC 6887 Section 8.1.1 retry timing - initialRetryDelay = 3 * time.Second - maxRetryDelay = 1024 * time.Second - maxRetries = 4 // 3s + 6s + 12s + 24s = 45s total worst case -) - -// Client is a PCP protocol client. -// All methods are safe for concurrent use. -type Client struct { - gateway netip.Addr - timeout time.Duration - - mu sync.Mutex - // localIP caches the resolved local IP address. - localIP netip.Addr - // lastEpoch is the last observed server epoch value. - lastEpoch uint32 - // epochTime tracks when lastEpoch was received for state loss detection. - epochTime time.Time - // externalIP caches the external IP from the last successful MAP response. - externalIP netip.Addr - // epochStateLost is set when epoch indicates server restart. - epochStateLost bool -} - -// NewClient creates a new PCP client for the gateway at the given IP. -func NewClient(gateway net.IP) *Client { - addr, ok := netip.AddrFromSlice(gateway) - if !ok { - log.Debugf("invalid gateway IP: %v", gateway) - } - return &Client{ - gateway: addr.Unmap(), - timeout: defaultTimeout, - } -} - -// NewClientWithTimeout creates a new PCP client with a custom timeout. -func NewClientWithTimeout(gateway net.IP, timeout time.Duration) *Client { - addr, ok := netip.AddrFromSlice(gateway) - if !ok { - log.Debugf("invalid gateway IP: %v", gateway) - } - return &Client{ - gateway: addr.Unmap(), - timeout: timeout, - } -} - -// SetLocalIP sets the local IP address to use in PCP requests. -func (c *Client) SetLocalIP(ip net.IP) { - addr, ok := netip.AddrFromSlice(ip) - if !ok { - log.Debugf("invalid local IP: %v", ip) - } - c.mu.Lock() - c.localIP = addr.Unmap() - c.mu.Unlock() -} - -// Gateway returns the gateway IP address. -func (c *Client) Gateway() net.IP { - return c.gateway.AsSlice() -} - -// Announce sends a PCP ANNOUNCE request to discover PCP support. -// Returns the server's epoch time on success. -func (c *Client) Announce(ctx context.Context) (epoch uint32, err error) { - localIP, err := c.getLocalIP() - if err != nil { - return 0, fmt.Errorf("get local IP: %w", err) - } - - req := buildAnnounceRequest(localIP) - resp, err := c.sendRequest(ctx, req) - if err != nil { - return 0, fmt.Errorf("send announce: %w", err) - } - - parsed, err := parseResponse(resp) - if err != nil { - return 0, fmt.Errorf("parse announce response: %w", err) - } - - if parsed.ResultCode != ResultSuccess { - return 0, fmt.Errorf("PCP ANNOUNCE failed: %s", ResultCodeString(parsed.ResultCode)) - } - - c.mu.Lock() - if c.updateEpochLocked(parsed.Epoch) { - log.Warnf("PCP server epoch indicates state loss - mappings may need refresh") - } - c.mu.Unlock() - return parsed.Epoch, nil -} - -// AddPortMapping requests a port mapping from the PCP server. -func (c *Client) AddPortMapping(ctx context.Context, protocol string, internalPort int, lifetime time.Duration) (*MapResponse, error) { - return c.addPortMappingWithHint(ctx, protocol, internalPort, internalPort, netip.Addr{}, lifetime) -} - -// AddPortMappingWithHint requests a port mapping with suggested external port and IP. -// Use lifetime <= 0 to delete a mapping. -func (c *Client) AddPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP net.IP, lifetime time.Duration) (*MapResponse, error) { - var extIP netip.Addr - if suggestedExtIP != nil { - var ok bool - extIP, ok = netip.AddrFromSlice(suggestedExtIP) - if !ok { - log.Debugf("invalid suggested external IP: %v", suggestedExtIP) - } - extIP = extIP.Unmap() - } - return c.addPortMappingWithHint(ctx, protocol, internalPort, suggestedExtPort, extIP, lifetime) -} - -func (c *Client) addPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP netip.Addr, lifetime time.Duration) (*MapResponse, error) { - localIP, err := c.getLocalIP() - if err != nil { - return nil, fmt.Errorf("get local IP: %w", err) - } - - proto, err := protocolNumber(protocol) - if err != nil { - return nil, fmt.Errorf("parse protocol: %w", err) - } - - var nonce [12]byte - if _, err := rand.Read(nonce[:]); err != nil { - return nil, fmt.Errorf("generate nonce: %w", err) - } - - // Convert lifetime to seconds. Lifetime 0 means delete, so only apply - // default for positive durations that round to 0 seconds. - var lifetimeSec uint32 - if lifetime > 0 { - lifetimeSec = uint32(lifetime.Seconds()) - if lifetimeSec == 0 { - lifetimeSec = DefaultLifetime - } - } - - req := buildMapRequest(localIP, nonce, proto, uint16(internalPort), uint16(suggestedExtPort), suggestedExtIP, lifetimeSec) - - resp, err := c.sendRequest(ctx, req) - if err != nil { - return nil, fmt.Errorf("send map request: %w", err) - } - - mapResp, err := parseMapResponse(resp) - if err != nil { - return nil, fmt.Errorf("parse map response: %w", err) - } - - if mapResp.Nonce != nonce { - return nil, fmt.Errorf("nonce mismatch in response") - } - - if mapResp.Protocol != proto { - return nil, fmt.Errorf("protocol mismatch: requested %d, got %d", proto, mapResp.Protocol) - } - if mapResp.InternalPort != uint16(internalPort) { - return nil, fmt.Errorf("internal port mismatch: requested %d, got %d", internalPort, mapResp.InternalPort) - } - - if mapResp.ResultCode != ResultSuccess { - return nil, &Error{ - Code: mapResp.ResultCode, - Message: ResultCodeString(mapResp.ResultCode), - } - } - - c.mu.Lock() - if c.updateEpochLocked(mapResp.Epoch) { - log.Warnf("PCP server epoch indicates state loss - mappings may need refresh") - } - c.cacheExternalIPLocked(mapResp.ExternalIP) - c.mu.Unlock() - return mapResp, nil -} - -// DeletePortMapping removes a port mapping by requesting zero lifetime. -func (c *Client) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { - if _, err := c.addPortMappingWithHint(ctx, protocol, internalPort, 0, netip.Addr{}, 0); err != nil { - var pcpErr *Error - if errors.As(err, &pcpErr) && pcpErr.Code == ResultNotAuthorized { - return nil - } - return fmt.Errorf("delete mapping: %w", err) - } - return nil -} - -// GetExternalAddress returns the external IP address. -// First checks for a cached value from previous MAP responses. -// If not cached, creates a short-lived mapping to discover the external IP. -func (c *Client) GetExternalAddress(ctx context.Context) (net.IP, error) { - c.mu.Lock() - if c.externalIP.IsValid() { - ip := c.externalIP.AsSlice() - c.mu.Unlock() - return ip, nil - } - c.mu.Unlock() - - // Use an ephemeral port in the dynamic range (49152-65535). - // Port 0 is not valid with UDP/TCP protocols per RFC 6887. - ephemeralPort := 49152 + int(uint16(time.Now().UnixNano()))%(65535-49152) - - // Use minimal lifetime (1 second) for discovery. - resp, err := c.AddPortMapping(ctx, "udp", ephemeralPort, time.Second) - if err != nil { - return nil, fmt.Errorf("create temporary mapping: %w", err) - } - - if err := c.DeletePortMapping(ctx, "udp", ephemeralPort); err != nil { - log.Debugf("cleanup temporary PCP mapping: %v", err) - } - - return resp.ExternalIP.AsSlice(), nil -} - -// LastEpoch returns the last observed server epoch value. -// A decrease in epoch indicates the server may have restarted and mappings may be lost. -func (c *Client) LastEpoch() uint32 { - c.mu.Lock() - defer c.mu.Unlock() - return c.lastEpoch -} - -// EpochStateLost returns true if epoch state loss was detected and clears the flag. -func (c *Client) EpochStateLost() bool { - c.mu.Lock() - defer c.mu.Unlock() - lost := c.epochStateLost - c.epochStateLost = false - return lost -} - -// updateEpoch updates the epoch tracking and detects potential state loss. -// Returns true if state loss was detected (server likely restarted). -// Caller must hold c.mu. -func (c *Client) updateEpochLocked(newEpoch uint32) bool { - now := time.Now() - stateLost := false - - // RFC 6887 Section 8.5: Detect invalid epoch indicating server state loss. - // client_delta = time since last response - // server_delta = epoch change since last response - // Invalid if: client_delta+2 < server_delta - server_delta/16 - // OR: server_delta+2 < client_delta - client_delta/16 - // The +2 handles quantization, /16 (6.25%) handles clock drift. - if !c.epochTime.IsZero() && c.lastEpoch > 0 { - clientDelta := uint32(now.Sub(c.epochTime).Seconds()) - serverDelta := newEpoch - c.lastEpoch - - // Check for epoch going backwards or jumping unexpectedly. - // Subtraction is safe: serverDelta/16 is always <= serverDelta. - if clientDelta+2 < serverDelta-(serverDelta/16) || - serverDelta+2 < clientDelta-(clientDelta/16) { - stateLost = true - c.epochStateLost = true - } - } - - c.lastEpoch = newEpoch - c.epochTime = now - return stateLost -} - -// cacheExternalIP stores the external IP from a successful MAP response. -// Caller must hold c.mu. -func (c *Client) cacheExternalIPLocked(ip netip.Addr) { - if ip.IsValid() && !ip.IsUnspecified() { - c.externalIP = ip - } -} - -// sendRequest sends a PCP request with retries per RFC 6887 Section 8.1.1. -func (c *Client) sendRequest(ctx context.Context, req []byte) ([]byte, error) { - addr := &net.UDPAddr{IP: c.gateway.AsSlice(), Port: Port} - - var lastErr error - delay := initialRetryDelay - - for range maxRetries { - resp, err := c.sendOnce(ctx, addr, req) - if err == nil { - return resp, nil - } - lastErr = err - - if ctx.Err() != nil { - return nil, ctx.Err() - } - - // RFC 6887 Section 8.1.1: RT = (1 + RAND) * MIN(2 * RTprev, MRT) - // RAND is random between -0.1 and +0.1 - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(retryDelayWithJitter(delay)): - } - delay = min(delay*2, maxRetryDelay) - } - - return nil, fmt.Errorf("PCP request failed after %d retries: %w", maxRetries, lastErr) -} - -// retryDelayWithJitter applies RFC 6887 jitter: multiply by (1 + RAND) where RAND is [-0.1, +0.1]. -func retryDelayWithJitter(d time.Duration) time.Duration { - var b [1]byte - _, _ = rand.Read(b[:]) - // Convert byte to range [-0.1, +0.1]: (b/255 * 0.2) - 0.1 - jitter := (float64(b[0])/255.0)*0.2 - 0.1 - return time.Duration(float64(d) * (1 + jitter)) -} - -func (c *Client) sendOnce(ctx context.Context, addr *net.UDPAddr, req []byte) ([]byte, error) { - // Use ListenUDP instead of DialUDP to validate response source address per RFC 6887 §8.3. - conn, err := net.ListenUDP("udp", nil) - if err != nil { - return nil, fmt.Errorf("listen: %w", err) - } - defer func() { - if err := conn.Close(); err != nil { - log.Debugf("close UDP connection: %v", err) - } - }() - - timeout := c.timeout - if deadline, ok := ctx.Deadline(); ok { - if remaining := time.Until(deadline); remaining < timeout { - timeout = remaining - } - } - - if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { - return nil, fmt.Errorf("set deadline: %w", err) - } - - if _, err := conn.WriteToUDP(req, addr); err != nil { - return nil, fmt.Errorf("write: %w", err) - } - - resp := make([]byte, responseBufferSize) - n, from, err := conn.ReadFromUDP(resp) - if err != nil { - return nil, fmt.Errorf("read: %w", err) - } - - // RFC 6887 §8.3: Validate response came from expected PCP server. - if !from.IP.Equal(addr.IP) { - return nil, fmt.Errorf("response from unexpected source %s (expected %s)", from.IP, addr.IP) - } - - return resp[:n], nil -} - -func (c *Client) getLocalIP() (netip.Addr, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if !c.localIP.IsValid() { - return netip.Addr{}, fmt.Errorf("local IP not set for gateway %s", c.gateway) - } - return c.localIP, nil -} - -func protocolNumber(protocol string) (uint8, error) { - switch protocol { - case "udp", "UDP": - return ProtoUDP, nil - case "tcp", "TCP": - return ProtoTCP, nil - default: - return 0, fmt.Errorf("unsupported protocol: %s", protocol) - } -} - -// Error represents a PCP error response. -type Error struct { - Code uint8 - Message string -} - -func (e *Error) Error() string { - return fmt.Sprintf("PCP error: %s (%d)", e.Message, e.Code) -} diff --git a/client/internal/portforward/pcp/client_test.go b/client/internal/portforward/pcp/client_test.go deleted file mode 100644 index 79f44a426..000000000 --- a/client/internal/portforward/pcp/client_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package pcp - -import ( - "context" - "net" - "net/netip" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAddrConversion(t *testing.T) { - tests := []struct { - name string - addr netip.Addr - }{ - {"IPv4", netip.MustParseAddr("192.168.1.100")}, - {"IPv4 loopback", netip.MustParseAddr("127.0.0.1")}, - {"IPv6", netip.MustParseAddr("2001:db8::1")}, - {"IPv6 loopback", netip.MustParseAddr("::1")}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - b16 := addrTo16(tt.addr) - - recovered := addrFrom16(b16) - assert.Equal(t, tt.addr, recovered, "address should round-trip") - }) - } -} - -func TestBuildAnnounceRequest(t *testing.T) { - clientIP := netip.MustParseAddr("192.168.1.100") - req := buildAnnounceRequest(clientIP) - - require.Len(t, req, headerSize) - assert.Equal(t, byte(Version), req[0], "version") - assert.Equal(t, byte(OpAnnounce), req[1], "opcode") - - // Check client IP is properly encoded as IPv4-mapped IPv6 - assert.Equal(t, byte(0xff), req[18], "IPv4-mapped prefix byte 10") - assert.Equal(t, byte(0xff), req[19], "IPv4-mapped prefix byte 11") - assert.Equal(t, byte(192), req[20], "IP octet 1") - assert.Equal(t, byte(168), req[21], "IP octet 2") - assert.Equal(t, byte(1), req[22], "IP octet 3") - assert.Equal(t, byte(100), req[23], "IP octet 4") -} - -func TestBuildMapRequest(t *testing.T) { - clientIP := netip.MustParseAddr("192.168.1.100") - nonce := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} - req := buildMapRequest(clientIP, nonce, ProtoUDP, 51820, 51820, netip.Addr{}, 3600) - - require.Len(t, req, mapRequestSize) - assert.Equal(t, byte(Version), req[0], "version") - assert.Equal(t, byte(OpMap), req[1], "opcode") - - // Lifetime at bytes 4-7 - assert.Equal(t, uint32(3600), (uint32(req[4])<<24)|(uint32(req[5])<<16)|(uint32(req[6])<<8)|uint32(req[7]), "lifetime") - - // Nonce at bytes 24-35 - assert.Equal(t, nonce[:], req[24:36], "nonce") - - // Protocol at byte 36 - assert.Equal(t, byte(ProtoUDP), req[36], "protocol") - - // Internal port at bytes 40-41 - assert.Equal(t, uint16(51820), (uint16(req[40])<<8)|uint16(req[41]), "internal port") - - // External port at bytes 42-43 - assert.Equal(t, uint16(51820), (uint16(req[42])<<8)|uint16(req[43]), "external port") -} - -func TestParseResponse(t *testing.T) { - // Construct a valid ANNOUNCE response - resp := make([]byte, headerSize) - resp[0] = Version - resp[1] = OpAnnounce | OpReply - // Result code = 0 (success) - // Lifetime = 0 - // Epoch = 12345 - resp[8] = 0 - resp[9] = 0 - resp[10] = 0x30 - resp[11] = 0x39 - - parsed, err := parseResponse(resp) - require.NoError(t, err) - assert.Equal(t, uint8(Version), parsed.Version) - assert.Equal(t, uint8(OpAnnounce|OpReply), parsed.Opcode) - assert.Equal(t, uint8(ResultSuccess), parsed.ResultCode) - assert.Equal(t, uint32(12345), parsed.Epoch) -} - -func TestParseResponseErrors(t *testing.T) { - t.Run("too short", func(t *testing.T) { - _, err := parseResponse([]byte{1, 2, 3}) - assert.Error(t, err) - }) - - t.Run("wrong version", func(t *testing.T) { - resp := make([]byte, headerSize) - resp[0] = 1 // Wrong version - resp[1] = OpReply - _, err := parseResponse(resp) - assert.Error(t, err) - }) - - t.Run("missing reply bit", func(t *testing.T) { - resp := make([]byte, headerSize) - resp[0] = Version - resp[1] = OpAnnounce // Missing OpReply bit - _, err := parseResponse(resp) - assert.Error(t, err) - }) -} - -func TestResultCodeString(t *testing.T) { - assert.Equal(t, "SUCCESS", ResultCodeString(ResultSuccess)) - assert.Equal(t, "NOT_AUTHORIZED", ResultCodeString(ResultNotAuthorized)) - assert.Equal(t, "ADDRESS_MISMATCH", ResultCodeString(ResultAddressMismatch)) - assert.Contains(t, ResultCodeString(255), "UNKNOWN") -} - -func TestProtocolNumber(t *testing.T) { - proto, err := protocolNumber("udp") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoUDP), proto) - - proto, err = protocolNumber("tcp") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoTCP), proto) - - proto, err = protocolNumber("UDP") - require.NoError(t, err) - assert.Equal(t, uint8(ProtoUDP), proto) - - _, err = protocolNumber("icmp") - assert.Error(t, err) -} - -func TestClientCreation(t *testing.T) { - gateway := netip.MustParseAddr("192.168.1.1").AsSlice() - - client := NewClient(gateway) - assert.Equal(t, net.IP(gateway), client.Gateway()) - assert.Equal(t, defaultTimeout, client.timeout) - - clientWithTimeout := NewClientWithTimeout(gateway, 5*time.Second) - assert.Equal(t, 5*time.Second, clientWithTimeout.timeout) -} - -func TestNATType(t *testing.T) { - n := NewNAT(netip.MustParseAddr("192.168.1.1").AsSlice(), netip.MustParseAddr("192.168.1.100").AsSlice()) - assert.Equal(t, "PCP", n.Type()) -} - -// Integration test - skipped unless PCP_TEST_GATEWAY env is set -func TestClientIntegration(t *testing.T) { - t.Skip("Integration test - run manually with PCP_TEST_GATEWAY=") - - gateway := netip.MustParseAddr("10.0.1.1").AsSlice() // Change to your test gateway - localIP := netip.MustParseAddr("10.0.1.100").AsSlice() // Change to your local IP - - client := NewClient(gateway) - client.SetLocalIP(localIP) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Test ANNOUNCE - epoch, err := client.Announce(ctx) - require.NoError(t, err) - t.Logf("Server epoch: %d", epoch) - - // Test MAP - resp, err := client.AddPortMapping(ctx, "udp", 51820, 1*time.Hour) - require.NoError(t, err) - t.Logf("Mapping: internal=%d external=%d externalIP=%s", - resp.InternalPort, resp.ExternalPort, resp.ExternalIP) - - // Cleanup - err = client.DeletePortMapping(ctx, "udp", 51820) - require.NoError(t, err) -} diff --git a/client/internal/portforward/pcp/nat.go b/client/internal/portforward/pcp/nat.go deleted file mode 100644 index 0e635b6c8..000000000 --- a/client/internal/portforward/pcp/nat.go +++ /dev/null @@ -1,222 +0,0 @@ -package pcp - -import ( - "context" - "fmt" - "net" - "net/netip" - "runtime" - "sync" - "time" - - log "github.com/sirupsen/logrus" - - "github.com/libp2p/go-nat" - "github.com/libp2p/go-netroute" -) - -var _ nat.NAT = (*NAT)(nil) - -// NAT implements the go-nat NAT interface using PCP. -// Supports dual-stack (IPv4 and IPv6) when available. -// All methods are safe for concurrent use. -// -// TODO: IPv6 pinholes use the local IPv6 address. If the address changes -// (e.g., due to SLAAC rotation or network change), the pinhole becomes stale -// and needs to be recreated with the new address. -type NAT struct { - client *Client - - mu sync.RWMutex - // client6 is the IPv6 PCP client, nil if IPv6 is unavailable. - client6 *Client - // localIP6 caches the local IPv6 address used for PCP requests. - localIP6 netip.Addr -} - -// NewNAT creates a new NAT instance backed by PCP. -func NewNAT(gateway, localIP net.IP) *NAT { - client := NewClient(gateway) - client.SetLocalIP(localIP) - return &NAT{ - client: client, - } -} - -// Type returns "PCP" as the NAT type. -func (n *NAT) Type() string { - return "PCP" -} - -// GetDeviceAddress returns the gateway IP address. -func (n *NAT) GetDeviceAddress() (net.IP, error) { - return n.client.Gateway(), nil -} - -// GetExternalAddress returns the external IP address. -func (n *NAT) GetExternalAddress() (net.IP, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return n.client.GetExternalAddress(ctx) -} - -// GetInternalAddress returns the local IP address used to communicate with the gateway. -func (n *NAT) GetInternalAddress() (net.IP, error) { - addr, err := n.client.getLocalIP() - if err != nil { - return nil, err - } - return addr.AsSlice(), nil -} - -// AddPortMapping creates a port mapping on both IPv4 and IPv6 (if available). -func (n *NAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, _ string, timeout time.Duration) (int, error) { - resp, err := n.client.AddPortMapping(ctx, protocol, internalPort, timeout) - if err != nil { - return 0, fmt.Errorf("add mapping: %w", err) - } - - n.mu.RLock() - client6 := n.client6 - localIP6 := n.localIP6 - n.mu.RUnlock() - - if client6 == nil { - return int(resp.ExternalPort), nil - } - - if _, err := client6.AddPortMapping(ctx, protocol, internalPort, timeout); err != nil { - log.Warnf("IPv6 PCP mapping failed (continuing with IPv4): %v", err) - return int(resp.ExternalPort), nil - } - - log.Infof("created IPv6 PCP pinhole: %s:%d", localIP6, internalPort) - return int(resp.ExternalPort), nil -} - -// DeletePortMapping removes a port mapping from both IPv4 and IPv6. -func (n *NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { - err := n.client.DeletePortMapping(ctx, protocol, internalPort) - - n.mu.RLock() - client6 := n.client6 - n.mu.RUnlock() - - if client6 != nil { - if err6 := client6.DeletePortMapping(ctx, protocol, internalPort); err6 != nil { - log.Warnf("IPv6 PCP delete mapping failed: %v", err6) - } - } - - if err != nil { - return fmt.Errorf("delete mapping: %w", err) - } - return nil -} - -// CheckServerHealth sends an ANNOUNCE to verify the server is still responsive. -// Returns the current epoch and whether the server may have restarted (epoch state loss detected). -func (n *NAT) CheckServerHealth(ctx context.Context) (epoch uint32, serverRestarted bool, err error) { - epoch, err = n.client.Announce(ctx) - if err != nil { - return 0, false, fmt.Errorf("announce: %w", err) - } - return epoch, n.client.EpochStateLost(), nil -} - -// DiscoverPCP attempts to discover a PCP-capable gateway. -// Returns a NAT interface if PCP is supported, or an error otherwise. -// Discovers both IPv4 and IPv6 gateways when available. -func DiscoverPCP(ctx context.Context) (nat.NAT, error) { - gateway, localIP, err := getDefaultGateway() - if err != nil { - return nil, fmt.Errorf("get default gateway: %w", err) - } - - client := NewClient(gateway) - client.SetLocalIP(localIP) - if _, err := client.Announce(ctx); err != nil { - return nil, fmt.Errorf("PCP announce: %w", err) - } - - result := &NAT{client: client} - discoverIPv6(ctx, result) - - return result, nil -} - -func discoverIPv6(ctx context.Context, result *NAT) { - gateway6, localIP6, err := getDefaultGateway6() - if err != nil { - log.Debugf("IPv6 gateway discovery failed: %v", err) - return - } - - client6 := NewClient(gateway6) - client6.SetLocalIP(localIP6) - if _, err := client6.Announce(ctx); err != nil { - log.Debugf("PCP IPv6 announce failed: %v", err) - return - } - - addr, ok := netip.AddrFromSlice(localIP6) - if !ok { - log.Debugf("invalid IPv6 local IP: %v", localIP6) - return - } - result.mu.Lock() - result.client6 = client6 - result.localIP6 = addr - result.mu.Unlock() - log.Debugf("PCP IPv6 gateway discovered: %s (local: %s)", gateway6, localIP6) -} - -// getDefaultGateway returns the default IPv4 gateway and local IP using the system routing table. -func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) { - router, err := netroute.New() - if err != nil { - return nil, nil, err - } - - dst := net.IPv4zero - if runtime.GOOS == "linux" || runtime.GOOS == "android" { - // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android. - // TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties / - // NWPathMonitor) when netlink-based lookup is restricted or unavailable. - dst = net.IPv4(0, 0, 0, 1) - } - _, gateway, localIP, err = router.Route(dst) - if err != nil { - return nil, nil, err - } - - if gateway == nil { - return nil, nil, nat.ErrNoNATFound - } - - return gateway, localIP, nil -} - -// getDefaultGateway6 returns the default IPv6 gateway IP address using the system routing table. -func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) { - router, err := netroute.New() - if err != nil { - return nil, nil, err - } - - dst := net.IPv6zero - if runtime.GOOS == "linux" || runtime.GOOS == "android" { - // ::2 - dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2} - } - _, gateway, localIP, err = router.Route(dst) - if err != nil { - return nil, nil, err - } - - if gateway == nil { - return nil, nil, nat.ErrNoNATFound - } - - return gateway, localIP, nil -} diff --git a/client/internal/portforward/pcp/protocol.go b/client/internal/portforward/pcp/protocol.go deleted file mode 100644 index d81c50c8c..000000000 --- a/client/internal/portforward/pcp/protocol.go +++ /dev/null @@ -1,225 +0,0 @@ -// Package pcp implements the Port Control Protocol (RFC 6887). -// -// # Implemented Features -// -// - ANNOUNCE opcode: Discovers PCP server support -// - MAP opcode: Creates/deletes port mappings (IPv4 NAT) and firewall pinholes (IPv6) -// - Dual-stack: Simultaneous IPv4 and IPv6 support via separate clients -// - Nonce validation: Prevents response spoofing -// - Epoch tracking: Detects server restarts per Section 8.5 -// - RFC-compliant retry timing: 3s initial, exponential backoff to 1024s max (Section 8.1.1) -// -// # Not Implemented -// -// - PEER opcode: For outbound peer connections (not needed for inbound NAT traversal) -// - THIRD_PARTY option: For managing mappings on behalf of other devices -// - PREFER_FAILURE option: Requires exact external port or fail (IPv4 NAT only, not needed for IPv6 pinholing) -// - FILTER option: To restrict remote peer addresses -// -// These optional features are omitted because the primary use case is simple -// port forwarding for WireGuard, which only requires MAP with default behavior. -package pcp - -import ( - "encoding/binary" - "fmt" - "net/netip" -) - -const ( - // Version is the PCP protocol version (RFC 6887). - Version = 2 - - // Port is the standard PCP server port. - Port = 5351 - - // DefaultLifetime is the default requested mapping lifetime in seconds. - DefaultLifetime = 7200 // 2 hours - - // Header sizes - headerSize = 24 - mapPayloadSize = 36 - mapRequestSize = headerSize + mapPayloadSize // 60 bytes -) - -// Opcodes -const ( - OpAnnounce = 0 - OpMap = 1 - OpPeer = 2 - OpReply = 0x80 // OR'd with opcode in responses -) - -// Protocol numbers for MAP requests -const ( - ProtoUDP = 17 - ProtoTCP = 6 -) - -// Result codes (RFC 6887 Section 7.4) -const ( - ResultSuccess = 0 - ResultUnsuppVersion = 1 - ResultNotAuthorized = 2 - ResultMalformedRequest = 3 - ResultUnsuppOpcode = 4 - ResultUnsuppOption = 5 - ResultMalformedOption = 6 - ResultNetworkFailure = 7 - ResultNoResources = 8 - ResultUnsuppProtocol = 9 - ResultUserExQuota = 10 - ResultCannotProvideExt = 11 - ResultAddressMismatch = 12 - ResultExcessiveRemotePeers = 13 -) - -// ResultCodeString returns a human-readable string for a result code. -func ResultCodeString(code uint8) string { - switch code { - case ResultSuccess: - return "SUCCESS" - case ResultUnsuppVersion: - return "UNSUPP_VERSION" - case ResultNotAuthorized: - return "NOT_AUTHORIZED" - case ResultMalformedRequest: - return "MALFORMED_REQUEST" - case ResultUnsuppOpcode: - return "UNSUPP_OPCODE" - case ResultUnsuppOption: - return "UNSUPP_OPTION" - case ResultMalformedOption: - return "MALFORMED_OPTION" - case ResultNetworkFailure: - return "NETWORK_FAILURE" - case ResultNoResources: - return "NO_RESOURCES" - case ResultUnsuppProtocol: - return "UNSUPP_PROTOCOL" - case ResultUserExQuota: - return "USER_EX_QUOTA" - case ResultCannotProvideExt: - return "CANNOT_PROVIDE_EXTERNAL" - case ResultAddressMismatch: - return "ADDRESS_MISMATCH" - case ResultExcessiveRemotePeers: - return "EXCESSIVE_REMOTE_PEERS" - default: - return fmt.Sprintf("UNKNOWN(%d)", code) - } -} - -// Response represents a parsed PCP response header. -type Response struct { - Version uint8 - Opcode uint8 - ResultCode uint8 - Lifetime uint32 - Epoch uint32 -} - -// MapResponse contains the full response to a MAP request. -type MapResponse struct { - Response - Nonce [12]byte - Protocol uint8 - InternalPort uint16 - ExternalPort uint16 - ExternalIP netip.Addr -} - -// addrTo16 converts an address to its 16-byte IPv4-mapped IPv6 representation. -func addrTo16(addr netip.Addr) [16]byte { - if addr.Is4() { - return netip.AddrFrom4(addr.As4()).As16() - } - return addr.As16() -} - -// addrFrom16 extracts an address from a 16-byte representation, unmapping IPv4. -func addrFrom16(b [16]byte) netip.Addr { - return netip.AddrFrom16(b).Unmap() -} - -// buildAnnounceRequest creates a PCP ANNOUNCE request packet. -func buildAnnounceRequest(clientIP netip.Addr) []byte { - req := make([]byte, headerSize) - req[0] = Version - req[1] = OpAnnounce - mapped := addrTo16(clientIP) - copy(req[8:24], mapped[:]) - return req -} - -// buildMapRequest creates a PCP MAP request packet. -func buildMapRequest(clientIP netip.Addr, nonce [12]byte, protocol uint8, internalPort, suggestedExtPort uint16, suggestedExtIP netip.Addr, lifetime uint32) []byte { - req := make([]byte, mapRequestSize) - - // Header - req[0] = Version - req[1] = OpMap - binary.BigEndian.PutUint32(req[4:8], lifetime) - mapped := addrTo16(clientIP) - copy(req[8:24], mapped[:]) - - // MAP payload - copy(req[24:36], nonce[:]) - req[36] = protocol - binary.BigEndian.PutUint16(req[40:42], internalPort) - binary.BigEndian.PutUint16(req[42:44], suggestedExtPort) - if suggestedExtIP.IsValid() { - extMapped := addrTo16(suggestedExtIP) - copy(req[44:60], extMapped[:]) - } - - return req -} - -// parseResponse parses the common PCP response header. -func parseResponse(data []byte) (*Response, error) { - if len(data) < headerSize { - return nil, fmt.Errorf("response too short: %d bytes", len(data)) - } - - resp := &Response{ - Version: data[0], - Opcode: data[1], - ResultCode: data[3], // Byte 2 is reserved, byte 3 is result code (RFC 6887 §7.2) - Lifetime: binary.BigEndian.Uint32(data[4:8]), - Epoch: binary.BigEndian.Uint32(data[8:12]), - } - - if resp.Version != Version { - return nil, fmt.Errorf("unsupported PCP version: %d", resp.Version) - } - - if resp.Opcode&OpReply == 0 { - return nil, fmt.Errorf("response missing reply bit: opcode=0x%02x", resp.Opcode) - } - - return resp, nil -} - -// parseMapResponse parses a complete MAP response. -func parseMapResponse(data []byte) (*MapResponse, error) { - if len(data) < mapRequestSize { - return nil, fmt.Errorf("MAP response too short: %d bytes", len(data)) - } - - resp, err := parseResponse(data) - if err != nil { - return nil, fmt.Errorf("parse header: %w", err) - } - - mapResp := &MapResponse{ - Response: *resp, - Protocol: data[36], - InternalPort: binary.BigEndian.Uint16(data[40:42]), - ExternalPort: binary.BigEndian.Uint16(data[42:44]), - ExternalIP: addrFrom16([16]byte(data[44:60])), - } - copy(mapResp.Nonce[:], data[24:36]) - - return mapResp, nil -} diff --git a/client/internal/portforward/pinhole_test.go b/client/internal/portforward/pinhole_test.go new file mode 100644 index 000000000..46b07a9e7 --- /dev/null +++ b/client/internal/portforward/pinhole_test.go @@ -0,0 +1,116 @@ +//go:build !js + +package portforward + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/netbirdio/go-nat" + log "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockPinholeNAT is a gateway that also reports an IPv6 pinhole outcome, the +// shape a dual-stack gateway has. +type mockPinholeNAT struct { + *mockNAT + pinholeErr error +} + +func (m *mockPinholeNAT) IPv6PinholeError() error { + return m.pinholeErr +} + +func TestSetupLogsPinholeOutcome(t *testing.T) { + pinholeErr := errors.New("pcp ipv6: NOT_AUTHORIZED") + + tests := []struct { + name string + pinholeErr error + mappingErr error + wantLevel log.Level + wantText string + }{ + { + name: "an open pinhole is reported", + wantLevel: log.InfoLevel, + wantText: "IPv6 pinhole open", + }, + { + name: "a failed pinhole is reported without failing the mapping", + // The IPv4 mapping is what the caller asked for, so the pinhole + // failure surfaces only in the log. + pinholeErr: pinholeErr, + wantLevel: log.WarnLevel, + wantText: pinholeErr.Error(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gateway := &mockPinholeNAT{mockNAT: newMockNAT(), pinholeErr: tt.pinholeErr} + hook := stubGatewayDiscovery(t, gateway) + + m := NewManager() + m.wgPort = 51820 + + _, mapping, err := m.setup(context.Background()) + + require.NoError(t, err) + require.NotNil(t, mapping) + + entry := findEntry(hook, tt.wantText) + require.NotNil(t, entry, "no log entry mentioning %q", tt.wantText) + assert.Equal(t, tt.wantLevel, entry.Level) + }) + } + + t.Run("a failed mapping reports no pinhole outcome", func(t *testing.T) { + // Nothing opened the pinhole, so whatever it currently reports says + // nothing about this attempt. + gateway := &mockPinholeNAT{mockNAT: newMockNAT()} + gateway.addMappingErr = errors.New("gateway refused") + hook := stubGatewayDiscovery(t, gateway) + + m := NewManager() + m.wgPort = 51820 + + _, _, err := m.setup(context.Background()) + + require.Error(t, err) + assert.Nil(t, findEntry(hook, "IPv6 pinhole")) + }) +} + +// stubGatewayDiscovery makes discovery return gateway and captures log output. +func stubGatewayDiscovery(t *testing.T, gateway nat.NAT) *test.Hook { + t.Helper() + + orig := discoverGateway + discoverGateway = func(context.Context) (nat.NAT, error) { return gateway, nil } + t.Cleanup(func() { discoverGateway = orig }) + + hook := test.NewGlobal() + origLevel := log.GetLevel() + log.SetLevel(log.DebugLevel) + t.Cleanup(func() { + hook.Reset() + log.SetLevel(origLevel) + }) + + return hook +} + +func findEntry(hook *test.Hook, substr string) *log.Entry { + for _, entry := range hook.AllEntries() { + if strings.Contains(entry.Message, substr) { + return entry + } + } + return nil +} diff --git a/client/internal/portforward/state.go b/client/internal/portforward/state.go index b1315cdc0..a21368e58 100644 --- a/client/internal/portforward/state.go +++ b/client/internal/portforward/state.go @@ -4,27 +4,94 @@ package portforward import ( "context" + "errors" "fmt" + "time" - "github.com/libp2p/go-nat" + "github.com/netbirdio/go-nat" + "github.com/netbirdio/go-nat/pcp" log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal/portforward/pcp" ) // discoverGateway is the function used for NAT gateway discovery. // It can be replaced in tests to avoid real network operations. -// Tries PCP first, then falls back to NAT-PMP/UPnP. var discoverGateway = defaultDiscoverGateway -func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) { - pcpGateway, err := pcp.DiscoverPCP(ctx) - if err == nil { - return pcpGateway, nil - } - log.Debugf("PCP discovery failed: %v, trying NAT-PMP/UPnP", err) +// pinholeDiscoveryTimeout is the slice of the discovery budget held back for +// the IPv6 pinhole probe. +// +// Sizing it is coarser than it looks: PCP retransmits on a 3s socket timeout +// and a 3s first backoff, so a second attempt needs about 9s. Anything from +// roughly 1s to 8s therefore buys exactly one attempt, and this only sets how +// long that attempt waits. A PCP server sits on the local link and answers in +// milliseconds, so 3s is margin rather than need, and the rest is left to +// gateway discovery, whose multicast SSDP search alone takes 5s. A probe lost +// to a dropped packet is retried by the next discovery round. +// +// It is a variable so tests can shorten it. +var pinholeDiscoveryTimeout = 3 * time.Second - return nat.DiscoverGateway(ctx) +// Discovery entry points, as variables so tests can drive the fallback without +// touching the network. +var ( + discoverNATGateway = nat.DiscoverGateway + + discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) { + pinhole, err := pcp.DiscoverPCP(ctx) + if err != nil { + return nil, err + } + return pinhole, nil + } +) + +// defaultDiscoverGateway finds a gateway that can make the WireGuard port +// reachable. DiscoverGateway prefers PCP for IPv4, races UPnP and NAT-PMP +// behind it, and attaches an IPv6 pinhole independently of which IPv4 protocol +// wins. +// +// It reports no gateway on a network offering only IPv6, having no IPv4 mapping +// to attach a pinhole to. Such a network still needs one: there is no +// translation to traverse, but the router drops inbound IPv6 until something +// opens it. Fall back to PCP alone, which yields a gateway holding just the +// pinhole. +func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) { + gatewayCtx, cancel := reserveForPinhole(ctx) + defer cancel() + + gateway, err := discoverNATGateway(gatewayCtx) + if err == nil { + return gateway, nil + } + if !errors.Is(err, nat.ErrNoNATFound) { + return nil, err + } + + pinhole, pinholeErr := discoverPCPPinhole(ctx) + if pinholeErr != nil { + log.Debugf("no IPv6 pinhole after %v: %v", err, pinholeErr) + return nil, err + } + + log.Infof("no IPv4 gateway, continuing with an IPv6 pinhole only") + return pinhole, nil +} + +// reserveForPinhole shortens ctx so that a pinhole probe still has time to run +// afterwards. Finding nothing takes gateway discovery everything it is given, +// so on the unshortened context the probe would start already expired. A budget +// too small to divide is left to gateway discovery, which is the likelier win. +func reserveForPinhole(ctx context.Context) (context.Context, context.CancelFunc) { + deadline, ok := ctx.Deadline() + if !ok { + return context.WithCancel(ctx) + } + + remaining := time.Until(deadline) + if remaining <= pinholeDiscoveryTimeout { + return context.WithCancel(ctx) + } + return context.WithTimeout(ctx, remaining-pinholeDiscoveryTimeout) } // State is persisted only for crash recovery cleanup diff --git a/client/internal/portforward/state_test.go b/client/internal/portforward/state_test.go new file mode 100644 index 000000000..8a584eecb --- /dev/null +++ b/client/internal/portforward/state_test.go @@ -0,0 +1,140 @@ +//go:build !js + +package portforward + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/netbirdio/go-nat" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubDiscovery replaces both discovery entry points for the duration of a +// test. gatewayDelay simulates gateway discovery spending everything it is +// given before reporting that it found nothing. +func stubDiscovery(t *testing.T, gateway nat.NAT, gatewayErr error, gatewayDelay time.Duration, pinhole nat.NAT, pinholeErr error) { + t.Helper() + + origGateway, origPinhole := discoverNATGateway, discoverPCPPinhole + discoverNATGateway = func(ctx context.Context) (nat.NAT, error) { + if gatewayDelay > 0 { + select { + case <-time.After(gatewayDelay): + case <-ctx.Done(): + } + } + return gateway, gatewayErr + } + discoverPCPPinhole = func(ctx context.Context) (nat.NAT, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return pinhole, pinholeErr + } + + t.Cleanup(func() { discoverNATGateway, discoverPCPPinhole = origGateway, origPinhole }) +} + +func TestDefaultDiscoverGateway(t *testing.T) { + ipv4Gateway := &mockNAT{natType: "PCP+PCPv6"} + ipv6Pinhole := &mockNAT{natType: "PCP"} + otherErr := errors.New("routing table unavailable") + + t.Run("an IPv4 gateway is used as is", func(t *testing.T) { + stubDiscovery(t, ipv4Gateway, nil, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + require.NoError(t, err) + assert.Same(t, ipv4Gateway, got) + }) + + t.Run("no IPv4 gateway still opens an IPv6 pinhole", func(t *testing.T) { + stubDiscovery(t, nil, nat.ErrNoNATFound, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + require.NoError(t, err) + assert.Same(t, ipv6Pinhole, got) + }) + + t.Run("no gateway and no pinhole reports the original failure", func(t *testing.T) { + stubDiscovery(t, nil, nat.ErrNoNATFound, 0, nil, errors.New("no IPv6 route")) + + got, err := defaultDiscoverGateway(context.Background()) + + assert.Nil(t, got) + assert.ErrorIs(t, err, nat.ErrNoNATFound, "the pinhole failure must not mask why no gateway was found") + }) + + t.Run("a failure other than no-gateway is reported as is", func(t *testing.T) { + stubDiscovery(t, nil, otherErr, 0, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(context.Background()) + + assert.Nil(t, got) + assert.ErrorIs(t, err, otherErr) + }) + + t.Run("the pinhole survives gateway discovery using its whole budget", func(t *testing.T) { + // On one shared context the probe would start already expired, which is + // how this failed against a real gateway. + reserve := 50 * time.Millisecond + origReserve := pinholeDiscoveryTimeout + pinholeDiscoveryTimeout = reserve + t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve }) + + budget := 4 * reserve + ctx, cancel := context.WithTimeout(context.Background(), budget) + defer cancel() + + stubDiscovery(t, nil, nat.ErrNoNATFound, budget, ipv6Pinhole, nil) + + got, err := defaultDiscoverGateway(ctx) + + require.NoError(t, err) + assert.Same(t, ipv6Pinhole, got) + }) +} + +func TestReserveForPinhole(t *testing.T) { + origReserve := pinholeDiscoveryTimeout + pinholeDiscoveryTimeout = time.Second + t.Cleanup(func() { pinholeDiscoveryTimeout = origReserve }) + + t.Run("a budget is divided", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + gatewayCtx, cancelGateway := reserveForPinhole(ctx) + defer cancelGateway() + + deadline, ok := gatewayCtx.Deadline() + require.True(t, ok) + assert.InDelta(t, 9*time.Second, time.Until(deadline), float64(500*time.Millisecond)) + }) + + t.Run("a budget too small to divide is left whole", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + gatewayCtx, cancelGateway := reserveForPinhole(ctx) + defer cancelGateway() + + deadline, ok := gatewayCtx.Deadline() + require.True(t, ok) + assert.InDelta(t, 500*time.Millisecond, time.Until(deadline), float64(100*time.Millisecond)) + }) + + t.Run("no deadline stays unbounded", func(t *testing.T) { + gatewayCtx, cancelGateway := reserveForPinhole(context.Background()) + defer cancelGateway() + + _, ok := gatewayCtx.Deadline() + assert.False(t, ok) + }) +} diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index a77f0ff32..e1668238e 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -96,13 +96,12 @@ type ConfigInput struct { BlockLANAccess *bool BlockInbound *bool DisableIPv6 *bool + SyncMessageVersion *int DisableNotifications *bool DNSLabels domain.List - LazyConnectionEnabled *bool - MTU *uint16 } @@ -139,6 +138,7 @@ type Config struct { BlockLANAccess bool BlockInbound bool DisableIPv6 bool + SyncMessageVersion *int DisableNotifications *bool @@ -180,7 +180,9 @@ type Config struct { ClientCertKeyPair *tls.Certificate `json:"-"` - LazyConnectionEnabled bool + // LazyConnection is the MDM-managed lazy-connection override ("on"/"off"/""). + // Runtime-only: re-derived from MDM policy on each load, never persisted. + LazyConnection string `json:"-"` MTU uint16 @@ -386,7 +388,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.NetworkMonitor != nil && input.NetworkMonitor != config.NetworkMonitor { + if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) { log.Infof("switching Network Monitor to %t", *input.NetworkMonitor) config.NetworkMonitor = input.NetworkMonitor updated = true @@ -433,7 +435,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.ServerSSHAllowed != nil && *input.ServerSSHAllowed != *config.ServerSSHAllowed { + if input.ServerSSHAllowed != nil && (config.ServerSSHAllowed == nil || *input.ServerSSHAllowed != *config.ServerSSHAllowed) { if *input.ServerSSHAllowed { log.Infof("enabling SSH server") } else { @@ -454,7 +456,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHRoot != nil && input.EnableSSHRoot != config.EnableSSHRoot { + if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) { if *input.EnableSSHRoot { log.Infof("enabling SSH root login") } else { @@ -464,7 +466,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHSFTP != nil && input.EnableSSHSFTP != config.EnableSSHSFTP { + if input.EnableSSHSFTP != nil && (config.EnableSSHSFTP == nil || *input.EnableSSHSFTP != *config.EnableSSHSFTP) { if *input.EnableSSHSFTP { log.Infof("enabling SSH SFTP subsystem") } else { @@ -474,7 +476,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHLocalPortForwarding != nil && input.EnableSSHLocalPortForwarding != config.EnableSSHLocalPortForwarding { + if input.EnableSSHLocalPortForwarding != nil && (config.EnableSSHLocalPortForwarding == nil || *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding) { if *input.EnableSSHLocalPortForwarding { log.Infof("enabling SSH local port forwarding") } else { @@ -484,7 +486,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHRemotePortForwarding != nil && input.EnableSSHRemotePortForwarding != config.EnableSSHRemotePortForwarding { + if input.EnableSSHRemotePortForwarding != nil && (config.EnableSSHRemotePortForwarding == nil || *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding) { if *input.EnableSSHRemotePortForwarding { log.Infof("enabling SSH remote port forwarding") } else { @@ -494,7 +496,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.DisableSSHAuth != nil && input.DisableSSHAuth != config.DisableSSHAuth { + if input.DisableSSHAuth != nil && (config.DisableSSHAuth == nil || *input.DisableSSHAuth != *config.DisableSSHAuth) { if *input.DisableSSHAuth { log.Infof("disabling SSH authentication") } else { @@ -504,7 +506,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.SSHJWTCacheTTL != nil && input.SSHJWTCacheTTL != config.SSHJWTCacheTTL { + if input.SSHJWTCacheTTL != nil && (config.SSHJWTCacheTTL == nil || *input.SSHJWTCacheTTL != *config.SSHJWTCacheTTL) { log.Infof("updating SSH JWT cache TTL to %d seconds", *input.SSHJWTCacheTTL) config.SSHJWTCacheTTL = input.SSHJWTCacheTTL updated = true @@ -587,7 +589,13 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.DisableNotifications != nil && input.DisableNotifications != config.DisableNotifications { + if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion { + log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion) + *config.SyncMessageVersion = *input.SyncMessageVersion + updated = true + } + + if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) { if *input.DisableNotifications { log.Infof("disabling notifications") } else { @@ -632,12 +640,6 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.LazyConnectionEnabled != nil && *input.LazyConnectionEnabled != config.LazyConnectionEnabled { - log.Infof("switching lazy connection to %t", *input.LazyConnectionEnabled) - config.LazyConnectionEnabled = *input.LazyConnectionEnabled - updated = true - } - if input.MTU != nil && *input.MTU != config.MTU { log.Infof("updating MTU to %d (old value %d)", *input.MTU, config.MTU) config.MTU = *input.MTU @@ -728,6 +730,15 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) { log.Warnf("MDM wireguard port %d out of range [1,65535]; keeping previous value", v) } } + + if v, ok := policy.GetBool(mdm.KeyLazyConnection); ok { + state := "off" + if v { + state = "on" + } + config.LazyConnection = state + logApplied(mdm.KeyLazyConnection, state) + } } // parseURL parses and validates the URL for the named service. The URL @@ -735,6 +746,13 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) { // appended for https or ":80" for http. The serviceName parameter is // used to contextualise error messages. On success returns the parsed // *url.URL; on failure returns a non-nil error. +// ParseServiceURL normalises a service URL exactly as the config layer does when +// it stores one, so callers comparing a requested URL against a stored one do not +// have to reimplement the scheme validation and default-port handling. +func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) { + return parseURL(serviceName, serviceURL) +} + func parseURL(serviceName, serviceURL string) (*url.URL, error) { parsedMgmtURL, err := url.ParseRequestURI(serviceURL) if err != nil { diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index 6a201235e..c6a688ab2 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -130,6 +130,37 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) { assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled)) } +func TestApply_MDMLazyConnection(t *testing.T) { + cases := []struct { + name string + raw any + want string + }{ + {"native true", true, "on"}, + {"native false", false, "off"}, + {"string on", "on", "on"}, + {"string off", "off", "off"}, + {"string yes", "yes", "on"}, + {"string no", "no", "off"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyLazyConnection: c.raw, + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, c.want, cfg.LazyConnection) + assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection)) + }) + } +} + func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) { const maskSentinel = "**********" diff --git a/client/internal/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go index 5216f2423..736ff3412 100644 --- a/client/internal/profilemanager/config_test.go +++ b/client/internal/profilemanager/config_test.go @@ -242,6 +242,35 @@ func TestWireguardPortDefaultVsExplicit(t *testing.T) { } } +func TestUpdateConfigServerSSHAllowedNotSet(t *testing.T) { + // Configs written before ServerSSHAllowed was introduced lack the field and + // unmarshal to nil. Supplying the SSH server flag on top of such a config must + // apply the value instead of panicking on a nil pointer dereference. + tests := []struct { + name string + input *bool + want bool + }{ + {"enable", util.True(), true}, + {"disable", util.False(), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600)) + + config, err := UpdateConfig(ConfigInput{ + ConfigPath: configPath, + ServerSSHAllowed: tt.input, + }) + require.NoError(t, err) + require.NotNil(t, config.ServerSSHAllowed, "ServerSSHAllowed should be set from input") + assert.Equal(t, tt.want, *config.ServerSSHAllowed) + }) + } +} + func TestUpdateOldManagementURL(t *testing.T) { origProber := newMgmProber newMgmProber = func(_ context.Context, _ string, _ wgtypes.Key, _ bool) (mgmProber, error) { diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go index 7d5b1674c..ec287f01a 100644 --- a/client/internal/profilemanager/service.go +++ b/client/internal/profilemanager/service.go @@ -11,6 +11,7 @@ import ( "runtime" "sort" "strings" + "syscall" log "github.com/sirupsen/logrus" @@ -444,7 +445,11 @@ func (s *ServiceManager) GetStatePath() string { activeProf, err := s.GetActiveProfileState() if err != nil { - log.Warnf("failed to get active profile state: %v", err) + if errors.Is(err, syscall.ENOSYS) { + log.Debugf("active profile state unavailable on this platform: %v", err) + } else { + log.Warnf("failed to get active profile state: %v", err) + } return defaultStatePath } diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go index 1bf3318af..ddb5dd056 100644 --- a/client/internal/profilemanager/state.go +++ b/client/internal/profilemanager/state.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "path/filepath" "github.com/netbirdio/netbird/util" @@ -44,12 +45,35 @@ func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) { return &state, nil } -func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { +// SetProfileState writes the state file of the profile identified by id. Prefer +// it over SetActiveProfileState whenever the caller knows which profile the data +// belongs to: an SSO login spans seconds of user interaction, and the active +// profile can change during it, which would file the account email under +// whichever profile happened to be active when the flow returned. +func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error { configDir, err := getConfigDir() if err != nil { return fmt.Errorf("get config directory: %w", err) } + if id == "" { + return fmt.Errorf("empty profile ID") + } + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid profile ID: %q", id) + } + + stateFile := filepath.Join(configDir, id.String()+".state.json") + if err := util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state); err != nil { + return fmt.Errorf("write profile state: %w", err) + } + + return nil +} + +// SetActiveProfileState writes the state file of whichever profile is active at +// call time. Use SetProfileState when the target profile is known. +func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { activeProf, err := pm.GetActiveProfile() if err != nil { if errors.Is(err, ErrNoActiveProfile) { @@ -58,15 +82,24 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { return fmt.Errorf("get active profile: %w", err) } - id := activeProf.ID - if id != defaultProfileName && !IsValidProfileFilenameStem(id) { - return fmt.Errorf("invalid active profile ID: %q", id) + return pm.SetProfileState(activeProf.ID, state) +} + +// RemoveProfileState deletes the per-profile state file (which holds the +// account email used for the SSO login hint and the UI display). Called after +// profile removal; logout keeps the file so the next login can pass the email +// as the login_hint. The state file only stores the email, so deleting it is +// equivalent to clearing it; the next SSO login recreates it. A missing file +// is not an error. +func (pm *ProfileManager) RemoveProfileState(profileName string) error { + configDir, err := getConfigDir() + if err != nil { + return fmt.Errorf("get config directory: %w", err) } - 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) + stateFile := filepath.Join(configDir, profileName+".state.json") + if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove profile state: %w", err) } return nil diff --git a/client/internal/rosenpass/manager.go b/client/internal/rosenpass/manager.go index 903753753..21dd751df 100644 --- a/client/internal/rosenpass/manager.go +++ b/client/internal/rosenpass/manager.go @@ -39,6 +39,7 @@ type rpServer interface { type Manager struct { ifaceName string + localWgKey wgtypes.Key spk []byte ssk []byte rpKeyHash string @@ -51,8 +52,9 @@ type Manager struct { wgIface PresharedKeySetter } -// NewManager creates a new Rosenpass manager -func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) { +// NewManager creates a new Rosenpass manager. localWgKey is the local +// WireGuard public key, used to derive the per-peer rendezvous key. +func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string, localWgKey wgtypes.Key) (*Manager, error) { public, secret, err := rp.GenerateKeyPair() if err != nil { return nil, err @@ -62,6 +64,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash) return &Manager{ ifaceName: wgIfaceName, + localWgKey: localWgKey, rpKeyHash: rpKeyHash, spk: public, ssk: secret, @@ -73,7 +76,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error) // nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will // replace it with a fresh handler on each Run() to clear stale peer // state from previous engine sessions. - rpWgHandler: NewNetbirdHandler(), + rpWgHandler: NewNetbirdHandler((*[32]byte)(preSharedKey), localWgKey), lock: sync.Mutex{}, }, nil } @@ -161,7 +164,7 @@ func (m *Manager) generateConfig() (rp.Config, error) { cfg.Peers = []rp.PeerConfig{} m.lock.Lock() - m.rpWgHandler = NewNetbirdHandler() + m.rpWgHandler = NewNetbirdHandler(m.preSharedKey, m.localWgKey) if m.wgIface != nil { m.rpWgHandler.SetInterface(m.wgIface) } diff --git a/client/internal/rosenpass/manager_test.go b/client/internal/rosenpass/manager_test.go index d74960d0d..69e18ac88 100644 --- a/client/internal/rosenpass/manager_test.go +++ b/client/internal/rosenpass/manager_test.go @@ -85,7 +85,7 @@ func newTestManager(spkFirstByte byte, mock *mockServer) *Manager { ssk: make([]byte, 32), rpKeyHash: "test-hash", rpPeerIDs: make(map[string]*rp.PeerID), - rpWgHandler: NewNetbirdHandler(), + rpWgHandler: NewNetbirdHandler(nil, wgtypes.Key{0x01}), server: mock, } } @@ -255,7 +255,7 @@ func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) { // issue #4341 cannot occur in the window between NewManager and Run(). func TestNewManager_PreInitializesHandler(t *testing.T) { psk := wgtypes.Key{} - m, err := NewManager(&psk, "wt0") + m, err := NewManager(&psk, "wt0", wgtypes.Key{0x01}) require.NoError(t, err) require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager") } @@ -329,10 +329,10 @@ func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing require.False(t, m.IsPresharedKeyInitialized(wgKey)) } -// --- NetbirdHandler.outputKey ---------------------------------------------- +// --- NetbirdHandler.applyKey ---------------------------------------------- -func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -348,8 +348,8 @@ func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) { require.Equal(t, wgKey.String(), iface.calls[0].peerKey) } -func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -364,8 +364,8 @@ func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) { require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true") } -func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_NilInterface_NoCrashNoCall(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) // no SetInterface — iface remains nil pid := rp.PeerID{0x03} h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{})) @@ -374,8 +374,8 @@ func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) { h.HandshakeCompleted(pid, rp.Key{}) } -func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) { - h := NewNetbirdHandler() +func TestHandler_ApplyKey_UnknownPeer_NoCall(t *testing.T) { + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -384,7 +384,7 @@ func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) { } func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) { - h := NewNetbirdHandler() + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) iface := &mockIface{} h.SetInterface(iface) @@ -398,7 +398,7 @@ func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) { } func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) { - h := NewNetbirdHandler() + h := NewNetbirdHandler(nil, wgtypes.Key{0x01}) pid := rp.PeerID{0x05} wgKey := wgtypes.Key{0xEE} h.AddPeer(pid, "wt0", rp.Key(wgKey)) diff --git a/client/internal/rosenpass/netbird_handler.go b/client/internal/rosenpass/netbird_handler.go index 9de2409ef..672650ca7 100644 --- a/client/internal/rosenpass/netbird_handler.go +++ b/client/internal/rosenpass/netbird_handler.go @@ -18,19 +18,34 @@ type PresharedKeySetter interface { type wireGuardPeer struct { Interface string PublicKey rp.Key + // initialized is true once a completed exchange has set a + // Rosenpass-managed PSK for this peer. + initialized bool + // chainKey is the key output by the last completed exchange, advanced by + // one ratchet step on expiry. Nil until the first exchange completes and + // after the peer has fallen back to the rendezvous key. + chainKey *wgtypes.Key + // expiries counts failed renewals since the last completed exchange. + expiries int } type NetbirdHandler struct { - mu sync.Mutex - iface PresharedKeySetter - peers map[rp.PeerID]wireGuardPeer - initializedPeers map[rp.PeerID]bool + mu sync.Mutex + iface PresharedKeySetter + // preSharedKey is the account-level preshared key, used as the rendezvous + // key when set. Nil means the deterministic seed key is used instead. + preSharedKey *[32]byte + // localWgKey is the local WireGuard public key, one of the two inputs to + // the deterministic seed key. + localWgKey wgtypes.Key + peers map[rp.PeerID]*wireGuardPeer } -func NewNetbirdHandler() *NetbirdHandler { +func NewNetbirdHandler(preSharedKey *[32]byte, localWgKey wgtypes.Key) *NetbirdHandler { return &NetbirdHandler{ - peers: map[rp.PeerID]wireGuardPeer{}, - initializedPeers: map[rp.PeerID]bool{}, + preSharedKey: preSharedKey, + localWgKey: localWgKey, + peers: map[rp.PeerID]*wireGuardPeer{}, } } @@ -42,10 +57,16 @@ func (h *NetbirdHandler) SetInterface(iface PresharedKeySetter) { h.iface = iface } +// AddPeer registers a peer with the handler. Re-adding a known peer (every +// reconnection does) keeps its key recovery state. func (h *NetbirdHandler) AddPeer(pid rp.PeerID, intf string, pk rp.Key) { h.mu.Lock() defer h.mu.Unlock() - h.peers[pid] = wireGuardPeer{ + if existing, ok := h.peers[pid]; ok && existing.PublicKey == pk { + existing.Interface = intf + return + } + h.peers[pid] = &wireGuardPeer{ Interface: intf, PublicKey: pk, } @@ -55,7 +76,6 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) { h.mu.Lock() defer h.mu.Unlock() delete(h.peers, pid) - delete(h.initializedPeers, pid) } // IsPeerInitialized returns true if Rosenpass has completed a handshake @@ -63,50 +83,120 @@ func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) { func (h *NetbirdHandler) IsPeerInitialized(pid rp.PeerID) bool { h.mu.Lock() defer h.mu.Unlock() - return h.initializedPeers[pid] + peer, ok := h.peers[pid] + return ok && peer.initialized } +// HandshakeCompleted programs the freshly exchanged output key and resets the +// peer's key recovery state. func (h *NetbirdHandler) HandshakeCompleted(pid rp.PeerID, key rp.Key) { - h.outputKey(rp.KeyOutputReasonStale, pid, key) -} + psk := wgtypes.Key(key) -func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) { - key, _ := rp.GeneratePresharedKey() - h.outputKey(rp.KeyOutputReasonStale, pid, key) -} - -func (h *NetbirdHandler) outputKey(_ rp.KeyOutputReason, pid rp.PeerID, psk rp.Key) { h.mu.Lock() - iface := h.iface - wg, ok := h.peers[pid] - isInitialized := h.initializedPeers[pid] - h.mu.Unlock() + defer h.mu.Unlock() - if iface == nil { - log.Warn("rosenpass: interface not set, cannot update preshared key") + peer, ok := h.peers[pid] + if !ok { return } + if peer.expiries > 0 { + log.Infof("rosenpass exchange completed for peer %s after %d expired renewals", wgtypes.Key(peer.PublicKey), peer.expiries) + } + // chainKey tracks the shared exchange output regardless of the local write + // outcome, so both ends still converge on the next expiry. + peer.chainKey = &psk + peer.expiries = 0 + if !h.applyKeyLocked(pid, psk, peer.initialized) { + return + } + peer.initialized = true +} +// HandshakeExpired replaces the expired key. The renewal exchange runs over +// the tunnel keyed by the PSK itself, so the replacement must be derivable on +// both ends without communication: the first expiry ratchets the last shared +// key forward, repeated expiries (and expiries without a completed exchange) +// fall back to the rendezvous key and drop the peer out of the initialized +// state so connection reconfigurations reprogram the rendezvous key as well. +func (h *NetbirdHandler) HandshakeExpired(pid rp.PeerID) { + h.mu.Lock() + defer h.mu.Unlock() + + peer, ok := h.peers[pid] if !ok { return } - peerKey := wgtypes.Key(wg.PublicKey).String() - pskKey := wgtypes.Key(psk) + peer.expiries++ - // Use updateOnly=true for later rotations (peer already has Rosenpass PSK) - // Use updateOnly=false for first rotation (peer has original/empty PSK) - if err := iface.SetPresharedKey(peerKey, pskKey, isInitialized); err != nil { + var psk wgtypes.Key + if peer.chainKey != nil && peer.expiries == 1 { + log.Infof("rosenpass key for peer %s expired without renewal, advancing to ratcheted key", wgtypes.Key(peer.PublicKey)) + psk = RatchetKey(*peer.chainKey) + peer.chainKey = &psk + } else { + rendezvous, err := h.rendezvousKey(peer) + if err != nil { + // Fail closed: without a rendezvous key the expired key must + // still be rotated out, even if the replacement is unusable. + log.Errorf("failed to derive rendezvous key, replacing expired key with a random one: %v", err) + h.applyRandomKeyLocked(pid) + return + } + log.Warnf("rosenpass key for peer %s expired %d times without renewal, falling back to the rendezvous key", wgtypes.Key(peer.PublicKey), peer.expiries) + psk = rendezvous + peer.chainKey = nil + peer.initialized = false + } + + h.applyKeyLocked(pid, psk, true) +} + +// rendezvousKey returns the key both ends converge on without communication: +// the account-level preshared key when configured, the deterministic seed key +// otherwise. It mirrors the key that peer connections program when Rosenpass +// does not manage the peer yet. +func (h *NetbirdHandler) rendezvousKey(peer *wireGuardPeer) (wgtypes.Key, error) { + if h.preSharedKey != nil { + return *h.preSharedKey, nil + } + + seed, err := DeterministicSeedKey(h.localWgKey.String(), wgtypes.Key(peer.PublicKey).String()) + if err != nil { + return wgtypes.Key{}, err + } + return *seed, nil +} + +// applyKeyLocked writes the preshared key for the peer to the WireGuard +// interface and reports whether the write succeeded. Callers must hold h.mu +// for the whole state-mutation-plus-write so that a concurrent completion and +// expiry cannot reorder their writes relative to the in-memory chain key. +func (h *NetbirdHandler) applyKeyLocked(pid rp.PeerID, psk wgtypes.Key, updateOnly bool) bool { + peer, ok := h.peers[pid] + if !ok { + return false + } + + if h.iface == nil { + log.Warn("rosenpass: interface not set, cannot update preshared key") + return false + } + + peerKey := wgtypes.Key(peer.PublicKey).String() + if err := h.iface.SetPresharedKey(peerKey, psk, updateOnly); err != nil { log.Errorf("Failed to apply rosenpass key: %v", err) + return false + } + + return true +} + +func (h *NetbirdHandler) applyRandomKeyLocked(pid rp.PeerID) { + key, err := rp.GeneratePresharedKey() + if err != nil { + log.Errorf("failed to generate random preshared key: %v", err) return } - - // Mark peer as isInitialized after the successful first rotation - if !isInitialized { - h.mu.Lock() - if _, exists := h.peers[pid]; exists { - h.initializedPeers[pid] = true - } - h.mu.Unlock() - } + h.applyKeyLocked(pid, wgtypes.Key(key), true) } diff --git a/client/internal/rosenpass/netbird_handler_test.go b/client/internal/rosenpass/netbird_handler_test.go new file mode 100644 index 000000000..9d91ba93b --- /dev/null +++ b/client/internal/rosenpass/netbird_handler_test.go @@ -0,0 +1,250 @@ +package rosenpass + +import ( + "testing" + + rp "cunicu.li/go-rosenpass" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// handlerTestLink wires two NetbirdHandlers as the two ends of a single +// tunnel: handler A manages the rosenpass peer B and vice versa, the way two +// NetBird clients see each other. +type handlerTestLink struct { + handlerA, handlerB *NetbirdHandler + ifaceA, ifaceB *mockIface + pidA, pidB rp.PeerID + wgKeyA, wgKeyB wgtypes.Key +} + +func newHandlerTestLink(t *testing.T, preSharedKey *[32]byte) *handlerTestLink { + t.Helper() + + link := &handlerTestLink{ + ifaceA: &mockIface{}, + ifaceB: &mockIface{}, + } + link.pidA[0] = 0xaa + link.pidB[0] = 0xbb + link.wgKeyA[31] = 1 + link.wgKeyB[31] = 2 + + link.handlerA = NewNetbirdHandler(preSharedKey, link.wgKeyA) + link.handlerB = NewNetbirdHandler(preSharedKey, link.wgKeyB) + + link.handlerA.SetInterface(link.ifaceA) + link.handlerB.SetInterface(link.ifaceB) + + link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB)) + link.handlerB.AddPeer(link.pidA, "wt0", rp.Key(link.wgKeyA)) + + return link +} + +// complete simulates a completed rosenpass exchange: both ends derive the +// same output key. +func (l *handlerTestLink) complete(osk rp.Key) { + l.handlerA.HandshakeCompleted(l.pidB, osk) + l.handlerB.HandshakeCompleted(l.pidA, osk) +} + +// expire simulates a failed key renewal on both ends. +func (l *handlerTestLink) expire() { + l.handlerA.HandshakeExpired(l.pidB) + l.handlerB.HandshakeExpired(l.pidA) +} + +func lastPSK(t *testing.T, m *mockIface) wgtypes.Key { + t.Helper() + m.mu.Lock() + defer m.mu.Unlock() + require.NotEmpty(t, m.calls, "expected at least one SetPresharedKey call") + return m.calls[len(m.calls)-1].psk +} + +func TestHandshakeCompleted_SetsKeyAndInitializes(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + require.Equal(t, wgtypes.Key(osk), lastPSK(t, link.ifaceA), "completed exchange must program the osk") + require.False(t, link.ifaceA.calls[0].updateOnly, "first rotation must not be update-only") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized after first completed exchange") + + link.complete(osk) + require.True(t, link.ifaceA.calls[1].updateOnly, "later rotations must be update-only") +} + +// TestHandshakeExpired_BothSidesConverge encodes the core recovery invariant: +// rosenpass renewals run over the tunnel that the PSK itself keys, so when a +// renewal fails on both ends, both ends must fall back to the same key or the +// tunnel can never handshake again. +func TestHandshakeExpired_BothSidesConverge(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + keyA := lastPSK(t, link.ifaceA) + keyB := lastPSK(t, link.ifaceB) + require.NotEqual(t, wgtypes.Key(osk), keyA, "expired key must be rotated out") + require.Equal(t, keyA, keyB, "both ends must converge on the same key after expiry") + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "both ends must still converge after repeated expiries") +} + +// TestHandshakeExpired_ExpiryWithoutCompletionConverges covers the bootstrap +// case: the initial exchange never completed (the tunnel ran on the rendezvous +// key), so an expiry must not replace the working key with an unrecoverable +// one on either end. +func TestHandshakeExpired_ExpiryWithoutCompletionConverges(t *testing.T) { + link := newHandlerTestLink(t, nil) + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "both ends must converge when the exchange never completed") +} + +// TestHandshakeExpired_RepeatedExpiryClearsInitialized: once renewals keep +// failing, the peer must drop out of the initialized state so the next +// connection reconfiguration reprograms the rendezvous key instead of +// preserving a poisoned rosenpass-managed key. +func TestHandshakeExpired_RepeatedExpiryClearsInitialized(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + require.False(t, link.handlerA.IsPeerInitialized(link.pidB), + "repeated expiries must clear the initialized state") + require.False(t, link.handlerB.IsPeerInitialized(link.pidA), + "repeated expiries must clear the initialized state") +} + +// TestHandshakeCompleted_AfterExpiryRecovers: a completed exchange after a +// desync must fully reset the recovery state. +func TestHandshakeCompleted_AfterExpiryRecovers(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk1, osk2 rp.Key + osk1[0] = 1 + osk2[0] = 2 + + link.complete(osk1) + link.expire() + link.expire() + + link.complete(osk2) + require.Equal(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "new exchange must program the fresh osk") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), "peer must be initialized again after recovery") + + link.expire() + require.Equal(t, lastPSK(t, link.ifaceA), lastPSK(t, link.ifaceB), + "recovered link must converge again on the next expiry") + require.NotEqual(t, wgtypes.Key(osk2), lastPSK(t, link.ifaceA), "expired key must be rotated out") +} + +// TestHandshakeExpired_FirstExpiryRatchetsLastKey: the first expiry must +// derive the replacement from the last shared key, so an attacker who only +// blocks the renewal exchange gains nothing over the previous key. +func TestHandshakeExpired_FirstExpiryRatchetsLastKey(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + require.Equal(t, RatchetKey(wgtypes.Key(osk)), lastPSK(t, link.ifaceA), + "first expiry must program the ratcheted key") + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), + "ratchet step must keep the peer initialized so reconfigurations preserve the key") +} + +// TestHandshakeExpired_RepeatedExpiryFallsBackToSeed: once the ratchet key +// also fails, both ends must land on the same key that peer connections +// program for uninitialized peers, so a reconnect completes the recovery. +func TestHandshakeExpired_RepeatedExpiryFallsBackToSeed(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String()) + require.NoError(t, err) + require.Equal(t, *seed, lastPSK(t, link.ifaceA), "repeated expiry must fall back to the seed key") + require.Equal(t, *seed, lastPSK(t, link.ifaceB), "repeated expiry must fall back to the seed key") +} + +// TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous: with an account-level +// preshared key configured, the fallback must be that key, matching what peer +// connections program for uninitialized peers. +func TestHandshakeExpired_ConfiguredPSKUsedAsRendezvous(t *testing.T) { + psk := &[32]byte{0x77} + link := newHandlerTestLink(t, psk) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceA), + "fallback must be the configured preshared key") + require.Equal(t, wgtypes.Key(*psk), lastPSK(t, link.ifaceB), + "fallback must be the configured preshared key on both ends") +} + +// TestHandshakeExpired_ExpiryWritesAreUpdateOnly: expiry replacements must +// never create a WireGuard peer that connection management has removed. +func TestHandshakeExpired_ExpiryWritesAreUpdateOnly(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + + link.expire() + link.expire() + + for _, call := range link.ifaceA.calls[1:] { + require.True(t, call.updateOnly, "expiry writes must be update-only") + } +} + +// TestAddPeer_ReAddKeepsRecoveryState: reconnections re-add the peer on every +// OnConnected; that must not reset the expiry chain state. +func TestAddPeer_ReAddKeepsRecoveryState(t *testing.T) { + link := newHandlerTestLink(t, nil) + + var osk rp.Key + osk[0] = 0x42 + link.complete(osk) + link.expire() + + link.handlerA.AddPeer(link.pidB, "wt0", rp.Key(link.wgKeyB)) + require.True(t, link.handlerA.IsPeerInitialized(link.pidB), + "re-adding a known peer must keep its state") + + link.expire() + seed, err := DeterministicSeedKey(link.wgKeyA.String(), link.wgKeyB.String()) + require.NoError(t, err) + require.Equal(t, *seed, lastPSK(t, link.ifaceA), + "second expiry after re-add must continue to the seed fallback") +} diff --git a/client/internal/rosenpass/seed.go b/client/internal/rosenpass/seed.go index 83aba1e0e..052c11ed4 100644 --- a/client/internal/rosenpass/seed.go +++ b/client/internal/rosenpass/seed.go @@ -1,11 +1,28 @@ package rosenpass import ( + "crypto/sha256" "fmt" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) +// ratchetLabel domain-separates the expiry ratchet from other uses of the +// rosenpass output key. +const ratchetLabel = "netbird-rosenpass-expiry-ratchet" + +// RatchetKey derives the successor preshared key from the previous Rosenpass +// output key. When a key expires without a completed renewal, both peers +// advance their last shared key by one ratchet step: the expired key is +// rotated out while both ends still converge on an identical, non-public +// replacement without communicating. +func RatchetKey(prev wgtypes.Key) wgtypes.Key { + input := make([]byte, 0, len(ratchetLabel)+len(prev)) + input = append(input, ratchetLabel...) + input = append(input, prev[:]...) + return sha256.Sum256(input) +} + // DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair // of peer public keys. Both peers, given the same key pair, produce the same // output regardless of which side runs the function: the inputs are ordered diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go index e25cc2a5c..d20f4b944 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -95,7 +95,7 @@ func (d *DnsInterceptor) RemoveRoute() error { // AllowedIPs should use real IPs if d.currentPeerKey != "" { - if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err)) } } @@ -172,7 +172,7 @@ func (d *DnsInterceptor) removeAllowedIP(realPrefix netip.Prefix) error { } // AllowedIPs use real IPs - if _, err := d.allowedIPsRefcounter.Decrement(realPrefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(realPrefix, d.currentPeerKey); err != nil { return fmt.Errorf("remove allowed IP %s: %v", realPrefix, err) } @@ -205,7 +205,7 @@ func (d *DnsInterceptor) RemoveAllowedIPs() error { for _, prefixes := range d.interceptedDomains { for _, prefix := range prefixes { // AllowedIPs use real IPs - if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err)) } } @@ -226,12 +226,11 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { return } - // pass if non A/AAAA query - if r.Question[0].Qtype != dns.TypeA && r.Question[0].Qtype != dns.TypeAAAA { - d.continueToNextHandler(w, r, logger, "non A/AAAA query") - return - } - + // All query types for an intercepted domain are forwarded to the peer's + // DNS forwarder, which owns the name. Falling through to the system + // resolver would let it answer NXDOMAIN for a name it isn't authoritative + // for, poisoning the whole name (including the A/AAAA records the route + // does serve). The forwarder answers NODATA for types it cannot resolve. d.mu.RLock() peerKey := d.currentPeerKey d.mu.RUnlock() @@ -251,6 +250,14 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { r.MsgHdr.AuthenticatedData = true } + // Advertise EDNS0 to the forwarder so it may return an Extended DNS Error + // describing why a lookup failed. The OPT is stripped from the reply when + // the original client did not request EDNS0. + hadEdns := r.IsEdns0() != nil + if !hadEdns { + r.SetEdns0(dns.DefaultMsgSize, false) + } + upstream := net.JoinHostPort(upstreamIP.String(), strconv.FormatUint(uint64(d.forwarderPort.Load()), 10)) ctx, cancel := context.WithTimeout(context.Background(), dnsTimeout) defer cancel() @@ -260,6 +267,13 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { return } + if ede, ok := resutil.ExtractEDE(reply); ok { + resutil.SetMeta(w, "ede", fmt.Sprintf("%d %s", ede.InfoCode, ede.ExtraText)) + } + if !hadEdns { + resutil.StripOPT(reply) + } + resutil.SetMeta(w, "peer", peerKey) reply.Id = r.Id @@ -278,19 +292,6 @@ func (d *DnsInterceptor) writeDNSError(w dns.ResponseWriter, r *dns.Msg, logger } } -// continueToNextHandler signals the handler chain to try the next handler -func (d *DnsInterceptor) continueToNextHandler(w dns.ResponseWriter, r *dns.Msg, logger *log.Entry, reason string) { - logger.Tracef("continuing to next handler for domain=%s reason=%s", r.Question[0].Name, reason) - - resp := new(dns.Msg) - resp.SetRcode(r, dns.RcodeNameError) - // Set Zero bit to signal handler chain to continue - resp.MsgHdr.Zero = true - if err := w.WriteMsg(resp); err != nil { - logger.Errorf("failed writing DNS continue response: %v", err) - } -} - func (d *DnsInterceptor) getUpstreamIP(peerKey string) (netip.Addr, error) { peerAllowedIP, exists := d.peerStore.AllowedIP(peerKey) if !exists { @@ -478,7 +479,7 @@ func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix, logger // internalDnatFw checks if the firewall supports internal DNAT func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) { - if d.firewall == nil || runtime.GOOS != "android" { + if d.firewall == nil || d.fakeIPManager == nil || runtime.GOOS != "android" { return nil, false } fw, ok := d.firewall.(internalDNATer) diff --git a/client/internal/routemanager/dynamic/route.go b/client/internal/routemanager/dynamic/route.go index f0efd7b22..bb3b1c59c 100644 --- a/client/internal/routemanager/dynamic/route.go +++ b/client/internal/routemanager/dynamic/route.go @@ -135,7 +135,7 @@ func (r *Route) RemoveAllowedIPs() error { var merr *multierror.Error for _, domainPrefixes := range r.dynamicDomains { for _, prefix := range domainPrefixes { - if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err)) } } @@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) { } func (r *Route) update(ctx context.Context) error { - resolved, err := r.resolveDomains() + resolved, err := r.resolveDomains(ctx) if err != nil { if len(resolved) == 0 { return fmt.Errorf("resolve domains: %w", err) @@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error { return nil } -func (r *Route) resolveDomains() (domainMap, error) { +func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) { results := make(chan resolveResult) - go r.resolve(results) + go r.resolve(ctx, results) resolved := domainMap{} var merr *multierror.Error @@ -217,7 +217,7 @@ func (r *Route) resolveDomains() (domainMap, error) { return resolved, nberrors.FormatErrorOrNil(merr) } -func (r *Route) resolve(results chan resolveResult) { +func (r *Route) resolve(ctx context.Context, results chan resolveResult) { var wg sync.WaitGroup for _, d := range r.route.Domains { @@ -225,10 +225,10 @@ func (r *Route) resolve(results chan resolveResult) { go func(domain domain.Domain) { defer wg.Done() - ips, err := r.getIPsFromResolver(domain) + ips, err := r.getIPsFromResolver(ctx, domain) if err != nil { log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err) - ips, err = net.LookupIP(domain.PunycodeString()) + ips, err = lookupHostIPs(ctx, domain) if err != nil { results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)} return @@ -320,7 +320,7 @@ func (r *Route) removeRoutes(prefixes []netip.Prefix) ([]netip.Prefix, error) { merr = multierror.Append(merr, fmt.Errorf("remove dynamic route for IP %s: %w", prefix, err)) } if r.currentPeerKey != "" { - if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil { + if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err)) } } @@ -364,6 +364,20 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR return } +// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation. +func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) { + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString()) + if err != nil { + return nil, err + } + + ips := make([]net.IP, 0, len(addrs)) + for _, addr := range addrs { + ips = append(ips, addr.IP) + } + return ips, nil +} + func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix { prefixSet := make(map[netip.Prefix]struct{}) for _, prefix := range oldPrefixes { diff --git a/client/internal/routemanager/dynamic/route_generic.go b/client/internal/routemanager/dynamic/route_generic.go index 56fd63fba..8bc2dd3df 100644 --- a/client/internal/routemanager/dynamic/route_generic.go +++ b/client/internal/routemanager/dynamic/route_generic.go @@ -3,11 +3,12 @@ package dynamic import ( + "context" "net" "github.com/netbirdio/netbird/shared/management/domain" ) -func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { - return net.LookupIP(domain.PunycodeString()) +func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) { + return lookupHostIPs(ctx, domain) } diff --git a/client/internal/routemanager/dynamic/route_ios.go b/client/internal/routemanager/dynamic/route_ios.go index 1ae281d56..6a3d262b8 100644 --- a/client/internal/routemanager/dynamic/route_ios.go +++ b/client/internal/routemanager/dynamic/route_ios.go @@ -3,6 +3,7 @@ package dynamic import ( + "context" "fmt" "net" "time" @@ -16,7 +17,7 @@ import ( const dialTimeout = 10 * time.Second -func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { +func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) { privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout) if err != nil { return nil, fmt.Errorf("error while creating private client: %s", err) @@ -32,7 +33,7 @@ func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) { msg := new(dns.Msg) msg.SetQuestion(fqdn, qtype) - response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String()) + response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String()) if err != nil { if queryErr == nil { queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err) diff --git a/client/internal/routemanager/exit_node_selection_test.go b/client/internal/routemanager/exit_node_selection_test.go new file mode 100644 index 000000000..28dd0a640 --- /dev/null +++ b/client/internal/routemanager/exit_node_selection_test.go @@ -0,0 +1,191 @@ +package routemanager + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +func newExitNodeTestManager() *DefaultManager { + return &DefaultManager{routeSelector: routeselector.NewRouteSelector()} +} + +func exitRoute(netID, peer string, skipAutoApply bool) *route.Route { + return &route.Route{ + NetID: route.NetID(netID), + Network: netip.MustParsePrefix("0.0.0.0/0"), + Peer: peer, + SkipAutoApply: skipAutoApply, + } +} + +func TestPickPreferredExitNode(t *testing.T) { + tests := []struct { + name string + info exitNodeInfo + want route.NetID + }{ + { + name: "persisted user selection wins over management", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b", "c"}, + userSelected: []route.NetID{"b"}, + selectedByManagement: []route.NetID{"a"}, + }, + want: "b", + }, + { + name: "multiple user-selected self-heal to deterministic min", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b", "c"}, + userSelected: []route.NetID{"c", "a"}, + }, + want: "a", + }, + { + name: "explicit opt-out keeps none", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b"}, + userDeselected: []route.NetID{"a", "b"}, + }, + want: "", + }, + { + name: "fresh defaults to management auto-apply pick", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b", "c"}, + selectedByManagement: []route.NetID{"b"}, + }, + want: "b", + }, + { + name: "no user pick and no management auto-apply selects none", + info: exitNodeInfo{ + allIDs: []route.NetID{"c", "a", "b"}, + }, + want: "", + }, + { + name: "user-deselect does not block a management auto-apply sibling", + info: exitNodeInfo{ + allIDs: []route.NetID{"a", "b"}, + userDeselected: []route.NetID{"a"}, + selectedByManagement: []route.NetID{"b"}, + }, + want: "b", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, pickPreferredExitNode(tt.info), "preferred exit node") + }) + } +} + +func TestEnforceSingleExitNode(t *testing.T) { + m := newExitNodeTestManager() + all := []route.NetID{"a", "b", "c"} + + m.enforceSingleExitNode("b", all) + assert.False(t, m.routeSelector.IsSelected("a"), "a should be deselected") + assert.True(t, m.routeSelector.IsSelected("b"), "b should be the only selected exit node") + assert.False(t, m.routeSelector.IsSelected("c"), "c should be deselected") + + // Switching the preferred node moves the single selection. + m.enforceSingleExitNode("c", all) + assert.False(t, m.routeSelector.IsSelected("a"), "a stays deselected") + assert.False(t, m.routeSelector.IsSelected("b"), "b should now be deselected") + assert.True(t, m.routeSelector.IsSelected("c"), "c should now be selected") + + // Empty preferred turns every exit node off. + m.enforceSingleExitNode("", all) + for _, id := range all { + assert.False(t, m.routeSelector.IsSelected(id), "no exit node should be selected") + } +} + +func TestEnforceSingleExitNode_RespectsDeselectAll(t *testing.T) { + m := newExitNodeTestManager() + m.routeSelector.DeselectAllRoutes() + + m.enforceSingleExitNode("b", []route.NetID{"a", "b"}) + + assert.True(t, m.routeSelector.IsDeselectAll(), "global deselect-all must stay in effect") + assert.False(t, m.routeSelector.IsSelected("b"), "no exit node should be forced on while deselect-all is set") +} + +func TestUpdateRouteSelectorFromManagement_FreshSelectsOne(t *testing.T) { + m := newExitNodeTestManager() + routes := route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)}, + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}}, + "exitC|0.0.0.0/0": {exitRoute("exitC", "p4", false)}, + } + + m.updateRouteSelectorFromManagement(routes) + + // Exactly one exit node (the deterministic first) is selected. + assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA is the deterministic default") + assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB must not also be selected") + assert.False(t, m.routeSelector.IsSelected("exitC"), "exitC must not also be selected") + // Non-exit routes are left at their default-on state. + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched") +} + +func TestUpdateRouteSelectorFromManagement_HonorsPersistedPick(t *testing.T) { + m := newExitNodeTestManager() + routes := route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)}, + } + all := []route.NetID{"exitA", "exitB"} + + // Simulate the state the runtime select path leaves behind: exactly one + // exit node explicitly selected, its sibling deselected. + require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exitB"}, true, all)) + require.NoError(t, m.routeSelector.DeselectRoutes([]route.NetID{"exitA"}, all)) + + m.updateRouteSelectorFromManagement(routes) + + assert.True(t, m.routeSelector.IsSelected("exitB"), "persisted pick must stay selected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "the other exit node stays deselected") +} + +func TestUpdateRouteSelectorFromManagement_OptOutKeepsNone(t *testing.T) { + m := newExitNodeTestManager() + routes := route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)}, + } + all := []route.NetID{"exitA", "exitB"} + + // User deselected exit nodes and selected none. + require.NoError(t, m.routeSelector.DeselectRoutes(all, all)) + + m.updateRouteSelectorFromManagement(routes) + + assert.False(t, m.routeSelector.IsSelected("exitA"), "opt-out keeps exitA off") + assert.False(t, m.routeSelector.IsSelected("exitB"), "opt-out keeps exitB off") +} + +func TestUpdateRouteSelectorFromManagement_NoAutoApplySelectsNone(t *testing.T) { + m := newExitNodeTestManager() + // SkipAutoApply=true: management offers the exit nodes but doesn't request + // auto-activation, so none should be selected until the user picks one. + routes := route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)}, + } + + m.updateRouteSelectorFromManagement(routes) + + assert.False(t, m.routeSelector.IsSelected("exitA"), "no auto-apply keeps exitA off") + assert.False(t, m.routeSelector.IsSelected("exitB"), "no auto-apply keeps exitB off") +} diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate.go b/client/internal/routemanager/ipfwdstate/ipfwdstate.go index 2be1c2ae7..3d571e16b 100644 --- a/client/internal/routemanager/ipfwdstate/ipfwdstate.go +++ b/client/internal/routemanager/ipfwdstate/ipfwdstate.go @@ -2,54 +2,183 @@ package ipfwdstate import ( "fmt" + "sync" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/internal/routemanager/systemops" ) -// IPForwardingState is a struct that keeps track of the IP forwarding state. -// todo: read initial state of the IP forwarding from the system and reset the state based on it. -// todo: separate v4/v6 forwarding state, since the sysctls are independent -// (net.ipv4.ip_forward vs net.ipv6.conf.all.forwarding). Currently the nftables -// manager shares one instance between both routers, which works only because -// EnableIPForwarding enables both sysctls in a single call. +// IPForwardingState tracks v4 and v6 IP-forwarding sysctl enables with +// independent refcounts so a v4-only routing setup doesn't flip v6 sysctls. type IPForwardingState struct { - enabledCounter int + mu sync.Mutex + + v4Count int + v6Count int + + // routingV4/routingV6 track whether the routing path currently holds a + // reference, so repeated EnableRouting calls (one per network-map update) + // hold at most one reference per family and an unpaired DisableRouting + // can't release references held by DNAT rules. + routingV4 bool + routingV6 bool + + wgIfaceName string + v6Saved map[string]int } -func NewIPForwardingState() *IPForwardingState { - return &IPForwardingState{} +// NewIPForwardingState returns a state tracker for the IP-forwarding sysctls. +// wgIfaceName is excluded from the per-interface accept_ra handling. +func NewIPForwardingState(wgIfaceName string) *IPForwardingState { + return &IPForwardingState{wgIfaceName: wgIfaceName} } -func (f *IPForwardingState) RequestForwarding() error { - if f.enabledCounter != 0 { - f.enabledCounter++ +// Counts returns the current v4 and v6 refcounts. Intended for diagnostics +// and tests. +func (f *IPForwardingState) Counts() (v4, v6 int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.v4Count, f.v6Count +} + +// RequestRouting takes the forwarding references for the routing path. It is +// idempotent: while routing already holds a reference, further calls don't +// increment the refcounts, and a v4-only request releases a previously held v6 +// reference. A v6 sysctl failure is logged and not returned so it can't take +// down v4 routing (the sysctl may be unwritable, e.g. read-only /proc/sys or +// IPv6 disabled on the kernel command line); v6 is retried on the next call. +func (f *IPForwardingState) RequestRouting(v6 bool) error { + f.mu.Lock() + defer f.mu.Unlock() + + if !f.routingV4 { + if err := f.requestV4(); err != nil { + return err + } + f.routingV4 = true + } + + if !v6 { + if !f.routingV6 { + return nil + } + f.routingV6 = false + return f.releaseV6() + } + + if f.routingV6 { return nil } - - if err := systemops.EnableIPForwarding(); err != nil { - return fmt.Errorf("failed to enable IP forwarding with sysctl: %w", err) + if err := f.requestV6(); err != nil { + log.Warnf("enable IPv6 forwarding for routing: %v", err) + return nil } - f.enabledCounter = 1 - log.Info("IP forwarding enabled") - + f.routingV6 = true return nil } -func (f *IPForwardingState) ReleaseForwarding() error { - if f.enabledCounter == 0 { - return nil +// ReleaseRouting releases the references RequestRouting holds. Calls without a +// held reference are no-ops. +func (f *IPForwardingState) ReleaseRouting() error { + f.mu.Lock() + defer f.mu.Unlock() + + if f.routingV4 { + f.routingV4 = false + f.releaseV4() } - - if f.enabledCounter > 1 { - f.enabledCounter-- - return nil + if f.routingV6 { + f.routingV6 = false + return f.releaseV6() } - - // if failed to disable IP forwarding we anyway decrement the counter - f.enabledCounter = 0 - - // todo call systemops.DisableIPForwarding() + return nil +} + +// RequestForwarding enables the family's forwarding sysctl on first request. +func (f *IPForwardingState) RequestForwarding(v6 bool) error { + f.mu.Lock() + defer f.mu.Unlock() + + if v6 { + return f.requestV6() + } + return f.requestV4() +} + +// ReleaseForwarding decrements the family counter. The last v6 release restores +// what enable captured. v4 stays on: net.ipv4.ip_forward is co-owned by other +// tooling (docker, k8s, libvirt). +func (f *IPForwardingState) ReleaseForwarding(v6 bool) error { + f.mu.Lock() + defer f.mu.Unlock() + + if v6 { + return f.releaseV6() + } + f.releaseV4() + return nil +} + +func (f *IPForwardingState) requestV4() error { + if f.v4Count == 0 { + if err := systemops.EnableV4IPForwarding(); err != nil { + return fmt.Errorf("enable IPv4 forwarding: %w", err) + } + log.Info("IPv4 forwarding enabled") + } + f.v4Count++ + return nil +} + +func (f *IPForwardingState) releaseV4() { + if f.v4Count > 0 { + f.v4Count-- + } +} + +func (f *IPForwardingState) requestV6() error { + if f.v6Count == 0 { + saved, err := systemops.EnableV6IPForwarding(f.wgIfaceName) + if err != nil { + if rerr := systemops.DisableV6IPForwarding(saved); rerr != nil { + log.Warnf("rollback partial v6 sysctls: %v", rerr) + } + return fmt.Errorf("enable IPv6 forwarding: %w", err) + } + // A failed restore on a previous release keeps its saved values; those + // are the true originals, so keep them over what this enable captured. + if f.v6Saved == nil { + f.v6Saved = saved + } else { + for k, v := range saved { + if _, ok := f.v6Saved[k]; !ok { + f.v6Saved[k] = v + } + } + } + log.Info("IPv6 forwarding enabled") + } + f.v6Count++ + return nil +} + +func (f *IPForwardingState) releaseV6() error { + if f.v6Count == 0 { + return nil + } + f.v6Count-- + if f.v6Count > 0 { + return nil + } + + // Keep the saved values on failure so a later release or enable/release + // cycle can still restore them; re-restoring an already-restored key is a + // no-op since the sysctl already holds the desired value. + if err := systemops.DisableV6IPForwarding(f.v6Saved); err != nil { + return fmt.Errorf("disable IPv6 forwarding: %w", err) + } + f.v6Saved = nil + log.Info("IPv6 forwarding disabled") return nil } diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go b/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go new file mode 100644 index 000000000..b4615ff02 --- /dev/null +++ b/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go @@ -0,0 +1,39 @@ +//go:build privileged + +package ipfwdstate + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRequestRoutingV6ToV4Transition verifies that a v4-only routing request +// releases a previously held routing-owned v6 reference without touching +// references held by DNAT rules. +func TestRequestRoutingV6ToV4Transition(t *testing.T) { + f := NewIPForwardingState("wt-fwd-test") + + require.NoError(t, f.RequestRouting(true), "request routing with v6") + v4, v6 := f.Counts() + assert.Equal(t, 1, v4, "v4 reference held") + assert.Equal(t, 1, v6, "v6 reference held") + + require.NoError(t, f.RequestRouting(false), "request routing v4-only") + v4, v6 = f.Counts() + assert.Equal(t, 1, v4, "v4 reference kept") + assert.Equal(t, 0, v6, "routing-owned v6 reference released") + + // A DNAT-held reference survives a v4-only routing request. + require.NoError(t, f.RequestForwarding(true), "dnat v6 reference") + require.NoError(t, f.RequestRouting(false), "repeat v4-only request") + _, v6 = f.Counts() + assert.Equal(t, 1, v6, "dnat-held v6 reference survives") + require.NoError(t, f.ReleaseForwarding(true), "release dnat v6 reference") + + require.NoError(t, f.ReleaseRouting(), "release routing") + v4, v6 = f.Counts() + assert.Equal(t, 0, v4, "all v4 references released") + assert.Equal(t, 0, v6, "all v6 references released") +} diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 22458d575..0ccfa83ac 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -8,13 +8,13 @@ import ( "net/netip" "net/url" "runtime" - "slices" + "sort" "strings" "sync" "sync/atomic" + "syscall" "time" - "github.com/google/uuid" "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" "golang.org/x/exp/maps" @@ -51,15 +51,20 @@ type Manager interface { UpdateRoutes(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]*route.Route, route.HAMap) TriggerSelection(route.HAMap) + SelectRoutes(ids []route.NetID, appendRoute bool) error + DeselectRoutes(ids []route.NetID) error + SelectAllRoutes() + DeselectAllRoutes() GetRouteSelector() *routeselector.RouteSelector GetClientRoutes() route.HAMap GetSelectedClientRoutes() route.HAMap GetActiveClientRoutes() route.HAMap GetClientRoutesWithNetID() map[route.NetID][]*route.Route SetRouteChangeListener(listener listener.NetworkChangeListener) - InitialRouteRange() []string + CurrentRouteRange() []string SetFirewall(firewall.Manager) error SetDNSForwarderPort(port uint16) + ReconcilePeerAllowedIPs(peerKey string) error Stop(stateManager *statemanager.Manager) } @@ -70,10 +75,8 @@ type ManagerConfig struct { WGInterface iface.WGIface StatusRecorder *peer.Status RelayManager *relayClient.Manager - InitialRoutes []*route.Route StateManager *statemanager.Manager DNSServer dns.Server - DNSFeatureFlag bool PeerStore *peerstore.Store DisableClientRoutes bool DisableServerRoutes bool @@ -143,45 +146,12 @@ func NewManager(config ManagerConfig) *DefaultManager { useNoop := netstack.IsEnabled() || config.DisableClientRoutes dm.setupRefCounters(useNoop) - // don't proceed with client routes if it is disabled - if config.DisableClientRoutes { - return dm - } - - if runtime.GOOS == "android" { - dm.setupAndroidRoutes(config) - } return dm } -func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) { - cr := m.initialClientRoutes(config.InitialRoutes) - routesForComparison := slices.Clone(cr) - - if config.DNSFeatureFlag { - m.fakeIPManager = fakeip.NewManager() - - v4ID := uuid.NewString() - fakeIPRoute := &route.Route{ - ID: route.ID(v4ID), - Network: m.fakeIPManager.GetFakeIPBlock(), - NetID: route.NetID(v4ID), - Peer: m.pubKey, - NetworkType: route.IPv4Network, - } - v6ID := uuid.NewString() - fakeIPv6Route := &route.Route{ - ID: route.ID(v6ID), - Network: m.fakeIPManager.GetFakeIPv6Block(), - NetID: route.NetID(v6ID), - Peer: m.pubKey, - NetworkType: route.IPv6Network, - } - cr = append(cr, fakeIPRoute, fakeIPv6Route) - m.notifier.SetFakeIPRoutes([]*route.Route{fakeIPRoute, fakeIPv6Route}) - } - - m.notifier.SetInitialClientRoutes(cr, routesForComparison) +func (m *DefaultManager) enableFakeIPRoutes() { + m.fakeIPManager = fakeip.NewManager() + m.notifier.NotifyRouteChange() } func (m *DefaultManager) setupRefCounters(useNoop bool) { @@ -214,7 +184,7 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) { ) } - m.allowedIPsRefCounter = refcounter.New( + m.allowedIPsRefCounter = refcounter.NewAllowedIPs( func(prefix netip.Prefix, peerKey string) (string, error) { // save peerKey to use it in the remove function return peerKey, m.wgInterface.AddAllowedIP(peerKey, prefix) @@ -231,6 +201,30 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) { ) } +// ReconcilePeerAllowedIPs re-applies every routed allowed IP currently tracked for the peer +// onto the WireGuard device. The allowed-IP refcounter only calls its AddFunc (which pushes to +// the device) on a prefix's 0->1 transition, so a peer whose device entry was rebuilt without a +// matching refcounter change — e.g. a lazy connection cycling through idle->wake, which recreates +// the WireGuard peer with the overlay /32 only — ends up missing routed prefixes the refcounter +// still considers installed, and nothing retries. Calling this when the peer's WireGuard entry is +// (re)created restores convergence. It is add-only and idempotent: AddAllowedIP is update-only, so +// prefixes are re-added to an existing peer and an absent peer is left untouched. +func (m *DefaultManager) ReconcilePeerAllowedIPs(peerKey string) error { + if m.allowedIPsRefCounter == nil { + return nil + } + + return m.allowedIPsRefCounter.ReapplyMatching( + func(out string) bool { return out == peerKey }, + func(prefix netip.Prefix) error { + if err := m.wgInterface.AddAllowedIP(peerKey, prefix); err != nil { + return fmt.Errorf("add allowed IP %s for peer %s: %w", prefix, peerKey, err) + } + return nil + }, + ) +} + // Init sets up the routing func (m *DefaultManager) Init() error { m.routeSelector = m.initSelector() @@ -264,7 +258,11 @@ func (m *DefaultManager) initSelector() *routeselector.RouteSelector { // restore selector state if it exists if err := m.stateManager.LoadState(state); err != nil { - log.Warnf("failed to load state: %v", err) + if errors.Is(err, syscall.ENOSYS) { + log.Debugf("route selector state unavailable on this platform: %v", err) + } else { + log.Warnf("failed to load state: %v", err) + } return routeselector.NewRouteSelector() } @@ -430,6 +428,9 @@ func (m *DefaultManager) UpdateRoutes( var merr *multierror.Error if !m.disableClientRoutes { + if runtime.GOOS == "android" && useNewDNSRoute && m.fakeIPManager == nil { + m.enableFakeIPRoutes() + } // Update route selector based on management server's isSelected status m.updateRouteSelectorFromManagement(clientRoutes) @@ -442,6 +443,11 @@ func (m *DefaultManager) UpdateRoutes( m.updateClientNetworks(updateSerial, filteredClientRoutes) m.notifier.OnNewRoutes(filteredClientRoutes) + // A new network map can add or drop route/exit-node candidates without + // touching any peer's chosen-route state, so the peer status alone + // wouldn't notify SubscribeStatus subscribers. Bump the revision so the + // UI re-fetches ListNetworks. + m.statusRecorder.BumpNetworksRevision() } m.clientRoutes = clientRoutes @@ -461,9 +467,32 @@ func (m *DefaultManager) SetRouteChangeListener(listener listener.NetworkChangeL m.notifier.SetListener(listener) } -// InitialRouteRange return the list of initial routes. It used by mobile systems -func (m *DefaultManager) InitialRouteRange() []string { - return m.notifier.GetInitialRouteRanges() +// CurrentRouteRange returns the current TUN route list. It is used by mobile systems +func (m *DefaultManager) CurrentRouteRange() []string { + m.mux.Lock() + defer m.mux.Unlock() + + if m.disableClientRoutes { + return nil + } + + filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes) + var nets []string + for _, routes := range filtered { + for _, r := range routes { + if r.IsDynamic() { + continue + } + nets = append(nets, r.NetString()) + } + } + + if m.fakeIPManager != nil { + nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String()) + } + + sort.Strings(nets) + return nets } // GetRouteSelector returns the route selector @@ -582,6 +611,10 @@ func (m *DefaultManager) TriggerSelection(networks route.HAMap) { if err := m.stateManager.UpdateState((*SelectorState)(m.routeSelector)); err != nil { log.Errorf("failed to update state: %v", err) } + + // A selection change flips Network.selected without altering the candidate + // set, so bump the revision to push the new state to the UI. + m.statusRecorder.BumpNetworksRevision() } // stopObsoleteClients stops the client network watcher for the networks that are not in the new list @@ -657,16 +690,6 @@ func (m *DefaultManager) ClassifyRoutes(newRoutes []*route.Route) (map[route.ID] return newServerRoutesMap, newClientRoutesIDMap } -func (m *DefaultManager) initialClientRoutes(initialRoutes []*route.Route) []*route.Route { - _, crMap := m.ClassifyRoutes(initialRoutes) - rs := make([]*route.Route, 0, len(crMap)) - for _, routes := range crMap { - rs = append(rs, routes...) - } - - return rs -} - func isRouteSupported(route *route.Route) bool { if netstack.IsEnabled() || !nbnet.CustomRoutingDisabled() || route.IsDynamic() { return true @@ -701,7 +724,13 @@ func resolveURLsToIPs(urls []string) []net.IP { return ips } -// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server +// updateRouteSelectorFromManagement reconciles exit-node selection on every +// network map: it keeps at most one exit node selected — the user's persisted +// pick, else whatever management marks for auto-apply (SkipAutoApply=false), +// else none. We never auto-activate an exit node the map doesn't request; it +// stays off until the user picks it. Exit nodes are mutually exclusive, but the +// RouteSelector stores routes with default-on semantics, so without this every +// available exit node would report selected at once. func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) { m.mirrorV6ExitPairSelections(clientRoutes) @@ -712,13 +741,14 @@ func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HA return } - exitNodeInfo := m.collectExitNodeInfo(clientRoutes) - if len(exitNodeInfo.allIDs) == 0 { + info := m.collectExitNodeInfo(clientRoutes) + if len(info.allIDs) == 0 { return } - m.updateExitNodeSelections(exitNodeInfo) - m.logExitNodeUpdate(exitNodeInfo) + preferred := pickPreferredExitNode(info) + m.enforceSingleExitNode(preferred, info.allIDs) + m.logExitNodeUpdate(info, preferred) } // mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection @@ -746,15 +776,22 @@ type exitNodeInfo struct { userDeselected []route.NetID } +// collectExitNodeInfo categorises the available exit nodes by their persisted +// selection state. It keys on the base (v4) NetID and skips the synthesized +// "-v6" partner, which inherits its base's selection through the RouteSelector +// — counting it separately would double-count the pair. func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeInfo { var info exitNodeInfo for haID, routes := range clientRoutes { - if !m.isExitNodeRoute(routes) { + if !isExitNodeRoutes(routes) { continue } netID := haID.NetID() + if strings.HasSuffix(string(netID), route.V6ExitSuffix) { + continue + } info.allIDs = append(info.allIDs, netID) if m.routeSelector.HasUserSelectionForRoute(netID) { @@ -767,13 +804,6 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI return info } -func (m *DefaultManager) isExitNodeRoute(routes []*route.Route) bool { - if len(routes) == 0 { - return false - } - return route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network) -} - func (m *DefaultManager) categorizeUserSelection(netID route.NetID, info *exitNodeInfo) { if m.routeSelector.IsSelected(netID) { info.userSelected = append(info.userSelected, netID) @@ -791,45 +821,52 @@ func (m *DefaultManager) checkManagementSelection(routes []*route.Route, netID r } } -func (m *DefaultManager) updateExitNodeSelections(info exitNodeInfo) { - routesToDeselect := m.getRoutesToDeselect(info.allIDs) - m.deselectExitNodes(routesToDeselect) - m.selectExitNodesByManagement(info.selectedByManagement, info.allIDs) +// pickPreferredExitNode chooses the single exit node to keep selected. In order: +// - a persisted user selection wins (deterministic if several survive from +// legacy state, so the set self-heals down to one); +// - otherwise activate only what management marks for auto-apply +// (SkipAutoApply=false); the lexicographically first if it marks several. +// +// Returns "" when neither holds — we never force an arbitrary exit node on. A +// route the map doesn't auto-apply stays off until the user selects it. +// info.userDeselected is informational only: an explicit deselect simply keeps +// that route out of both lists above, so it can't be picked. +func pickPreferredExitNode(info exitNodeInfo) route.NetID { + if len(info.userSelected) > 0 { + return minNetID(info.userSelected) + } + if len(info.selectedByManagement) > 0 { + return minNetID(info.selectedByManagement) + } + return "" } -func (m *DefaultManager) getRoutesToDeselect(allIDs []route.NetID) []route.NetID { - var routesToDeselect []route.NetID - for _, netID := range allIDs { - if !m.routeSelector.HasUserSelectionForRoute(netID) { - routesToDeselect = append(routesToDeselect, netID) +// enforceSingleExitNode makes preferred the only selected exit node: every other +// available exit node is deselected and preferred (if any) is selected, without +// disturbing non-exit route selections. The whole reconciliation runs under a +// single RouteSelector lock (SetExclusiveExitNode) so a concurrent deselect-all +// cannot interleave and get undone; a global deselect-all is left untouched so +// the user's "all off" stays in effect. +func (m *DefaultManager) enforceSingleExitNode(preferred route.NetID, allIDs []route.NetID) { + m.routeSelector.SetExclusiveExitNode(preferred, allIDs) +} + +func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo, preferred route.NetID) { + log.Debugf("Exit node selection: %d available, preferred=%q (%d user-selected, %d user-deselected, %d management-selected)", + len(info.allIDs), preferred, len(info.userSelected), len(info.userDeselected), len(info.selectedByManagement)) +} + +// minNetID returns the lexicographically smallest NetID, for a deterministic +// default pick that stays stable across restarts. +func minNetID(ids []route.NetID) route.NetID { + if len(ids) == 0 { + return "" + } + best := ids[0] + for _, id := range ids[1:] { + if id < best { + best = id } } - return routesToDeselect -} - -func (m *DefaultManager) deselectExitNodes(routesToDeselect []route.NetID) { - if len(routesToDeselect) == 0 { - return - } - - err := m.routeSelector.DeselectRoutes(routesToDeselect, routesToDeselect) - if err != nil { - log.Warnf("Failed to deselect exit nodes: %v", err) - } -} - -func (m *DefaultManager) selectExitNodesByManagement(selectedByManagement []route.NetID, allIDs []route.NetID) { - if len(selectedByManagement) == 0 { - return - } - - err := m.routeSelector.SelectRoutes(selectedByManagement, true, allIDs) - if err != nil { - log.Warnf("Failed to select exit nodes: %v", err) - } -} - -func (m *DefaultManager) logExitNodeUpdate(info exitNodeInfo) { - log.Debugf("Updated route selector: %d exit nodes available, %d selected by management, %d user-selected, %d user-deselected", - len(info.allIDs), len(info.selectedByManagement), len(info.userSelected), len(info.userDeselected)) + return best } diff --git a/client/internal/routemanager/manager_test.go b/client/internal/routemanager/manager_test.go index 926f06bc9..18b44820a 100644 --- a/client/internal/routemanager/manager_test.go +++ b/client/internal/routemanager/manager_test.go @@ -1,3 +1,5 @@ +//go:build privileged + package routemanager import ( diff --git a/client/internal/routemanager/mock.go b/client/internal/routemanager/mock.go index 937314995..2a8398b95 100644 --- a/client/internal/routemanager/mock.go +++ b/client/internal/routemanager/mock.go @@ -16,6 +16,8 @@ type MockManager struct { ClassifyRoutesFunc func(routes []*route.Route) (map[route.ID]*route.Route, route.HAMap) UpdateRoutesFunc func(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error TriggerSelectionFunc func(haMap route.HAMap) + SelectRoutesFunc func(ids []route.NetID, appendRoute bool) error + DeselectRoutesFunc func(ids []route.NetID) error GetRouteSelectorFunc func() *routeselector.RouteSelector GetClientRoutesFunc func() route.HAMap GetSelectedClientRoutesFunc func() route.HAMap @@ -28,8 +30,8 @@ func (m *MockManager) Init() error { return nil } -// InitialRouteRange mock implementation of InitialRouteRange from Manager interface -func (m *MockManager) InitialRouteRange() []string { +// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface +func (m *MockManager) CurrentRouteRange() []string { return nil } @@ -55,6 +57,30 @@ func (m *MockManager) TriggerSelection(networks route.HAMap) { } } +// SelectRoutes mock implementation of SelectRoutes from Manager interface +func (m *MockManager) SelectRoutes(ids []route.NetID, appendRoute bool) error { + if m.SelectRoutesFunc != nil { + return m.SelectRoutesFunc(ids, appendRoute) + } + return nil +} + +// DeselectRoutes mock implementation of DeselectRoutes from Manager interface +func (m *MockManager) DeselectRoutes(ids []route.NetID) error { + if m.DeselectRoutesFunc != nil { + return m.DeselectRoutesFunc(ids) + } + return nil +} + +// SelectAllRoutes mock implementation of SelectAllRoutes from Manager interface +func (m *MockManager) SelectAllRoutes() { +} + +// DeselectAllRoutes mock implementation of DeselectAllRoutes from Manager interface +func (m *MockManager) DeselectAllRoutes() { +} + // GetRouteSelector mock implementation of GetRouteSelector from Manager interface func (m *MockManager) GetRouteSelector() *routeselector.RouteSelector { if m.GetRouteSelectorFunc != nil { @@ -112,6 +138,11 @@ func (m *MockManager) SetFirewall(firewall.Manager) error { func (m *MockManager) SetDNSForwarderPort(port uint16) { } +// ReconcilePeerAllowedIPs mock implementation of ReconcilePeerAllowedIPs from Manager interface +func (m *MockManager) ReconcilePeerAllowedIPs(peerKey string) error { + return nil +} + // Stop mock implementation of Stop from Manager interface func (m *MockManager) Stop(stateManager *statemanager.Manager) { if m.StopFunc != nil { diff --git a/client/internal/routemanager/notifier/notifier_android.go b/client/internal/routemanager/notifier/notifier_android.go index 49300dbb2..5fa329310 100644 --- a/client/internal/routemanager/notifier/notifier_android.go +++ b/client/internal/routemanager/notifier/notifier_android.go @@ -6,7 +6,6 @@ import ( "net/netip" "slices" "sort" - "strings" "sync" "github.com/netbirdio/netbird/client/internal/listener" @@ -14,12 +13,15 @@ import ( ) type Notifier struct { - initialRoutes []*route.Route - currentRoutes []*route.Route - fakeIPRoutes []*route.Route + mu sync.Mutex - listener listener.NetworkChangeListener - listenerMux sync.Mutex + // currentRoutes is the last announced route set. It exists only to + // suppress noise: without it every network map sync would trigger the + // Java side, even when the routes did not change. The actual TUN route + // state is owned by the route manager and pulled from there. + currentRoutes []*route.Route + + listener listener.NetworkChangeListener } func NewNotifier() *Notifier { @@ -27,20 +29,15 @@ func NewNotifier() *Notifier { } func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() + n.mu.Lock() + defer n.mu.Unlock() n.listener = listener } -// SetInitialClientRoutes stores the initial route sets for TUN configuration. -func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) { - n.initialRoutes = filterStatic(initialRoutes) - n.currentRoutes = filterStatic(routesForComparison) -} - -// SetFakeIPRoutes stores the fake IP routes to be included in every TUN rebuild. -func (n *Notifier) SetFakeIPRoutes(routes []*route.Route) { - n.fakeIPRoutes = routes +func (n *Notifier) NotifyRouteChange() { + n.mu.Lock() + defer n.mu.Unlock() + n.notifyLocked() } func (n *Notifier) OnNewRoutes(idMap route.HAMap) { @@ -54,46 +51,32 @@ func (n *Notifier) OnNewRoutes(idMap route.HAMap) { } } - if !n.hasRouteDiff(n.currentRoutes, newRoutes) { + n.mu.Lock() + defer n.mu.Unlock() + if !hasRouteDiff(n.currentRoutes, newRoutes) { return } n.currentRoutes = newRoutes - n.notify() + n.notifyLocked() } func (n *Notifier) OnNewPrefixes([]netip.Prefix) { // Not used on Android } -func (n *Notifier) notify() { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() +func (n *Notifier) notifyLocked() { if n.listener == nil { return } - - allRoutes := slices.Clone(n.currentRoutes) - allRoutes = append(allRoutes, n.fakeIPRoutes...) - - routeStrings := n.routesToStrings(allRoutes) - sort.Strings(routeStrings) - go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged(strings.Join(routeStrings, ",")) - }(n.listener) + n.listener.OnNetworkChanged("") } -func filterStatic(routes []*route.Route) []*route.Route { - out := make([]*route.Route, 0, len(routes)) - for _, r := range routes { - if !r.IsDynamic() { - out = append(out, r) - } - } - return out +func (n *Notifier) Close() { + // unused } -func (n *Notifier) routesToStrings(routes []*route.Route) []string { +func routesToStrings(routes []*route.Route) []string { nets := make([]string, 0, len(routes)) for _, r := range routes { nets = append(nets, r.NetString()) @@ -101,25 +84,10 @@ func (n *Notifier) routesToStrings(routes []*route.Route) []string { return nets } -func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool { - slices.SortFunc(a, func(x, y *route.Route) int { - return strings.Compare(x.NetString(), y.NetString()) - }) - slices.SortFunc(b, func(x, y *route.Route) int { - return strings.Compare(x.NetString(), y.NetString()) - }) - - return !slices.EqualFunc(a, b, func(x, y *route.Route) bool { - return x.NetString() == y.NetString() - }) -} - -func (n *Notifier) GetInitialRouteRanges() []string { - initialStrings := n.routesToStrings(n.initialRoutes) - sort.Strings(initialStrings) - return initialStrings -} - -func (n *Notifier) Close() { - // unused +func hasRouteDiff(a []*route.Route, b []*route.Route) bool { + as := routesToStrings(a) + bs := routesToStrings(b) + sort.Strings(as) + sort.Strings(bs) + return !slices.Equal(as, bs) } diff --git a/client/internal/routemanager/notifier/notifier_ios.go b/client/internal/routemanager/notifier/notifier_ios.go index d0888f3a1..d663dd471 100644 --- a/client/internal/routemanager/notifier/notifier_ios.go +++ b/client/internal/routemanager/notifier/notifier_ios.go @@ -3,7 +3,6 @@ package notifier import ( - "container/list" "net/netip" "slices" "sort" @@ -16,20 +15,12 @@ import ( type Notifier struct { mu sync.Mutex - cond *sync.Cond currentPrefixes []string listener listener.NetworkChangeListener - queue *list.List - closed bool } func NewNotifier() *Notifier { - n := &Notifier{ - queue: list.New(), - } - n.cond = sync.NewCond(&n.mu) - go n.deliverLoop() - return n + return &Notifier{} } func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { @@ -38,11 +29,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { n.listener = listener } -func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) { - // iOS doesn't care about initial routes -} - -func (n *Notifier) SetFakeIPRoutes([]*route.Route) { +func (n *Notifier) NotifyRouteChange() { // Not used on iOS } @@ -59,44 +46,19 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { sort.Strings(newNets) n.mu.Lock() + defer n.mu.Unlock() if slices.Equal(n.currentPrefixes, newNets) { - n.mu.Unlock() return } n.currentPrefixes = newNets - routes := strings.Join(n.currentPrefixes, ",") - n.queue.PushBack(routes) - n.cond.Signal() - n.mu.Unlock() + if n.listener != nil { + n.listener.OnNetworkChanged(strings.Join(n.currentPrefixes, ",")) + } } 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 71b1096c2..fe48e07b3 100644 --- a/client/internal/routemanager/notifier/notifier_other.go +++ b/client/internal/routemanager/notifier/notifier_other.go @@ -19,11 +19,7 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { // Not used on non-mobile platforms } -func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) { - // Not used on non-mobile platforms -} - -func (n *Notifier) SetFakeIPRoutes([]*route.Route) { +func (n *Notifier) NotifyRouteChange() { // Not used on non-mobile platforms } @@ -35,10 +31,6 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { // Not used on non-mobile platforms } -func (n *Notifier) GetInitialRouteRanges() []string { - return []string{} -} - func (n *Notifier) Close() { // unused } diff --git a/client/internal/routemanager/reconcile_test.go b/client/internal/routemanager/reconcile_test.go new file mode 100644 index 000000000..c6806a6cd --- /dev/null +++ b/client/internal/routemanager/reconcile_test.go @@ -0,0 +1,90 @@ +//go:build !windows + +package routemanager + +import ( + "net" + "net/netip" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/tun/netstack" + + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" +) + +// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other +// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them. +type reconcileWGMock struct { + mu sync.Mutex + adds map[string][]netip.Prefix +} + +func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.adds == nil { + m.adds = map[string][]netip.Prefix{} + } + m.adds[peerKey] = append(m.adds[peerKey], allowedIP) + return nil +} + +func (m *reconcileWGMock) added(peerKey string) []netip.Prefix { + m.mu.Lock() + defer m.mu.Unlock() + return m.adds[peerKey] +} + +func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil } +func (m *reconcileWGMock) Name() string { return "utun-test" } +func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} } +func (m *reconcileWGMock) ToInterface() *net.Interface { return nil } +func (m *reconcileWGMock) IsUserspaceBind() bool { return false } +func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil } +func (m *reconcileWGMock) GetDevice() *device.FilteredDevice { return nil } +func (m *reconcileWGMock) GetNet() *netstack.Net { return nil } + +// TestReconcilePeerAllowedIPs verifies the declarative reconcile re-applies every routed prefix +// tracked for the peer (self-heal, independent of refcount level) and stays scoped to that peer. +func TestReconcilePeerAllowedIPs(t *testing.T) { + wg := &reconcileWGMock{} + m := &DefaultManager{wgInterface: wg} + m.allowedIPsRefCounter = refcounter.NewAllowedIPs( + func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, + func(netip.Prefix, string) error { return nil }, + ) + + peerA1 := netip.MustParsePrefix("10.0.0.0/24") + peerA2 := netip.MustParsePrefix("10.1.0.0/24") + peerB1 := netip.MustParsePrefix("10.2.0.0/24") + + for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} { + _, err := m.allowedIPsRefCounter.Increment(prefix, peer) + require.NoError(t, err) + } + // Extra reference: reconcile must still re-apply the prefix even though its refcount never + // hit 0 again (the exact case the plain incremental path skips). + _, err := m.allowedIPsRefCounter.Increment(peerA1, "peerA") + require.NoError(t, err) + + require.NoError(t, m.ReconcilePeerAllowedIPs("peerA")) + + assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, wg.added("peerA"), + "reconcile must re-apply all routed prefixes of the peer") + assert.Empty(t, wg.added("peerB"), "reconcile must not touch another peer's prefixes") +} + +// TestReconcilePeerAllowedIPsNoCounter verifies reconcile is a safe no-op before the refcounter is +// set up. +func TestReconcilePeerAllowedIPsNoCounter(t *testing.T) { + wg := &reconcileWGMock{} + m := &DefaultManager{wgInterface: wg} + + require.NoError(t, m.ReconcilePeerAllowedIPs("peerA")) + assert.Empty(t, wg.added("peerA")) +} diff --git a/client/internal/routemanager/refcounter/allowedips.go b/client/internal/routemanager/refcounter/allowedips.go new file mode 100644 index 000000000..6d682e8a9 --- /dev/null +++ b/client/internal/routemanager/refcounter/allowedips.go @@ -0,0 +1,206 @@ +package refcounter + +import ( + "errors" + "fmt" + "net/netip" + "sort" + "sync" + + "github.com/hashicorp/go-multierror" + + nberrors "github.com/netbirdio/netbird/client/errors" +) + +// allowedIPsEntry holds the per-peer reference counts for a single prefix and which peer is +// currently installed in WireGuard. WireGuard allows a prefix on exactly one peer, so at most +// one peer is active at a time even when several peers reference the prefix. +type allowedIPsEntry struct { + // peers maps a peerKey to the number of references holding the prefix for that peer. + peers map[string]int + // active is the peerKey currently installed in WireGuard for this prefix ("" if none). + active string + // total is the sum of all per-peer reference counts (kept in sync with peers). + total int +} + +// AllowedIPsRefCounter is a peer-aware reference counter for WireGuard AllowedIPs. +// +// The generic Counter keys only by prefix and remembers a single Out value set by the first +// caller, which it never changes. That is wrong for AllowedIPs: two independent watchers (or +// multiple resolved domains) can reference the same prefix through different peers, and when the +// peer currently installed in WireGuard releases its last reference the prefix must be handed over +// to a surviving peer instead of being left pointing at the released one. +// +// It calls add/remove (which program WireGuard) only on the transitions that matter: +// - add on the first reference for a prefix, or when swapping the active peer; +// - remove on the last reference for a prefix, or on the old peer during a swap. +type AllowedIPsRefCounter struct { + mu sync.Mutex + entries map[netip.Prefix]*allowedIPsEntry + add AddFunc[netip.Prefix, string, string] + remove RemoveFunc[netip.Prefix, string] +} + +// NewAllowedIPs creates a new peer-aware AllowedIPs reference counter. +// add programs a prefix on a peer in WireGuard and returns the peerKey to store as the active peer. +// remove unprograms the prefix from the given peer. +func NewAllowedIPs(add AddFunc[netip.Prefix, string, string], remove RemoveFunc[netip.Prefix, string]) *AllowedIPsRefCounter { + return &AllowedIPsRefCounter{ + entries: map[netip.Prefix]*allowedIPsEntry{}, + add: add, + remove: remove, + } +} + +// Increment adds a reference to prefix for peerKey. WireGuard is programmed only for the first +// reference to a prefix; while a different peer is already installed the prefix is left with it +// (first peer wins, HA at the WireGuard layer is not possible) and only the reference count is kept. +func (rm *AllowedIPsRefCounter) Increment(prefix netip.Prefix, peerKey string) (Ref[string], error) { + rm.mu.Lock() + defer rm.mu.Unlock() + + e, ok := rm.entries[prefix] + if !ok { + e = &allowedIPsEntry{peers: map[string]int{}} + rm.entries[prefix] = e + } + + logCallerF("Increasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]", + prefix, peerKey, e.peers[peerKey], e.peers[peerKey]+1, e.total, e.total+1, e.active) + + // Program WireGuard only when nothing is installed yet for this prefix. + if e.active == "" { + out, err := rm.add(prefix, peerKey) + if errors.Is(err, ErrIgnore) { + if e.total == 0 { + delete(rm.entries, prefix) + } + return Ref[string]{Count: e.total, Out: e.active}, nil + } + if err != nil { + if e.total == 0 { + delete(rm.entries, prefix) + } + return Ref[string]{}, fmt.Errorf("failed to add allowed IP %v for peer %s: %w", prefix, peerKey, err) + } + e.active = out + } + + e.peers[peerKey]++ + e.total++ + + return Ref[string]{Count: e.total, Out: e.active}, nil +} + +// Decrement removes a reference to prefix for peerKey. When the peer currently installed in +// WireGuard releases its last reference, the prefix is swapped to a surviving peer if one exists, +// otherwise it is removed from WireGuard. +func (rm *AllowedIPsRefCounter) Decrement(prefix netip.Prefix, peerKey string) (Ref[string], error) { + rm.mu.Lock() + defer rm.mu.Unlock() + + e, ok := rm.entries[prefix] + if !ok { + logCallerF("No allowed IP reference found for prefix %v", prefix) + return Ref[string]{}, nil + } + + if e.peers[peerKey] > 0 { + logCallerF("Decreasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]", + prefix, peerKey, e.peers[peerKey], e.peers[peerKey]-1, e.total, e.total-1, e.active) + e.peers[peerKey]-- + e.total-- + if e.peers[peerKey] == 0 { + delete(e.peers, peerKey) + } + } else { + logCallerF("No allowed IP reference found for prefix %v peer %s", prefix, peerKey) + } + + // If the peer currently installed in WireGuard still holds references, nothing to reprogram. + // Keying the check on the active peer (not the one just released) makes this self-healing: + // a prior swap whose remove/add failed leaves e.active pointing at a peer with no references, + // and this retries the hand-off on the next Decrement instead of getting stuck. + if e.active != "" && e.peers[e.active] > 0 { + return Ref[string]{Count: e.total, Out: e.active}, nil + } + + // Detach the stale/gone active peer from WireGuard before reprogramming. + if e.active != "" { + if err := rm.remove(prefix, e.active); err != nil { + return Ref[string]{Count: e.total, Out: e.active}, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err) + } + e.active = "" + } + + // Hand the prefix over to a surviving peer, or drop the entry when none remain. + if survivor, ok := pickSurvivor(e.peers); ok { + out, err := rm.add(prefix, survivor) + if err != nil { + return Ref[string]{Count: e.total, Out: ""}, fmt.Errorf("swap allowed IP %v to peer %s: %w", prefix, survivor, err) + } + e.active = out + return Ref[string]{Count: e.total, Out: e.active}, nil + } + + delete(rm.entries, prefix) + return Ref[string]{Count: 0, Out: ""}, nil +} + +// Flush removes all prefixes from WireGuard and clears the counter. +func (rm *AllowedIPsRefCounter) Flush() error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for prefix, e := range rm.entries { + if e.active == "" { + continue + } + logCallerF("Flushing allowed IP for prefix %v peer %s", prefix, e.active) + if err := rm.remove(prefix, e.active); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err)) + } + } + + clear(rm.entries) + + return nberrors.FormatErrorOrNil(merr) +} + +// ReapplyMatching calls apply for every prefix whose currently installed (active) peer satisfies +// pred, holding the lock for the whole pass. It is used to re-push allowed IPs onto a peer whose +// WireGuard entry was rebuilt (e.g. a lazy connection cycling idle->wake) without a matching +// refcounter change, which would otherwise leave the prefix installed in the counter but missing +// on the device. Only the active peer is considered — a prefix that lost its installed peer to a +// failed swap is skipped here and reconciled by the next Increment/Decrement. +func (rm *AllowedIPsRefCounter) ReapplyMatching(pred func(out string) bool, apply func(key netip.Prefix) error) error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for prefix, e := range rm.entries { + if e.active != "" && pred(e.active) { + if err := apply(prefix); err != nil { + merr = multierror.Append(merr, err) + } + } + } + return nberrors.FormatErrorOrNil(merr) +} + +// pickSurvivor deterministically selects a peer still referencing the prefix. WireGuard cannot do +// multipath for a single prefix, so any surviving peer is a valid winner; the choice is made stable +// (lowest peerKey) for predictable behavior and testability. +func pickSurvivor(peers map[string]int) (string, bool) { + if len(peers) == 0 { + return "", false + } + keys := make([]string, 0, len(peers)) + for k := range peers { + keys = append(keys, k) + } + sort.Strings(keys) + return keys[0], true +} diff --git a/client/internal/routemanager/refcounter/allowedips_test.go b/client/internal/routemanager/refcounter/allowedips_test.go new file mode 100644 index 000000000..835142083 --- /dev/null +++ b/client/internal/routemanager/refcounter/allowedips_test.go @@ -0,0 +1,241 @@ +package refcounter + +import ( + "errors" + "net/netip" + "testing" +) + +// fakeWG models WireGuard's cryptokey routing: a prefix can be installed on exactly one peer. +// failAdd/failRemove make the next add/remove fail once, to exercise the self-healing error paths. +type fakeWG struct { + installed map[netip.Prefix]string + adds int + removes int + failAdd bool + failRemove bool +} + +func newFakeWG() *fakeWG { + return &fakeWG{installed: map[netip.Prefix]string{}} +} + +func (f *fakeWG) counter() *AllowedIPsRefCounter { + return NewAllowedIPs( + func(prefix netip.Prefix, peerKey string) (string, error) { + if f.failAdd { + f.failAdd = false + return "", errors.New("add failed") + } + f.adds++ + f.installed[prefix] = peerKey + return peerKey, nil + }, + func(prefix netip.Prefix, peerKey string) error { + if f.failRemove { + f.failRemove = false + return errors.New("remove failed") + } + f.removes++ + // only clear if this peer is the one installed, mirroring wg semantics + if f.installed[prefix] == peerKey { + delete(f.installed, prefix) + } + return nil + }, + ) +} + +func mustPrefix(t *testing.T, s string) netip.Prefix { + t.Helper() + p, err := netip.ParsePrefix(s) + if err != nil { + t.Fatalf("parse prefix %q: %v", s, err) + } + return p +} + +func mustIncrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] { + t.Helper() + ref, err := c.Increment(p, peer) + if err != nil { + t.Fatalf("Increment(%v, %s): %v", p, peer, err) + } + return ref +} + +func mustDecrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] { + t.Helper() + ref, err := c.Decrement(p, peer) + if err != nil { + t.Fatalf("Decrement(%v, %s): %v", p, peer, err) + } + return ref +} + +// TestAllowedIPs_SwapOnActivePeerRemoval reproduces the reported bug: two networks with the same +// prefix routed by different peers. Removing the network whose peer is installed must hand the +// prefix over to the surviving peer instead of leaving it on the removed one. +func TestAllowedIPs_SwapOnActivePeerRemoval(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + // First peer wins while both are present. + if got := f.installed[p]; got != "peerA" { + t.Fatalf("expected peerA installed, got %q", got) + } + + // Remove the active peer's network -> must swap to peerB. + mustDecrement(t, c, p, "peerA") + if got := f.installed[p]; got != "peerB" { + t.Fatalf("BUG: prefix stuck on removed peer, want peerB got %q", got) + } + + // Remove the last one -> prefix gone. + mustDecrement(t, c, p, "peerB") + if _, ok := f.installed[p]; ok { + t.Fatalf("expected prefix removed, still installed on %q", f.installed[p]) + } +} + +// TestAllowedIPs_RemoveNonActivePeer removing a non-installed peer must not touch WireGuard. +func TestAllowedIPs_RemoveNonActivePeer(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + removesBefore := f.removes + + mustDecrement(t, c, p, "peerB") + if f.installed[p] != "peerA" { + t.Fatalf("active peer must stay peerA, got %q", f.installed[p]) + } + if f.removes != removesBefore { + t.Fatalf("removing a non-active peer must not call wg remove") + } +} + +// TestAllowedIPs_SamePeerMultipleRefs two references via the same peer must keep the prefix until +// the last reference is released (the reason the per-peer count must be an int, not a set). +func TestAllowedIPs_SamePeerMultipleRefs(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerA") + if f.adds != 1 { + t.Fatalf("expected a single wg add for the same peer, got %d", f.adds) + } + + mustDecrement(t, c, p, "peerA") + if f.installed[p] != "peerA" { + t.Fatalf("prefix must stay while a reference remains, got %q", f.installed[p]) + } + if f.removes != 0 { + t.Fatalf("no wg remove expected while a reference remains, got %d", f.removes) + } + + mustDecrement(t, c, p, "peerA") + if _, ok := f.installed[p]; ok { + t.Fatalf("prefix must be removed after last reference") + } +} + +// TestAllowedIPs_RefCountAndActive checks the Ref returned to callers (used for the HA-disabled log). +func TestAllowedIPs_RefCountAndActive(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + ref := mustIncrement(t, c, p, "peerA") + if ref.Count != 1 || ref.Out != "peerA" { + t.Fatalf("want {1, peerA}, got {%d, %q}", ref.Count, ref.Out) + } + ref = mustIncrement(t, c, p, "peerB") + if ref.Count != 2 || ref.Out != "peerA" { + t.Fatalf("want {2, peerA}, got {%d, %q}", ref.Count, ref.Out) + } +} + +// TestAllowedIPs_Flush removes everything installed and clears the counter. +func TestAllowedIPs_Flush(t *testing.T) { + f := newFakeWG() + c := f.counter() + p1 := mustPrefix(t, "10.44.8.0/24") + p2 := mustPrefix(t, "10.44.9.0/24") + + mustIncrement(t, c, p1, "peerA") + mustIncrement(t, c, p2, "peerB") + + if err := c.Flush(); err != nil { + t.Fatal(err) + } + if len(f.installed) != 0 { + t.Fatalf("expected all prefixes removed, got %v", f.installed) + } + // After flush, a fresh increment must add again. + mustIncrement(t, c, p1, "peerC") + if f.installed[p1] != "peerC" { + t.Fatalf("counter not reset after flush") + } +} + +// TestAllowedIPs_SelfHealAfterSwapAddError ensures a failed add during a swap does not permanently +// strand the prefix: the next Decrement (or Increment) must retry and install a surviving peer. +func TestAllowedIPs_SelfHealAfterSwapAddError(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + mustIncrement(t, c, p, "peerC") + + // Removing the active peerA triggers a swap to a survivor; make the add fail once. + f.failAdd = true + if _, err := c.Decrement(p, "peerA"); err == nil { + t.Fatalf("expected error from failed swap add") + } + if _, ok := f.installed[p]; ok { + t.Fatalf("nothing should be installed after a failed swap add, got %q", f.installed[p]) + } + + // A later Decrement of a non-active survivor must retry the hand-off (self-heal), not stay stuck. + ref := mustDecrement(t, c, p, "peerC") + if got := f.installed[p]; got == "" { + t.Fatalf("self-heal failed: prefix left unrouted after add recovered") + } + if ref.Out == "" { + t.Fatalf("expected an active peer after self-heal, got empty") + } +} + +// TestAllowedIPs_SelfHealAfterRemoveError ensures a failed remove during a swap is retried instead +// of leaving e.active stuck on a peer that no longer holds references. +func TestAllowedIPs_SelfHealAfterRemoveError(t *testing.T) { + f := newFakeWG() + c := f.counter() + p := mustPrefix(t, "10.44.8.0/24") + + mustIncrement(t, c, p, "peerA") + mustIncrement(t, c, p, "peerB") + + // Releasing active peerA must detach it (remove) then add peerB; fail the remove once. + f.failRemove = true + if _, err := c.Decrement(p, "peerA"); err == nil { + t.Fatalf("expected error from failed remove") + } + + // Next Decrement of the non-active survivor retries: removes stale peerA, installs peerB. + mustDecrement(t, c, p, "peerB") + // peerB had only one ref, so after retry the prefix is fully released. + if _, ok := f.installed[p]; ok { + t.Fatalf("expected prefix released after self-heal, still on %q", f.installed[p]) + } +} diff --git a/client/internal/routemanager/refcounter/refcounter.go b/client/internal/routemanager/refcounter/refcounter.go index 27a724f50..917120275 100644 --- a/client/internal/routemanager/refcounter/refcounter.go +++ b/client/internal/routemanager/refcounter/refcounter.go @@ -94,6 +94,26 @@ func (rm *Counter[Key, I, O]) Get(key Key) (Ref[O], bool) { return ref, ok } +// ReapplyMatching calls apply for every key whose stored Out satisfies pred, holding the +// counter lock for the whole pass. Running apply under the lock keeps it atomic with respect +// to Increment/Decrement: a prefix dropped to zero is removed from the map (and had its +// RemoveFunc called) before this pass observes it, so a stale key can never be re-applied. +// pred and apply are invoked under the lock, so they must not call back into the counter. +func (rm *Counter[Key, I, O]) ReapplyMatching(pred func(out O) bool, apply func(key Key) error) error { + rm.mu.Lock() + defer rm.mu.Unlock() + + var merr *multierror.Error + for key, ref := range rm.refCountMap { + if pred(ref.Out) { + if err := apply(key); err != nil { + merr = multierror.Append(merr, err) + } + } + } + return nberrors.FormatErrorOrNil(merr) +} + // Increment increments the reference count for the given key. // If this is the first reference to the key, the AddFunc is called. func (rm *Counter[Key, I, O]) Increment(key Key, in I) (Ref[O], error) { diff --git a/client/internal/routemanager/refcounter/refcounter_test.go b/client/internal/routemanager/refcounter/refcounter_test.go new file mode 100644 index 000000000..79a99c388 --- /dev/null +++ b/client/internal/routemanager/refcounter/refcounter_test.go @@ -0,0 +1,47 @@ +package refcounter + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReapplyMatching verifies ReapplyMatching invokes apply for exactly the keys whose stored +// Out satisfies the predicate (no duplicates for multiply-referenced keys) — the primitive +// ReconcilePeerAllowedIPs relies on to re-apply a single peer's routed prefixes. +func TestReapplyMatching(t *testing.T) { + rc := New[netip.Prefix, string, string]( + func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil }, + func(netip.Prefix, string) error { return nil }, + ) + + peerA1 := netip.MustParsePrefix("10.0.0.0/24") + peerA2 := netip.MustParsePrefix("10.1.0.0/24") + peerB1 := netip.MustParsePrefix("10.2.0.0/24") + + for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} { + _, err := rc.Increment(prefix, peer) + require.NoError(t, err) + } + // a second reference must not make the key applied twice + _, err := rc.Increment(peerA1, "peerA") + require.NoError(t, err) + + var applied []netip.Prefix + err = rc.ReapplyMatching( + func(out string) bool { return out == "peerA" }, + func(key netip.Prefix) error { applied = append(applied, key); return nil }, + ) + require.NoError(t, err) + assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, applied) + + var none []netip.Prefix + err = rc.ReapplyMatching( + func(out string) bool { return out == "missing" }, + func(key netip.Prefix) error { none = append(none, key); return nil }, + ) + require.NoError(t, err) + assert.Empty(t, none) +} diff --git a/client/internal/routemanager/refcounter/types.go b/client/internal/routemanager/refcounter/types.go index aadac3e25..7da0e17e3 100644 --- a/client/internal/routemanager/refcounter/types.go +++ b/client/internal/routemanager/refcounter/types.go @@ -5,5 +5,7 @@ import "net/netip" // RouteRefCounter is a Counter for Route, it doesn't take any input on Increment and doesn't use any output on Decrement type RouteRefCounter = Counter[netip.Prefix, struct{}, struct{}] -// AllowedIPsRefCounter is a Counter for AllowedIPs, it takes a peer key on Increment and passes it back to Decrement -type AllowedIPsRefCounter = Counter[netip.Prefix, string, string] +// AllowedIPsRefCounter tracks WireGuard AllowedIPs per prefix. Unlike the generic Counter it is peer-aware: +// a prefix can be claimed by several peers at once and WireGuard allows a given prefix on exactly one peer, +// so the counter records the per-peer reference count and swaps the installed peer when the active one is released. +// See allowedips.go. diff --git a/client/internal/routemanager/selection.go b/client/internal/routemanager/selection.go new file mode 100644 index 000000000..6d5feec79 --- /dev/null +++ b/client/internal/routemanager/selection.go @@ -0,0 +1,138 @@ +package routemanager + +import ( + "fmt" + "slices" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/route" +) + +// SelectRoutes selects the routes with the given network IDs and applies the +// new selection. V4/v6 exit-node pairs are expanded automatically. Exit nodes +// are mutually exclusive: if the selection activates an exit node, every other +// available exit node is deselected so two can't be active at once. With +// appendRoute=false the previous selection is replaced instead of extended. +func (m *DefaultManager) SelectRoutes(ids []route.NetID, appendRoute bool) error { + if err := m.selectRoutes(ids, appendRoute); err != nil { + return err + } + m.TriggerSelection(m.GetClientRoutes()) + return nil +} + +// DeselectRoutes removes the routes with the given network IDs from the +// selection and applies the change. V4/v6 exit-node pairs are expanded +// automatically. +func (m *DefaultManager) DeselectRoutes(ids []route.NetID) error { + if err := m.deselectRoutes(ids); err != nil { + return err + } + m.TriggerSelection(m.GetClientRoutes()) + return nil +} + +func (m *DefaultManager) deselectRoutes(ids []route.NetID) error { + routesMap := m.GetClientRoutesWithNetID() + routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap) + + log.Debugf("deselecting routes with ids: %v", routes) + + if err := m.routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil { + return fmt.Errorf("deselect routes: %w", err) + } + + return nil +} + +// SelectAllRoutes selects every available route and applies the selection. +// Exit nodes stay mutually exclusive: at most one remains active. +func (m *DefaultManager) SelectAllRoutes() { + m.selectAllRoutes() + m.TriggerSelection(m.GetClientRoutes()) +} + +func (m *DefaultManager) selectAllRoutes() { + m.routeSelector.SelectAllRoutes() + + // Select-all wipes every explicit selection, so exit nodes fall back to + // management's auto-apply flags — which may mark several at once. + // Reconcile immediately so at most one exit node stays active instead of + // waiting for the next network map to enforce it. + m.mux.Lock() + defer m.mux.Unlock() + m.updateRouteSelectorFromManagement(m.clientRoutes) +} + +// DeselectAllRoutes deselects every route and applies the change. +func (m *DefaultManager) DeselectAllRoutes() { + m.routeSelector.DeselectAllRoutes() + m.TriggerSelection(m.GetClientRoutes()) +} + +func (m *DefaultManager) selectRoutes(ids []route.NetID, appendRoute bool) error { + routesMap := m.GetClientRoutesWithNetID() + routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap) + allIDs := maps.Keys(routesMap) + + log.Debugf("selecting routes with ids: %v", routes) + + // A partial failure (e.g. an unknown ID in the request) still selects the + // valid routes, so exclusivity below must run regardless of the error. + var merr *multierror.Error + if err := m.routeSelector.SelectRoutes(routes, appendRoute, allIDs); err != nil { + merr = multierror.Append(merr, fmt.Errorf("select routes: %w", err)) + } + + // Exit nodes are mutually exclusive: if this selection activates an + // exit node, deselect every other available exit node so two can't be + // selected at once. Non-exit route selections are left untouched. + if requestActivatesExitNode(routes, routesMap) { + if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 { + if err := m.routeSelector.DeselectRoutes(others, allIDs); err != nil { + merr = multierror.Append(merr, fmt.Errorf("deselect sibling exit nodes: %w", err)) + } + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +func isExitNodeRoutes(routes []*route.Route) bool { + return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)) +} + +// requestActivatesExitNode reports whether any requested NetID maps to an exit +// node (default route) in the current route table. +func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool { + for _, id := range requested { + if isExitNodeRoutes(routesMap[id]) { + return true + } + } + return false +} + +// otherExitNodeIDs returns every available exit-node NetID that is not in the +// requested set — the siblings to deselect so a single exit node stays active. +func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID { + keep := make(map[route.NetID]struct{}, len(requested)) + for _, id := range requested { + keep[id] = struct{}{} + } + var others []route.NetID + for id, routes := range routesMap { + if !isExitNodeRoutes(routes) { + continue + } + if _, ok := keep[id]; ok { + continue + } + others = append(others, id) + } + return others +} diff --git a/client/internal/routemanager/selection_test.go b/client/internal/routemanager/selection_test.go new file mode 100644 index 000000000..6066b5661 --- /dev/null +++ b/client/internal/routemanager/selection_test.go @@ -0,0 +1,129 @@ +package routemanager + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +func v6ExitRoute(netID, peer string) *route.Route { + return &route.Route{ + NetID: route.NetID(netID), + Network: netip.MustParsePrefix("::/0"), + Peer: peer, + } +} + +func newSelectionTestManager() *DefaultManager { + return &DefaultManager{ + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)}, + "exitA-v6|::/0": {v6ExitRoute("exitA-v6", "p1")}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)}, + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}}, + }, + } +} + +func TestSelectRoutes_ExitNodeExclusivity(t *testing.T) { + m := newSelectionTestManager() + + // Selecting an exit node selects its v6 pair and deselects the sibling. + require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA should be selected") + assert.True(t, m.routeSelector.IsSelected("exitA-v6"), "the v6 pair follows its v4 base") + assert.False(t, m.routeSelector.IsSelected("exitB"), "the sibling exit node must be deselected") + + // Switching to the sibling deselects the previous exit node and its v6 pair. + require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitB"), "exitB should now be selected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "the previous exit node must be deselected") + assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "the previous exit node's v6 pair must be deselected") + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched") + + // Selecting a non-exit route leaves the active exit node alone. + require.NoError(t, m.selectRoutes([]route.NetID{"lan"}, true)) + assert.True(t, m.routeSelector.IsSelected("exitB"), "selecting a non-exit route keeps the exit node") + + // Deselecting the active exit node turns every exit node off. + require.NoError(t, m.deselectRoutes([]route.NetID{"exitB"})) + assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB should be deselected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "exitA stays deselected") + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched") +} + +func TestSelectRoutes_PartialErrorStillEnforcesExclusivity(t *testing.T) { + // The unknown ID must be reported, but the valid exit node in the same + // request is still selected — so its sibling must still be deselected. + // Both orderings are covered: processing must continue past the invalid + // ID wherever it sits in the request. + requests := map[string][]route.NetID{ + "invalid id first": {"missing", "exitB"}, + "invalid id last": {"exitB", "missing"}, + } + + for name, ids := range requests { + t.Run(name, func(t *testing.T) { + m := newSelectionTestManager() + + require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true)) + + err := m.selectRoutes(ids, true) + assert.Error(t, err, "unknown id must be reported") + assert.True(t, m.routeSelector.IsSelected("exitB"), "valid exit node from the request is selected") + assert.False(t, m.routeSelector.IsSelected("exitA"), "sibling exit node must be deselected despite the error") + assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "sibling's v6 pair must be deselected too") + }) + } +} + +func TestSelectAllRoutes_KeepsSingleExitNode(t *testing.T) { + // Both exit nodes are marked for auto-apply by management + // (SkipAutoApply=false), the state where select-all could turn on two at + // once without the immediate reconciliation. + m := &DefaultManager{ + routeSelector: routeselector.NewRouteSelector(), + clientRoutes: route.HAMap{ + "exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)}, + "exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)}, + "lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}}, + }, + } + + require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true)) + + m.selectAllRoutes() + + assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit routes are all selected") + assert.True(t, m.routeSelector.IsSelected("exitA"), "the deterministic management pick stays active") + assert.False(t, m.routeSelector.IsSelected("exitB"), "select-all must not leave a second exit node active") +} + +func TestSelectRoutes_UnknownRoute(t *testing.T) { + m := newSelectionTestManager() + + assert.Error(t, m.selectRoutes([]route.NetID{"missing"}, true), "selecting an unavailable route must fail") + assert.Error(t, m.deselectRoutes([]route.NetID{"missing"}), "deselecting an unavailable route must fail") +} + +func TestExitNodeSelectionHelpers(t *testing.T) { + routesMap := map[route.NetID][]*route.Route{ + "exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}}, + "exitB": {{Network: netip.MustParsePrefix("::/0")}}, + "lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}}, + } + + assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node") + assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node") + assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node") + assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node") + + others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"}) + assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored") +} diff --git a/client/internal/routemanager/static/route.go b/client/internal/routemanager/static/route.go index d480fdf00..8ba03d090 100644 --- a/client/internal/routemanager/static/route.go +++ b/client/internal/routemanager/static/route.go @@ -15,6 +15,11 @@ type Route struct { route *route.Route routeRefCounter *refcounter.RouteRefCounter allowedIPsRefcounter *refcounter.AllowedIPsRefCounter + // currentPeerKey is the routing peer this watcher currently has the prefix installed on + // (the HA winner elected by the watcher). It can differ from route.Peer and change on + // failover, so it is recorded on AddAllowedIPs and used on RemoveAllowedIPs to decrement + // the exact peer that was incremented. + currentPeerKey string } func NewRoute(params common.HandlerParams) *Route { @@ -52,12 +57,15 @@ func (r *Route) AddAllowedIPs(peerKey string) error { ref.Out, ) } + r.currentPeerKey = peerKey return nil } func (r *Route) RemoveAllowedIPs() error { - if _, err := r.allowedIPsRefcounter.Decrement(r.route.Network); err != nil { - return err + var err error + if _, decErr := r.allowedIPsRefcounter.Decrement(r.route.Network, r.currentPeerKey); decErr != nil { + err = fmt.Errorf("remove allowed IP %s: %w", r.route.Network, decErr) } - return nil + r.currentPeerKey = "" + return err } diff --git a/client/internal/routemanager/sysctl/sysctl_linux.go b/client/internal/routemanager/sysctl/sysctl_linux.go index f96a57f37..bb131c691 100644 --- a/client/internal/routemanager/sysctl/sysctl_linux.go +++ b/client/internal/routemanager/sysctl/sysctl_linux.go @@ -20,6 +20,8 @@ const ( rpFilterPath = "net.ipv4.conf.all.rp_filter" rpFilterInterfacePath = "net.ipv4.conf.%s.rp_filter" srcValidMarkPath = "net.ipv4.conf.all.src_valid_mark" + percentEscape = "%25" + dotEscape = "%2E" ) type iface interface { @@ -56,7 +58,7 @@ func Setup(wgIface iface) (map[string]int, error) { continue } - i := fmt.Sprintf(rpFilterInterfacePath, intf.Name) + i := fmt.Sprintf(rpFilterInterfacePath, EscapeInterfaceName(intf.Name)) oldVal, err := Set(i, 2, true) if err != nil { result = multierror.Append(result, err) @@ -68,9 +70,20 @@ func Setup(wgIface iface) (map[string]int, error) { return keys, nberrors.FormatErrorOrNil(result) } +// EscapeInterfaceName escapes '%' and '.' in an interface name (e.g. VLANs +// like eth0.100) so the name survives the dot-to-slash conversion in Set. +func EscapeInterfaceName(name string) string { + safe := strings.ReplaceAll(name, "%", percentEscape) + return strings.ReplaceAll(safe, ".", dotEscape) +} + // Set sets a sysctl configuration, if onlyIfOne is true it will only set the new value if it's set to 1 func Set(key string, desiredValue int, onlyIfOne bool) (int, error) { - path := fmt.Sprintf("/proc/sys/%s", strings.ReplaceAll(key, ".", "/")) + path := strings.ReplaceAll(key, ".", "/") + // Unescape interface dots and percent signs + path = strings.ReplaceAll(path, dotEscape, ".") + path = strings.ReplaceAll(path, percentEscape, "%") + path = fmt.Sprintf("/proc/sys/%s", path) currentValue, err := os.ReadFile(path) if err != nil { return -1, fmt.Errorf("read sysctl %s: %w", key, err) diff --git a/client/internal/routemanager/systemops/routeselection_windows_test.go b/client/internal/routemanager/systemops/routeselection_windows_test.go new file mode 100644 index 000000000..108338dd9 --- /dev/null +++ b/client/internal/routemanager/systemops/routeselection_windows_test.go @@ -0,0 +1,82 @@ +//go:build windows + +package systemops + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSortRouteCandidates(t *testing.T) { + tests := []struct { + name string + candidates []candidateRoute + wantOrder []uint32 + }{ + { + name: "longest prefix wins over metrics", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: 0, interfaceMetric: 5}, + {interfaceIndex: 2, prefixLength: 24, routeMetric: 100, interfaceMetric: 50}, + }, + wantOrder: []uint32{2, 1}, + }, + { + // Windows ranks equal-length prefixes by route metric + interface metric, + // so a higher route metric on a low metric interface can still win. + name: "combined metric beats route metric alone", + candidates: []candidateRoute{ + {interfaceIndex: 8, prefixLength: 0, routeMetric: 0, interfaceMetric: 100}, + {interfaceIndex: 5, prefixLength: 0, routeMetric: 10, interfaceMetric: 5}, + }, + wantOrder: []uint32{5, 8}, + }, + { + name: "lower combined metric wins", + candidates: []candidateRoute{ + {interfaceIndex: 5, prefixLength: 0, routeMetric: 300, interfaceMetric: 5}, + {interfaceIndex: 8, prefixLength: 0, routeMetric: 0, interfaceMetric: 100}, + }, + wantOrder: []uint32{8, 5}, + }, + { + name: "equal combined metric falls back to route metric", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: 20, interfaceMetric: 10}, + {interfaceIndex: 2, prefixLength: 0, routeMetric: 5, interfaceMetric: 25}, + }, + wantOrder: []uint32{2, 1}, + }, + { + // The metrics are uint32 on the Windows side, so the sum must not wrap. + name: "combined metric beyond the uint32 range", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: math.MaxUint32, interfaceMetric: 5}, + {interfaceIndex: 2, prefixLength: 0, routeMetric: math.MaxUint32 - 10, interfaceMetric: 5}, + }, + wantOrder: []uint32{2, 1}, + }, + { + name: "unknown interface metric ranks on route metric only", + candidates: []candidateRoute{ + {interfaceIndex: 1, prefixLength: 0, routeMetric: 30, interfaceMetric: -1}, + {interfaceIndex: 2, prefixLength: 0, routeMetric: 5, interfaceMetric: 10}, + }, + wantOrder: []uint32{2, 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sortRouteCandidates(tt.candidates) + + got := make([]uint32, 0, len(tt.candidates)) + for _, c := range tt.candidates { + got = append(got, c.interfaceIndex) + } + assert.Equal(t, tt.wantOrder, got) + }) + } +} diff --git a/client/internal/routemanager/systemops/rt_tables_linux_test.go b/client/internal/routemanager/systemops/rt_tables_linux_test.go new file mode 100644 index 000000000..bc9cca8b1 --- /dev/null +++ b/client/internal/routemanager/systemops/rt_tables_linux_test.go @@ -0,0 +1,69 @@ +//go:build linux && !android + +package systemops + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEntryExists(t *testing.T) { + tempDir := t.TempDir() + tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir) + + content := []string{ + "1000 reserved", + fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName), + "9999 other_table", + } + require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644)) + + file, err := os.Open(tempFilePath) + require.NoError(t, err) + defer func() { + assert.NoError(t, file.Close()) + }() + + tests := []struct { + name string + id int + shouldExist bool + err error + }{ + { + name: "ExistsWithNetbirdPrefix", + id: 7120, + shouldExist: true, + err: nil, + }, + { + name: "ExistsWithDifferentName", + id: 1000, + shouldExist: true, + err: ErrTableIDExists, + }, + { + name: "DoesNotExist", + id: 1234, + shouldExist: false, + err: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + exists, err := entryExists(file, tc.id) + if tc.err != nil { + assert.ErrorIs(t, err, tc.err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.shouldExist, exists) + }) + } +} diff --git a/client/internal/routemanager/systemops/systemops_android.go b/client/internal/routemanager/systemops/systemops_android.go index 7cb8dae93..97b4ed8ec 100644 --- a/client/internal/routemanager/systemops/systemops_android.go +++ b/client/internal/routemanager/systemops/systemops_android.go @@ -32,8 +32,17 @@ func (r *SysOps) removeFromRouteTable(netip.Prefix, Nexthop) error { return nil } -func EnableIPForwarding() error { - log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS) +func EnableV4IPForwarding() error { + log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS) + return nil +} + +func EnableV6IPForwarding(string) (map[string]int, error) { + log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS) + return map[string]int{}, nil +} + +func DisableV6IPForwarding(map[string]int) error { return nil } diff --git a/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go new file mode 100644 index 000000000..d45028c19 --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go @@ -0,0 +1,191 @@ +//go:build (darwin || dragonfly || freebsd || netbsd || openbsd) && privileged + +package systemops + +import ( + "fmt" + "net" + "net/netip" + "os/exec" + "regexp" + "runtime" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + testCases = append(testCases, []testCase{ + { + name: "To more specific route without custom dialer via vpn", + expectedInterface: expectedVPNint, + dialer: &net.Dialer{}, + expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53), + }, + }...) +} + +func TestConcurrentRoutes(t *testing.T) { + baseIP := netip.MustParseAddr("192.0.2.0") + + var intf *net.Interface + var nexthop Nexthop + + _, intf = setupDummyInterface(t) + nexthop = Nexthop{netip.Addr{}, intf} + + r := New(nil, nil) + + var wg sync.WaitGroup + for i := 0; i < 1024; i++ { + wg.Add(1) + go func(ip netip.Addr) { + defer wg.Done() + prefix := netip.PrefixFrom(ip, 32) + if err := r.addToRouteTable(prefix, nexthop); err != nil { + t.Errorf("Failed to add route for %s: %v", prefix, err) + } + }(baseIP) + baseIP = baseIP.Next() + } + + wg.Wait() + + baseIP = netip.MustParseAddr("192.0.2.0") + + for i := 0; i < 1024; i++ { + wg.Add(1) + go func(ip netip.Addr) { + defer wg.Done() + prefix := netip.PrefixFrom(ip, 32) + if err := r.removeFromRouteTable(prefix, nexthop); err != nil { + t.Errorf("Failed to remove route for %s: %v", prefix, err) + } + }(baseIP) + baseIP = baseIP.Next() + } + + wg.Wait() +} + +func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string { + t.Helper() + + if runtime.GOOS == "darwin" { + err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run() + require.NoError(t, err, "Failed to create loopback alias") + + t.Cleanup(func() { + err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run() + assert.NoError(t, err, "Failed to remove loopback alias") + }) + + return intf + } + + prefix, err := netip.ParsePrefix(ipAddressCIDR) + require.NoError(t, err, "Failed to parse prefix") + + netIntf, err := net.InterfaceByName(intf) + require.NoError(t, err, "Failed to get interface by name") + + nexthop := Nexthop{netip.Addr{}, netIntf} + + r := New(nil, nil) + err = r.addToRouteTable(prefix, nexthop) + require.NoError(t, err, "Failed to add route to table") + + t.Cleanup(func() { + err := r.removeFromRouteTable(prefix, nexthop) + assert.NoError(t, err, "Failed to remove route from table") + }) + + return intf +} + +func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) { + t.Helper() + + var originalNexthop net.IP + if dstCIDR == "0.0.0.0/0" { + var err error + originalNexthop, err = fetchOriginalGateway() + if err != nil { + t.Logf("Failed to fetch original gateway: %v", err) + } + + if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil { + t.Logf("Failed to delete route: %v, output: %s", err, output) + } + } + + t.Cleanup(func() { + if originalNexthop != nil { + err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run() + assert.NoError(t, err, "Failed to restore original route") + } + }) + + err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run() + require.NoError(t, err, "Failed to add route") + + t.Cleanup(func() { + err := exec.Command("route", "delete", "-net", dstCIDR).Run() + assert.NoError(t, err, "Failed to remove route") + }) +} + +func fetchOriginalGateway() (net.IP, error) { + output, err := exec.Command("route", "-n", "get", "default").CombinedOutput() + if err != nil { + return nil, err + } + + matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output)) + if len(matches) == 0 { + return nil, fmt.Errorf("gateway not found") + } + + return net.ParseIP(matches[1]), nil +} + +// setupDummyInterface creates a dummy tun interface for FreeBSD route testing +func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) { + t.Helper() + + if runtime.GOOS == "darwin" { + return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"} + } + + output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput() + require.NoError(t, err, "Failed to create tun interface: %s", string(output)) + + tunName := strings.TrimSpace(string(output)) + + output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput() + require.NoError(t, err, "Failed to configure tun interface: %s", string(output)) + + intf, err := net.InterfaceByName(tunName) + require.NoError(t, err, "Failed to get interface by name") + + t.Cleanup(func() { + if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil { + t.Logf("Failed to destroy tun interface %s: %v", tunName, err) + } + }) + + return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf +} + +func setupDummyInterfacesAndRoutes(t *testing.T) { + t.Helper() + + defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24") + addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy) + + otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24") + addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy) +} diff --git a/client/internal/routemanager/systemops/systemops_bsd_test.go b/client/internal/routemanager/systemops/systemops_bsd_test.go index ec4fc406e..9650945b3 100644 --- a/client/internal/routemanager/systemops/systemops_bsd_test.go +++ b/client/internal/routemanager/systemops/systemops_bsd_test.go @@ -3,79 +3,24 @@ package systemops import ( - "fmt" - "net" - "net/netip" - "os/exec" - "regexp" - "runtime" - "strings" - "sync" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "golang.org/x/net/route" ) +// Interface names used by the shared routing test fixtures. Kept untagged (no +// privileged build tag) so the non-privileged test files in this package compile. +// +//nolint:unused // consumed by the privileged-tagged routing tests var expectedVPNint = "utun100" + +//nolint:unused // consumed by the privileged-tagged routing tests var expectedExternalInt = "lo0" + +//nolint:unused // consumed by the privileged-tagged routing tests var expectedInternalInt = "lo0" -func init() { - testCases = append(testCases, []testCase{ - { - name: "To more specific route without custom dialer via vpn", - expectedInterface: expectedVPNint, - dialer: &net.Dialer{}, - expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53), - }, - }...) -} - -func TestConcurrentRoutes(t *testing.T) { - baseIP := netip.MustParseAddr("192.0.2.0") - - var intf *net.Interface - var nexthop Nexthop - - _, intf = setupDummyInterface(t) - nexthop = Nexthop{netip.Addr{}, intf} - - r := New(nil, nil) - - var wg sync.WaitGroup - for i := 0; i < 1024; i++ { - wg.Add(1) - go func(ip netip.Addr) { - defer wg.Done() - prefix := netip.PrefixFrom(ip, 32) - if err := r.addToRouteTable(prefix, nexthop); err != nil { - t.Errorf("Failed to add route for %s: %v", prefix, err) - } - }(baseIP) - baseIP = baseIP.Next() - } - - wg.Wait() - - baseIP = netip.MustParseAddr("192.0.2.0") - - for i := 0; i < 1024; i++ { - wg.Add(1) - go func(ip netip.Addr) { - defer wg.Done() - prefix := netip.PrefixFrom(ip, 32) - if err := r.removeFromRouteTable(prefix, nexthop); err != nil { - t.Errorf("Failed to remove route for %s: %v", prefix, err) - } - }(baseIP) - baseIP = baseIP.Next() - } - - wg.Wait() -} - func TestBits(t *testing.T) { tests := []struct { name string @@ -122,122 +67,3 @@ func TestBits(t *testing.T) { }) } } - -func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string { - t.Helper() - - if runtime.GOOS == "darwin" { - err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run() - require.NoError(t, err, "Failed to create loopback alias") - - t.Cleanup(func() { - err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run() - assert.NoError(t, err, "Failed to remove loopback alias") - }) - - return intf - } - - prefix, err := netip.ParsePrefix(ipAddressCIDR) - require.NoError(t, err, "Failed to parse prefix") - - netIntf, err := net.InterfaceByName(intf) - require.NoError(t, err, "Failed to get interface by name") - - nexthop := Nexthop{netip.Addr{}, netIntf} - - r := New(nil, nil) - err = r.addToRouteTable(prefix, nexthop) - require.NoError(t, err, "Failed to add route to table") - - t.Cleanup(func() { - err := r.removeFromRouteTable(prefix, nexthop) - assert.NoError(t, err, "Failed to remove route from table") - }) - - return intf -} - -func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) { - t.Helper() - - var originalNexthop net.IP - if dstCIDR == "0.0.0.0/0" { - var err error - originalNexthop, err = fetchOriginalGateway() - if err != nil { - t.Logf("Failed to fetch original gateway: %v", err) - } - - if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil { - t.Logf("Failed to delete route: %v, output: %s", err, output) - } - } - - t.Cleanup(func() { - if originalNexthop != nil { - err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run() - assert.NoError(t, err, "Failed to restore original route") - } - }) - - err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run() - require.NoError(t, err, "Failed to add route") - - t.Cleanup(func() { - err := exec.Command("route", "delete", "-net", dstCIDR).Run() - assert.NoError(t, err, "Failed to remove route") - }) -} - -func fetchOriginalGateway() (net.IP, error) { - output, err := exec.Command("route", "-n", "get", "default").CombinedOutput() - if err != nil { - return nil, err - } - - matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output)) - if len(matches) == 0 { - return nil, fmt.Errorf("gateway not found") - } - - return net.ParseIP(matches[1]), nil -} - -// setupDummyInterface creates a dummy tun interface for FreeBSD route testing -func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) { - t.Helper() - - if runtime.GOOS == "darwin" { - return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"} - } - - output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput() - require.NoError(t, err, "Failed to create tun interface: %s", string(output)) - - tunName := strings.TrimSpace(string(output)) - - output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput() - require.NoError(t, err, "Failed to configure tun interface: %s", string(output)) - - intf, err := net.InterfaceByName(tunName) - require.NoError(t, err, "Failed to get interface by name") - - t.Cleanup(func() { - if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil { - t.Logf("Failed to destroy tun interface %s: %v", tunName, err) - } - }) - - return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf -} - -func setupDummyInterfacesAndRoutes(t *testing.T) { - t.Helper() - - defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24") - addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy) - - otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24") - addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy) -} diff --git a/client/internal/routemanager/systemops/systemops_dialer_test.go b/client/internal/routemanager/systemops/systemops_dialer_test.go new file mode 100644 index 000000000..f00f9099c --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_dialer_test.go @@ -0,0 +1,17 @@ +//go:build !android && !ios + +package systemops + +import ( + "context" + "net" +) + +// dialer is shared by the per-platform routing test cases. Kept untagged (no +// privileged build tag) so the non-privileged test files compile on every platform. +// +//nolint:unused // consumed by the privileged-tagged routing tests +type dialer interface { + Dial(network, address string) (net.Conn, error) + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} diff --git a/client/internal/routemanager/systemops/systemops_generic_test.go b/client/internal/routemanager/systemops/systemops_generic_test.go index 5695c40c3..c4f739c30 100644 --- a/client/internal/routemanager/systemops/systemops_generic_test.go +++ b/client/internal/routemanager/systemops/systemops_generic_test.go @@ -1,4 +1,4 @@ -//go:build !android && !ios +//go:build !android && !ios && privileged package systemops @@ -26,11 +26,6 @@ import ( nbnet "github.com/netbirdio/netbird/client/net" ) -type dialer interface { - Dial(network, address string) (net.Conn, error) - DialContext(ctx context.Context, network, address string) (net.Conn, error) -} - func TestAddVPNRoute(t *testing.T) { testCases := []struct { name string @@ -515,125 +510,3 @@ func setupTestEnv(t *testing.T) { // unique route in vpn table setupRouteAndCleanup(t, r, netip.MustParsePrefix("172.16.0.0/12"), intf) } - -func TestIsVpnRoute(t *testing.T) { - tests := []struct { - name string - addr string - vpnRoutes []string - localRoutes []string - expectedVpn bool - expectedPrefix netip.Prefix - }{ - { - name: "Match in VPN routes", - addr: "192.168.1.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: true, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - { - name: "Match in local routes", - addr: "10.1.1.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: false, - expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"), - }, - { - name: "No match", - addr: "172.16.0.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: false, - expectedPrefix: netip.Prefix{}, - }, - { - name: "Default route ignored", - addr: "192.168.1.1", - vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: true, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - { - name: "Default route matches but ignored", - addr: "172.16.1.1", - vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"}, - localRoutes: []string{"10.0.0.0/8"}, - expectedVpn: false, - expectedPrefix: netip.Prefix{}, - }, - { - name: "Longest prefix match local", - addr: "192.168.1.1", - vpnRoutes: []string{"192.168.0.0/16"}, - localRoutes: []string{"192.168.1.0/24"}, - expectedVpn: false, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - { - name: "Longest prefix match local multiple", - addr: "192.168.0.1", - vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"}, - localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"}, - expectedVpn: false, - expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"), - }, - { - name: "Longest prefix match vpn", - addr: "192.168.1.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"192.168.0.0/16"}, - expectedVpn: true, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - { - name: "Longest prefix match vpn multiple", - addr: "192.168.0.1", - vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"}, - localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"}, - expectedVpn: true, - expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"), - }, - { - name: "Duplicate prefix in both", - addr: "192.168.1.1", - vpnRoutes: []string{"192.168.1.0/24"}, - localRoutes: []string{"192.168.1.0/24"}, - expectedVpn: false, - expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - addr, err := netip.ParseAddr(tt.addr) - if err != nil { - t.Fatalf("Failed to parse address %s: %v", tt.addr, err) - } - - var vpnRoutes, localRoutes []netip.Prefix - for _, route := range tt.vpnRoutes { - prefix, err := netip.ParsePrefix(route) - if err != nil { - t.Fatalf("Failed to parse VPN route %s: %v", route, err) - } - vpnRoutes = append(vpnRoutes, prefix) - } - - for _, route := range tt.localRoutes { - prefix, err := netip.ParsePrefix(route) - if err != nil { - t.Fatalf("Failed to parse local route %s: %v", route, err) - } - localRoutes = append(localRoutes, prefix) - } - - isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes) - assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value") - assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix") - }) - } -} diff --git a/client/internal/routemanager/systemops/systemops_ios.go b/client/internal/routemanager/systemops/systemops_ios.go index 99a363371..0cccd4962 100644 --- a/client/internal/routemanager/systemops/systemops_ios.go +++ b/client/internal/routemanager/systemops/systemops_ios.go @@ -58,8 +58,17 @@ func (r *SysOps) removeFromRouteTable(netip.Prefix, Nexthop) error { return nil } -func EnableIPForwarding() error { - log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS) +func EnableV4IPForwarding() error { + log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS) + return nil +} + +func EnableV6IPForwarding(string) (map[string]int, error) { + log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS) + return map[string]int{}, nil +} + +func DisableV6IPForwarding(map[string]int) error { return nil } diff --git a/client/internal/routemanager/systemops/systemops_isvpnroute_test.go b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go new file mode 100644 index 000000000..677fe1287 --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go @@ -0,0 +1,132 @@ +//go:build !android && !ios + +package systemops + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsVpnRoute(t *testing.T) { + tests := []struct { + name string + addr string + vpnRoutes []string + localRoutes []string + expectedVpn bool + expectedPrefix netip.Prefix + }{ + { + name: "Match in VPN routes", + addr: "192.168.1.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: true, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + { + name: "Match in local routes", + addr: "10.1.1.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: false, + expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"), + }, + { + name: "No match", + addr: "172.16.0.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: false, + expectedPrefix: netip.Prefix{}, + }, + { + name: "Default route ignored", + addr: "192.168.1.1", + vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: true, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + { + name: "Default route matches but ignored", + addr: "172.16.1.1", + vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"}, + localRoutes: []string{"10.0.0.0/8"}, + expectedVpn: false, + expectedPrefix: netip.Prefix{}, + }, + { + name: "Longest prefix match local", + addr: "192.168.1.1", + vpnRoutes: []string{"192.168.0.0/16"}, + localRoutes: []string{"192.168.1.0/24"}, + expectedVpn: false, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + { + name: "Longest prefix match local multiple", + addr: "192.168.0.1", + vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"}, + localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"}, + expectedVpn: false, + expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"), + }, + { + name: "Longest prefix match vpn", + addr: "192.168.1.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"192.168.0.0/16"}, + expectedVpn: true, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + { + name: "Longest prefix match vpn multiple", + addr: "192.168.0.1", + vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"}, + localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"}, + expectedVpn: true, + expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"), + }, + { + name: "Duplicate prefix in both", + addr: "192.168.1.1", + vpnRoutes: []string{"192.168.1.0/24"}, + localRoutes: []string{"192.168.1.0/24"}, + expectedVpn: false, + expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr, err := netip.ParseAddr(tt.addr) + if err != nil { + t.Fatalf("Failed to parse address %s: %v", tt.addr, err) + } + + var vpnRoutes, localRoutes []netip.Prefix + for _, route := range tt.vpnRoutes { + prefix, err := netip.ParsePrefix(route) + if err != nil { + t.Fatalf("Failed to parse VPN route %s: %v", route, err) + } + vpnRoutes = append(vpnRoutes, prefix) + } + + for _, route := range tt.localRoutes { + prefix, err := netip.ParsePrefix(route) + if err != nil { + t.Fatalf("Failed to parse local route %s: %v", route, err) + } + localRoutes = append(localRoutes, prefix) + } + + isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes) + assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value") + assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix") + }) + } +} diff --git a/client/internal/routemanager/systemops/systemops_linux.go b/client/internal/routemanager/systemops/systemops_linux.go index 8c6b7d9a9..7d608d886 100644 --- a/client/internal/routemanager/systemops/systemops_linux.go +++ b/client/internal/routemanager/systemops/systemops_linux.go @@ -763,13 +763,10 @@ func flushRoutes(tableID, family int) error { return nberrors.FormatErrorOrNil(result) } -func EnableIPForwarding() error { +func EnableV4IPForwarding() error { if _, err := sysctl.Set(ipv4ForwardingPath, 1, false); err != nil { return err } - if _, err := sysctl.Set(ipv6ForwardingPath, 1, false); err != nil { - log.Warnf("failed to enable IPv6 forwarding: %v", err) - } return nil } diff --git a/client/internal/routemanager/systemops/systemops_linux_test.go b/client/internal/routemanager/systemops/systemops_linux_test.go index 880296d91..06c528ce5 100644 --- a/client/internal/routemanager/systemops/systemops_linux_test.go +++ b/client/internal/routemanager/systemops/systemops_linux_test.go @@ -1,13 +1,10 @@ -//go:build !android +//go:build linux && !android && privileged package systemops import ( "errors" - "fmt" "net" - "os" - "strings" "syscall" "testing" @@ -18,10 +15,6 @@ import ( "github.com/netbirdio/netbird/client/internal/routemanager/vars" ) -var expectedVPNint = "wgtest0" -var expectedExternalInt = "dummyext0" -var expectedInternalInt = "dummyint0" - func init() { testCases = append(testCases, []testCase{ { @@ -33,62 +26,6 @@ func init() { }...) } -func TestEntryExists(t *testing.T) { - tempDir := t.TempDir() - tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir) - - content := []string{ - "1000 reserved", - fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName), - "9999 other_table", - } - require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644)) - - file, err := os.Open(tempFilePath) - require.NoError(t, err) - defer func() { - assert.NoError(t, file.Close()) - }() - - tests := []struct { - name string - id int - shouldExist bool - err error - }{ - { - name: "ExistsWithNetbirdPrefix", - id: 7120, - shouldExist: true, - err: nil, - }, - { - name: "ExistsWithDifferentName", - id: 1000, - shouldExist: true, - err: ErrTableIDExists, - }, - { - name: "DoesNotExist", - id: 1234, - shouldExist: false, - err: nil, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - exists, err := entryExists(file, tc.id) - if tc.err != nil { - assert.ErrorIs(t, err, tc.err) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tc.shouldExist, exists) - }) - } -} - func createAndSetupDummyInterface(t *testing.T, interfaceName, ipAddressCIDR string) string { t.Helper() diff --git a/client/internal/routemanager/systemops/systemops_nonlinux.go b/client/internal/routemanager/systemops/systemops_nonlinux.go index 016a62ebd..837ac0cd2 100644 --- a/client/internal/routemanager/systemops/systemops_nonlinux.go +++ b/client/internal/routemanager/systemops/systemops_nonlinux.go @@ -43,8 +43,17 @@ func (r *SysOps) RemoveVPNRoute(prefix netip.Prefix, intf *net.Interface) error return r.genericRemoveVPNRoute(prefix, intf) } -func EnableIPForwarding() error { - log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS) +func EnableV4IPForwarding() error { + log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS) + return nil +} + +func EnableV6IPForwarding(string) (map[string]int, error) { + log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS) + return map[string]int{}, nil +} + +func DisableV6IPForwarding(map[string]int) error { return nil } diff --git a/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go new file mode 100644 index 000000000..9be267980 --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go @@ -0,0 +1,15 @@ +//go:build linux && !android + +package systemops + +// Interface names used by the shared routing test fixtures. Kept untagged (no +// privileged build tag) so the non-privileged test files in this package compile. +// +//nolint:unused // consumed by the privileged-tagged routing tests +var expectedVPNint = "wgtest0" + +//nolint:unused // consumed by the privileged-tagged routing tests +var expectedExternalInt = "dummyext0" + +//nolint:unused // consumed by the privileged-tagged routing tests +var expectedInternalInt = "dummyint0" diff --git a/client/internal/routemanager/systemops/systemops_routing_data_test.go b/client/internal/routemanager/systemops/systemops_routing_data_test.go new file mode 100644 index 000000000..16f17f5b9 --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_routing_data_test.go @@ -0,0 +1,83 @@ +//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly + +package systemops + +import ( + "net" + + nbnet "github.com/netbirdio/netbird/client/net" +) + +// Shared, non-privileged routing test fixtures. The privileged TestRouting (and its +// per-platform init() appenders) consume these; they live here so the unprivileged +// BSD/darwin test files compile without the privileged build tag. + +type PacketExpectation struct { + SrcIP net.IP + DstIP net.IP + SrcPort int + DstPort int + UDP bool + TCP bool +} + +//nolint:unused // consumed by the privileged-tagged routing tests +type testCase struct { + name string + expectedInterface string + dialer dialer + expectedPacket PacketExpectation +} + +//nolint:unused // consumed by the privileged-tagged routing tests +var testCases = []testCase{ + { + name: "To external host without custom dialer via vpn", + expectedInterface: expectedVPNint, + dialer: &net.Dialer{}, + expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53), + }, + { + name: "To external host with custom dialer via physical interface", + expectedInterface: expectedExternalInt, + dialer: nbnet.NewDialer(), + expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53), + }, + + { + name: "To duplicate internal route with custom dialer via physical interface", + expectedInterface: expectedInternalInt, + dialer: nbnet.NewDialer(), + expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53), + }, + { + name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence + expectedInterface: expectedInternalInt, + dialer: &net.Dialer{}, + expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53), + }, + + { + name: "To unique vpn route with custom dialer via physical interface", + expectedInterface: expectedExternalInt, + dialer: nbnet.NewDialer(), + expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53), + }, + { + name: "To unique vpn route without custom dialer via vpn", + expectedInterface: expectedVPNint, + dialer: &net.Dialer{}, + expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53), + }, +} + +//nolint:unused // consumed by the privileged-tagged routing tests +func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation { + return PacketExpectation{ + SrcIP: net.ParseIP(srcIP), + DstIP: net.ParseIP(dstIP), + SrcPort: srcPort, + DstPort: dstPort, + UDP: true, + } +} diff --git a/client/internal/routemanager/systemops/systemops_unix_test.go b/client/internal/routemanager/systemops/systemops_unix_test.go index 959c697e4..efb0ae4e4 100644 --- a/client/internal/routemanager/systemops/systemops_unix_test.go +++ b/client/internal/routemanager/systemops/systemops_unix_test.go @@ -1,4 +1,4 @@ -//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly +//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly) && privileged package systemops @@ -20,63 +20,6 @@ import ( nbnet "github.com/netbirdio/netbird/client/net" ) -type PacketExpectation struct { - SrcIP net.IP - DstIP net.IP - SrcPort int - DstPort int - UDP bool - TCP bool -} - -type testCase struct { - name string - expectedInterface string - dialer dialer - expectedPacket PacketExpectation -} - -var testCases = []testCase{ - { - name: "To external host without custom dialer via vpn", - expectedInterface: expectedVPNint, - dialer: &net.Dialer{}, - expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53), - }, - { - name: "To external host with custom dialer via physical interface", - expectedInterface: expectedExternalInt, - dialer: nbnet.NewDialer(), - expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53), - }, - - { - name: "To duplicate internal route with custom dialer via physical interface", - expectedInterface: expectedInternalInt, - dialer: nbnet.NewDialer(), - expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53), - }, - { - name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence - expectedInterface: expectedInternalInt, - dialer: &net.Dialer{}, - expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53), - }, - - { - name: "To unique vpn route with custom dialer via physical interface", - expectedInterface: expectedExternalInt, - dialer: nbnet.NewDialer(), - expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53), - }, - { - name: "To unique vpn route without custom dialer via vpn", - expectedInterface: expectedVPNint, - dialer: &net.Dialer{}, - expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53), - }, -} - func TestRouting(t *testing.T) { nbnet.Init() for _, tc := range testCases { @@ -102,16 +45,6 @@ func TestRouting(t *testing.T) { } } -func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation { - return PacketExpectation{ - SrcIP: net.ParseIP(srcIP), - DstIP: net.ParseIP(dstIP), - SrcPort: srcPort, - DstPort: dstPort, - UDP: true, - } -} - func startPacketCapture(t *testing.T, intf, filter string) *pcap.Handle { t.Helper() diff --git a/client/internal/routemanager/systemops/systemops_windows.go b/client/internal/routemanager/systemops/systemops_windows.go index 7bce6af80..47d556cf6 100644 --- a/client/internal/routemanager/systemops/systemops_windows.go +++ b/client/internal/routemanager/systemops/systemops_windows.go @@ -882,26 +882,40 @@ func getInterfaceMetric(interfaceIndex uint32, family int16) int { return int(ipInterfaceRow.Metric) } -// sortRouteCandidates sorts route candidates by priority: prefix length -> route metric -> interface metric +// sortRouteCandidates sorts route candidates by priority: prefix length -> combined metric -> route metric. +// Windows prefers the longest matching prefix and, among prefixes of the same length, the lowest metric, see +// https://learn.microsoft.com/en-us/windows-hardware/customize/desktop/unattend/microsoft-windows-tcpip-interfaces-interface-routes-route-metric func sortRouteCandidates(candidates []candidateRoute) { sort.Slice(candidates, func(i, j int) bool { if candidates[i].prefixLength != candidates[j].prefixLength { return candidates[i].prefixLength > candidates[j].prefixLength } - if candidates[i].routeMetric != candidates[j].routeMetric { - return candidates[i].routeMetric < candidates[j].routeMetric + mi, mj := combinedMetric(candidates[i]), combinedMetric(candidates[j]) + if mi != mj { + return mi < mj } - return candidates[i].interfaceMetric < candidates[j].interfaceMetric + return candidates[i].routeMetric < candidates[j].routeMetric }) } +// combinedMetric returns the effective metric Windows uses to rank routes with an equal prefix length: +// the sum of the route metric and the metric of the interface the route is on, see +// https://learn.microsoft.com/en-us/windows-server/networking/technologies/network-subsystem/net-sub-interface-metric +// An unknown interface metric contributes nothing. +func combinedMetric(candidate candidateRoute) uint64 { + if candidate.interfaceMetric < 0 { + return uint64(candidate.routeMetric) + } + return uint64(candidate.routeMetric) + uint64(candidate.interfaceMetric) +} + // GetBestInterface finds the best interface for reaching a destination, // excluding the VPN interface to avoid routing loops. // // Route selection priority: // 1. Longest prefix match (most specific route) -// 2. Lowest route metric -// 3. Lowest interface metric +// 2. Lowest combined metric (route metric + interface metric) +// 3. Lowest route metric. func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) { var skipInterfaceIndex int if vpnIntf != "" { @@ -925,7 +939,6 @@ func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) { return nil, fmt.Errorf("no route to %s", dest) } - // Sort routes: prefix length -> route metric -> interface metric sortRouteCandidates(candidates) for _, candidate := range candidates { diff --git a/client/internal/routemanager/systemops/systemops_windows_test.go b/client/internal/routemanager/systemops/systemops_windows_test.go index 3561adec4..77e349bd6 100644 --- a/client/internal/routemanager/systemops/systemops_windows_test.go +++ b/client/internal/routemanager/systemops/systemops_windows_test.go @@ -1,3 +1,5 @@ +//go:build windows && privileged + package systemops import ( diff --git a/client/internal/routemanager/systemops/v6forwarding_linux.go b/client/internal/routemanager/systemops/v6forwarding_linux.go new file mode 100644 index 000000000..c1e0d4588 --- /dev/null +++ b/client/internal/routemanager/systemops/v6forwarding_linux.go @@ -0,0 +1,92 @@ +//go:build !android + +package systemops + +import ( + "fmt" + "net" + "os" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/internal/routemanager/sysctl" +) + +const ( + // 1 (default) accepts RAs only while forwarding is off; 2 keeps RA + // acceptance on regardless, so RA-installed host defaults survive our + // v6 forwarding flip. + acceptRAInterfacePath = "net.ipv6.conf.%s.accept_ra" + acceptRADefaultPath = "net.ipv6.conf.default.accept_ra" + acceptRAProcPathFormat = "/proc/sys/net/ipv6/conf/%s/accept_ra" +) + +// EnableV6IPForwarding bumps accept_ra=2 on host v6 interfaces before flipping +// forwarding=1, so RA-installed host defaults survive. Returns the prior values +// of sysctls we actually changed; entries already at the target are omitted. +func EnableV6IPForwarding(wgIfaceName string) (map[string]int, error) { + saved := map[string]int{} + bumpAcceptRA(saved, wgIfaceName) + + oldVal, err := sysctl.Set(ipv6ForwardingPath, 1, false) + if err != nil { + return saved, err + } + if oldVal != 1 { + saved[ipv6ForwardingPath] = oldVal + } + return saved, nil +} + +// DisableV6IPForwarding restores what EnableV6IPForwarding captured. +func DisableV6IPForwarding(saved map[string]int) error { + var result *multierror.Error + for key, value := range saved { + if _, err := sysctl.Set(key, value, false); err != nil { + result = multierror.Append(result, fmt.Errorf("restore %s: %w", key, err)) + } + } + return nberrors.FormatErrorOrNil(result) +} + +func bumpAcceptRA(saved map[string]int, wgIfaceName string) { + // Also bump conf.default so interfaces created while forwarding is on + // (hotplug, new Wi-Fi/dock) inherit accept_ra=2 and keep accepting RAs. + bumpAcceptRAKey(saved, acceptRADefaultPath) + + interfaces, err := net.Interfaces() + if err != nil { + log.Warnf("list interfaces for accept_ra: %v", err) + return + } + for _, intf := range interfaces { + if intf.Name == "lo" || intf.Name == wgIfaceName { + continue + } + bumpAcceptRAForInterface(saved, intf.Name) + } +} + +func bumpAcceptRAForInterface(saved map[string]int, name string) { + // Build procfs path from name, not the dotted key: VLAN names like eth0.100. + if _, err := os.Stat(fmt.Sprintf(acceptRAProcPathFormat, name)); err != nil { + return + } + bumpAcceptRAKey(saved, fmt.Sprintf(acceptRAInterfacePath, sysctl.EscapeInterfaceName(name))) +} + +func bumpAcceptRAKey(saved map[string]int, key string) { + // onlyIfOne=true: leave admin overrides (0, 2) alone. + oldVal, err := sysctl.Set(key, 2, true) + if err != nil { + log.Warnf("bump %s: %v", key, err) + return + } + // With onlyIfOne, a write only happened when the old value was 1; values + // left untouched (0, 2) must not be recorded for restore. + if oldVal == 1 { + saved[key] = oldVal + } +} diff --git a/client/internal/routemanager/systemops/v6route_bsd_test.go b/client/internal/routemanager/systemops/v6route_bsd_test.go index 98ce29c6d..90e49f54e 100644 --- a/client/internal/routemanager/systemops/v6route_bsd_test.go +++ b/client/internal/routemanager/systemops/v6route_bsd_test.go @@ -11,6 +11,8 @@ import ( // ensureIPv6DefaultRoute installs an IPv6 default route via the loopback // interface so route lookups for global IPv6 prefixes resolve in environments // without v6 connectivity. If a default already exists it is left alone. +// +//nolint:unused // consumed by the privileged-tagged routing tests func ensureIPv6DefaultRoute(t *testing.T) { t.Helper() diff --git a/client/internal/routemanager/systemops/v6route_linux_test.go b/client/internal/routemanager/systemops/v6route_linux_test.go index 0b17cefff..d8c0012d1 100644 --- a/client/internal/routemanager/systemops/v6route_linux_test.go +++ b/client/internal/routemanager/systemops/v6route_linux_test.go @@ -1,10 +1,11 @@ -//go:build linux && !android +//go:build linux && !android && privileged package systemops import ( "errors" "net" + "net/netip" "syscall" "testing" @@ -29,6 +30,7 @@ func ensureIPv6DefaultRoute(t *testing.T) { } if err := netlink.RouteAdd(route); err != nil { if errors.Is(err, syscall.EEXIST) { + requireUsableIPv6Nexthop(t) return } t.Skipf("install IPv6 fallback default route: %v", err) @@ -38,4 +40,36 @@ func ensureIPv6DefaultRoute(t *testing.T) { t.Logf("delete IPv6 fallback default route: %v", err) } }) + + requireUsableIPv6Nexthop(t) +} + +// requireUsableIPv6Nexthop skips the test unless the resolved IPv6 default +// nexthop can actually carry a route. Installing the default route succeeding +// does not imply the kernel accepts it as a nexthop for a concrete prefix. +func requireUsableIPv6Nexthop(t *testing.T) { + t.Helper() + + nexthop, err := GetNextHop(netip.IPv6Unspecified()) + if err != nil { + t.Skipf("resolve IPv6 default nexthop: %v", err) + } + + probe := &netlink.Route{ + Scope: netlink.SCOPE_UNIVERSE, + Table: syscall.RT_TABLE_MAIN, + Family: netlink.FAMILY_V6, + Dst: &net.IPNet{IP: net.ParseIP("100::64"), Mask: net.CIDRMask(128, 128)}, + } + require.NoError(t, addNextHop(nexthop, probe), "build IPv6 probe route") + + switch err := netlink.RouteAdd(probe); { + case err == nil: + if err := netlink.RouteDel(probe); err != nil && !errors.Is(err, syscall.ESRCH) { + t.Logf("delete IPv6 probe route: %v", err) + } + case errors.Is(err, syscall.EEXIST): + default: + t.Skipf("IPv6 nexthop %s unusable for route installation: %v", nexthop, err) + } } diff --git a/client/internal/routemanager/systemops/v6route_windows_test.go b/client/internal/routemanager/systemops/v6route_windows_test.go index f79277b87..2c813a790 100644 --- a/client/internal/routemanager/systemops/v6route_windows_test.go +++ b/client/internal/routemanager/systemops/v6route_windows_test.go @@ -8,11 +8,14 @@ import ( "testing" ) +//nolint:unused // consumed by the privileged-tagged routing tests const loopbackIfaceWindows = "Loopback Pseudo-Interface 1" // ensureIPv6DefaultRoute installs an IPv6 default route via the loopback // interface so route lookups for global IPv6 prefixes resolve in environments // without v6 connectivity. If a default already exists it is left alone. +// +//nolint:unused // consumed by the privileged-tagged routing tests func ensureIPv6DefaultRoute(t *testing.T) { t.Helper() diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go index 232baf746..1254b384d 100644 --- a/client/internal/routeselector/routeselector.go +++ b/client/internal/routeselector/routeselector.go @@ -115,7 +115,38 @@ func (rs *RouteSelector) DeselectAllRoutes() { clear(rs.selectedRoutes) } -// IsDeselectAll reports whether the user has explicitly deselected all routes. +// SetExclusiveExitNode atomically makes preferred the only selected exit node +// among exitIDs: every other ID in exitIDs is deselected and preferred (when +// non-empty) is selected, all under a single lock. Holding the lock across the +// whole reconciliation prevents a concurrent DeselectAllRoutes from interleaving +// between the deselect and select steps and being silently undone. A global +// deselect-all is left untouched so the user's "all off" stays in effect; +// non-exit routes are never referenced, so their selection is preserved. +func (rs *RouteSelector) SetExclusiveExitNode(preferred route.NetID, exitIDs []route.NetID) { + rs.mu.Lock() + defer rs.mu.Unlock() + + if rs.deselectAll { + return + } + + for _, id := range exitIDs { + if id == preferred { + continue + } + rs.deselectedRoutes[id] = struct{}{} + delete(rs.selectedRoutes, id) + } + + if preferred != "" { + delete(rs.deselectedRoutes, preferred) + rs.selectedRoutes[preferred] = struct{}{} + } +} + +// IsDeselectAll reports whether the global "deselect all" flag is set, i.e. the +// user explicitly disabled every route. Callers enforcing per-route invariants +// (e.g. single exit node) should leave the selection untouched when it is. func (rs *RouteSelector) IsDeselectAll() bool { rs.mu.RLock() defer rs.mu.RUnlock() diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go index c9d6acb4d..2b1ba3fb9 100644 --- a/client/internal/routeselector/routeselector_test.go +++ b/client/internal/routeselector/routeselector_test.go @@ -859,3 +859,31 @@ func TestRouteSelector_ComplexScenarios(t *testing.T) { }) } } + +// TestRouteSelector_EnableExitNodeKeepsOtherRoutes is a regression test for the +// tray exit-node toggle disabling every non-exit routed network. The tray used +// to Select an exit node with append=false, which the RouteSelector treats as +// "drop the whole current selection" (default-on semantics) — so enabling an +// exit node also turned off every LAN/route the user had on. The fix sends +// append=true and lets the daemon's SelectNetworks handler deselect only the +// sibling exit nodes. This test models that handler sequence against the +// selector: SelectRoutes(exit, append=true) followed by DeselectRoutes(other +// exit nodes) must leave non-exit routes untouched. +func TestRouteSelector_EnableExitNodeKeepsOtherRoutes(t *testing.T) { + rs := routeselector.NewRouteSelector() + all := []route.NetID{"exitA", "exitB", "lan1", "lan2"} + + // User has two LAN routes on (default-on: nothing deselected => all selected). + require.True(t, rs.IsSelected("lan1")) + require.True(t, rs.IsSelected("lan2")) + + // Tray enables exitA: SelectNetworks handler does SelectRoutes(append=true) + // then deselects sibling exit nodes (exitB), never the LAN routes. + require.NoError(t, rs.SelectRoutes([]route.NetID{"exitA"}, true, all)) + require.NoError(t, rs.DeselectRoutes([]route.NetID{"exitB"}, all)) + + assert.True(t, rs.IsSelected("exitA"), "selected exit node stays on") + assert.False(t, rs.IsSelected("exitB"), "sibling exit node is deselected") + assert.True(t, rs.IsSelected("lan1"), "non-exit route must stay selected") + assert.True(t, rs.IsSelected("lan2"), "non-exit route must stay selected") +} diff --git a/client/internal/sleep/service.go b/client/internal/sleep/service.go index 196a33f52..93691c4c7 100644 --- a/client/internal/sleep/service.go +++ b/client/internal/sleep/service.go @@ -18,8 +18,8 @@ type Service struct { } func New() (*Service, error) { - d, err := NewDetector() - if err != nil { + d, err := NewDetector() //nolint:staticcheck + if err != nil { //nolint:staticcheck // always errors on platforms without a sleep detector return nil, err } diff --git a/client/internal/state.go b/client/internal/state.go index 041cb73f8..0adfa26e4 100644 --- a/client/internal/state.go +++ b/client/internal/state.go @@ -33,17 +33,34 @@ func CtxGetState(ctx context.Context) *contextState { } type contextState struct { - err error - status StatusType - mutex sync.Mutex + err error + status StatusType + mutex sync.Mutex + onChange func() +} + +// SetOnChange installs a callback fired after every successful Set. Used by +// the daemon to wire the status recorder's notifyStateChange so any +// state.Set in the connect/login paths pushes a fresh snapshot to +// SubscribeStatus subscribers without each callsite having to opt in. +// The callback runs outside the contextState mutex to avoid a lock-order +// dependency with the recorder's stateChangeMux. +func (c *contextState) SetOnChange(fn func()) { + c.mutex.Lock() + c.onChange = fn + c.mutex.Unlock() } func (c *contextState) Set(update StatusType) { c.mutex.Lock() - defer c.mutex.Unlock() - c.status = update c.err = nil + cb := c.onChange + c.mutex.Unlock() + + if cb != nil { + cb() + } } func (c *contextState) Status() (StatusType, error) { @@ -57,6 +74,17 @@ func (c *contextState) Status() (StatusType, error) { return c.status, nil } +// CurrentStatus returns the last status set via Set, ignoring any wrapped +// error. Use when the status is needed for reporting purposes (e.g. the +// status snapshot stream) and a transient wrapped error from a retry loop +// shouldn't blank out the underlying status. +func (c *contextState) CurrentStatus() StatusType { + c.mutex.Lock() + defer c.mutex.Unlock() + + return c.status +} + func (c *contextState) Wrap(err error) error { c.mutex.Lock() defer c.mutex.Unlock() diff --git a/client/internal/statemanager/manager.go b/client/internal/statemanager/manager.go index 566905985..ca4194690 100644 --- a/client/internal/statemanager/manager.go +++ b/client/internal/statemanager/manager.go @@ -1,6 +1,7 @@ package statemanager import ( + "bytes" "context" "encoding/json" "errors" @@ -305,6 +306,11 @@ func (m *Manager) loadStateFile(deleteCorrupt bool) (map[string]json.RawMessage, var rawStates map[string]json.RawMessage if err := json.Unmarshal(data, &rawStates); err != nil { + if len(bytes.TrimSpace(data)) == 0 { + log.Warnf("state file %s is empty (%d bytes)", m.filePath, len(data)) + } else { + log.Warnf("state file %s has malformed content (%d bytes)", m.filePath, len(data)) + } m.handleCorruptedState(deleteCorrupt) return nil, fmt.Errorf("unmarshal states: %w", err) } diff --git a/client/internal/tunnelnotifier/notifier.go b/client/internal/tunnelnotifier/notifier.go new file mode 100644 index 000000000..b62923a6e --- /dev/null +++ b/client/internal/tunnelnotifier/notifier.go @@ -0,0 +1,124 @@ +package tunnelnotifier + +import ( + "container/list" + "sync" + + "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/listener" +) + +type eventKind int + +const ( + eventRoutes eventKind = iota + eventIfaceIP + eventIfaceIPv6 + eventDNS +) + +var ( + _ listener.NetworkChangeListener = (*Notifier)(nil) + _ dns.IosDnsManager = (*Notifier)(nil) +) + +type event struct { + kind eventKind + payload string +} + +type Notifier struct { + mu sync.Mutex + cond *sync.Cond + queue *list.List + closed bool + done chan struct{} + + listener listener.NetworkChangeListener + dnsManager dns.IosDnsManager +} + +func New(l listener.NetworkChangeListener, dm dns.IosDnsManager) *Notifier { + n := &Notifier{ + queue: list.New(), + done: make(chan struct{}), + listener: l, + dnsManager: dm, + } + n.cond = sync.NewCond(&n.mu) + go n.deliverLoop() + return n +} + +func (n *Notifier) OnNetworkChanged(routes string) { + n.enqueue(event{kind: eventRoutes, payload: routes}) +} + +func (n *Notifier) SetInterfaceIP(ip string) { + n.enqueue(event{kind: eventIfaceIP, payload: ip}) +} + +func (n *Notifier) SetInterfaceIPv6(ip string) { + n.enqueue(event{kind: eventIfaceIPv6, payload: ip}) +} + +func (n *Notifier) ApplyDns(config string) { + n.enqueue(event{kind: eventDNS, payload: config}) +} + +// Close stops accepting new events and blocks until the delivery loop has +// drained all queued events and exited. +func (n *Notifier) Close() { + n.mu.Lock() + n.closed = true + n.cond.Signal() + n.mu.Unlock() + <-n.done +} + +func (n *Notifier) enqueue(ev event) { + n.mu.Lock() + defer n.mu.Unlock() + if n.closed { + return + } + n.queue.PushBack(ev) + n.cond.Signal() +} + +func (n *Notifier) deliverLoop() { + defer close(n.done) + 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 + } + ev := n.queue.Remove(n.queue.Front()).(event) + l := n.listener + dm := n.dnsManager + n.mu.Unlock() + + switch ev.kind { + case eventRoutes: + if l != nil { + l.OnNetworkChanged(ev.payload) + } + case eventIfaceIP: + if l != nil { + l.SetInterfaceIP(ev.payload) + } + case eventIfaceIPv6: + if l != nil { + l.SetInterfaceIPv6(ev.payload) + } + case eventDNS: + if dm != nil { + dm.ApplyDns(ev.payload) + } + } + } +} diff --git a/client/internal/tunnelnotifier/notifier_test.go b/client/internal/tunnelnotifier/notifier_test.go new file mode 100644 index 000000000..ffbcdc15c --- /dev/null +++ b/client/internal/tunnelnotifier/notifier_test.go @@ -0,0 +1,192 @@ +package tunnelnotifier + +import ( + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type call struct { + kind string + payload string +} + +type recorder struct { + mu sync.Mutex + calls []call + inFlight atomic.Int32 + overlap atomic.Bool + delay time.Duration +} + +func (r *recorder) record(kind, payload string) { + if r.inFlight.Add(1) != 1 { + r.overlap.Store(true) + } + if r.delay > 0 { + time.Sleep(r.delay) + } + r.mu.Lock() + r.calls = append(r.calls, call{kind: kind, payload: payload}) + r.mu.Unlock() + r.inFlight.Add(-1) +} + +func (r *recorder) count() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.calls) +} + +func (r *recorder) snapshot() []call { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]call, len(r.calls)) + copy(out, r.calls) + return out +} + +type fakeListener struct { + rec *recorder +} + +func (f *fakeListener) OnNetworkChanged(routes string) { + f.rec.record("routes", routes) +} + +func (f *fakeListener) SetInterfaceIP(ip string) { + f.rec.record("ip", ip) +} + +func (f *fakeListener) SetInterfaceIPv6(ip string) { + f.rec.record("ipv6", ip) +} + +type fakeDNSManager struct { + rec *recorder +} + +func (f *fakeDNSManager) ApplyDns(config string) { + f.rec.record("dns", config) +} + +func TestFIFOOrder(t *testing.T) { + rec := &recorder{} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + n.SetInterfaceIP("10.0.0.1") + n.SetInterfaceIPv6("fd00::1") + n.ApplyDns(`{"domains":[]}`) + n.OnNetworkChanged("10.0.0.0/8,192.168.0.0/16") + n.ApplyDns(`{"domains":["example.com"]}`) + + require.Eventually(t, func() bool { return rec.count() == 5 }, time.Second, time.Millisecond) + + expected := []call{ + {kind: "ip", payload: "10.0.0.1"}, + {kind: "ipv6", payload: "fd00::1"}, + {kind: "dns", payload: `{"domains":[]}`}, + {kind: "routes", payload: "10.0.0.0/8,192.168.0.0/16"}, + {kind: "dns", payload: `{"domains":["example.com"]}`}, + } + assert.Equal(t, expected, rec.snapshot()) +} + +func TestNoOverlappingCalls(t *testing.T) { + rec := &recorder{delay: 100 * time.Microsecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + const producers = 8 + const perProducer = 25 + + var wg sync.WaitGroup + for i := 0; i < producers; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < perProducer; j++ { + payload := fmt.Sprintf("%d-%d", id, j) + switch j % 4 { + case 0: + n.OnNetworkChanged(payload) + case 1: + n.SetInterfaceIP(payload) + case 2: + n.SetInterfaceIPv6(payload) + case 3: + n.ApplyDns(payload) + } + } + }(i) + } + wg.Wait() + + require.Eventually(t, func() bool { return rec.count() == producers*perProducer }, 5*time.Second, time.Millisecond) + assert.False(t, rec.overlap.Load()) +} + +func TestDNSAndRoutesInterleaved(t *testing.T) { + rec := &recorder{delay: 100 * time.Microsecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + defer n.Close() + + const events = 50 + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < events; i++ { + n.ApplyDns(fmt.Sprintf("dns-%d", i)) + } + }() + go func() { + defer wg.Done() + for i := 0; i < events; i++ { + n.OnNetworkChanged(fmt.Sprintf("routes-%d", i)) + } + }() + wg.Wait() + + require.Eventually(t, func() bool { return rec.count() == 2*events }, 5*time.Second, time.Millisecond) + assert.False(t, rec.overlap.Load()) + + var dnsSeen, routesSeen int + for _, c := range rec.snapshot() { + switch c.kind { + case "dns": + assert.Equal(t, fmt.Sprintf("dns-%d", dnsSeen), c.payload) + dnsSeen++ + case "routes": + assert.Equal(t, fmt.Sprintf("routes-%d", routesSeen), c.payload) + routesSeen++ + } + } + assert.Equal(t, events, dnsSeen) + assert.Equal(t, events, routesSeen) +} + +func TestCloseDrainsQueue(t *testing.T) { + rec := &recorder{delay: time.Millisecond} + n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec}) + + const events = 20 + for i := 0; i < events; i++ { + n.OnNetworkChanged(fmt.Sprintf("routes-%d", i)) + } + n.Close() + + require.Equal(t, events, rec.count(), "Close must not return before all queued events are delivered") + + n.OnNetworkChanged("after-close") + n.ApplyDns("after-close") + time.Sleep(50 * time.Millisecond) + assert.Equal(t, events, rec.count()) +} diff --git a/client/internal/updater/installer/doc.go b/client/internal/updater/installer/doc.go index 0a60454bb..11b0512ac 100644 --- a/client/internal/updater/installer/doc.go +++ b/client/internal/updater/installer/doc.go @@ -37,23 +37,32 @@ // Updater Process (Setup): // // 1. Receives parameters from service via command-line arguments -// 2. Runs installer with appropriate silent/quiet flags: +// 2. Terminates the UI so the installer does not have to replace a locked image +// file, which would otherwise leave the install needing a reboot +// 3. Runs installer with appropriate silent/quiet flags: // - Windows EXE: installer.exe /S -// - Windows MSI: msiexec.exe /i installer.msi /quiet /qn /l*v msi.log +// - Windows MSI: msiexec.exe /i installer.msi /qn /norestart REBOOT=ReallySuppress /l*v msi.log // - macOS PKG: installer -pkg installer.pkg -target / // - macOS Homebrew: brew upgrade netbirdio/tap/netbird -// 3. Installer terminates daemon and UI processes -// 4. Installer replaces binaries with new version -// 5. Updater waits for installer to complete -// 6. Updater restarts daemon: +// 4. Installer terminates the daemon +// 5. Installer replaces binaries with new version +// 6. Updater waits for installer to complete. On Windows, MSI exit codes 3010 +// (ERROR_SUCCESS_REBOOT_REQUIRED) and 1641 (ERROR_SUCCESS_REBOOT_INITIATED) +// are a pending-reboot outcome, not a failure: the install succeeded, but +// some files are only replaced on the next restart (the reboot itself is +// suppressed via /norestart and REBOOT=ReallySuppress), and the flow +// continues as on success +// 7. Updater restarts daemon: // - Windows: netbird.exe service start // - macOS/Linux: netbird service start -// 7. Updater restarts UI: -// - Windows: Launches netbird-ui.exe as active console user using CreateProcessAsUser +// 8. Updater restarts UI: +// - Windows: Launches netbird-ui.exe using CreateProcessAsUser in every +// session it was terminated in, falling back to the active console session // - macOS: Uses launchctl asuser to launch NetBird.app for console user // - Linux: Not implemented (UI typically auto-starts) -// 8. Updater writes result.json with success/error status -// 9. Updater process exits +// 9. Updater writes result.json with success/error status (a pending reboot is +// recorded as success) +// 10. Updater process exits // // # Result Communication // diff --git a/client/internal/updater/installer/installer_common.go b/client/internal/updater/installer/installer_common.go index 8e44bee82..17566f7de 100644 --- a/client/internal/updater/installer/installer_common.go +++ b/client/internal/updater/installer/installer_common.go @@ -42,6 +42,9 @@ func NewWithDir(tempDir string) *Installer { // This will run by the original service process func (u *Installer) RunInstallation(ctx context.Context, targetVersion string) (err error) { resultHandler := NewResultHandler(u.tempDir) + if err := resultHandler.ClearStaleResult(); err != nil { + log.Warnf("clear stale installer result: %v", err) + } defer func() { if err != nil { diff --git a/client/internal/updater/installer/installer_run_darwin.go b/client/internal/updater/installer/installer_run_darwin.go index 248a404aa..5650bc769 100644 --- a/client/internal/updater/installer/installer_run_darwin.go +++ b/client/internal/updater/installer/installer_run_darwin.go @@ -98,47 +98,44 @@ func (u *Installer) startDaemon(daemonFolder string) error { func (u *Installer) startUIAsUser() error { log.Infof("starting netbird-ui: %s", uiBinary) - // Get the current console user - cmd := exec.Command("stat", "-f", "%Su", "/dev/console") - output, err := cmd.Output() + username, err := consoleUser() if err != nil { - return fmt.Errorf("failed to get console user: %w", err) + return err } - username := strings.TrimSpace(string(output)) - if username == "" || username == "root" { - return fmt.Errorf("no active user session found") - } - - log.Infof("starting UI for user: %s", username) - - // Get user's UID userInfo, err := user.Lookup(username) if err != nil { - return fmt.Errorf("failed to lookup user %s: %w", username, err) + return fmt.Errorf("lookup user %s: %w", username, err) } - // Start the UI process as the console user using launchctl - // This ensures the app runs in the user's context with proper GUI access - launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "open", "-a", uiBinary) + log.Infof("starting UI for user: %s (uid %s)", username, userInfo.Uid) + + launchCmd := exec.Command("launchctl", "asuser", userInfo.Uid, "sudo", "-u", username, "-H", "open", "-a", uiBinary) log.Infof("launchCmd: %s", launchCmd.String()) - // Set the user's home directory for proper macOS app behavior - launchCmd.Env = append(os.Environ(), "HOME="+userInfo.HomeDir) - log.Infof("set HOME environment variable: %s", userInfo.HomeDir) - if err := launchCmd.Start(); err != nil { - return fmt.Errorf("failed to start UI process: %w", err) - } - - // Release the process so it can run independently - if err := launchCmd.Process.Release(); err != nil { - log.Warnf("failed to release UI process: %v", err) + if err := launchCmd.Run(); err != nil { + return fmt.Errorf("run UI launch: %w", err) } log.Infof("netbird-ui started successfully for user %s", username) return nil } +func consoleUser() (string, error) { + output, err := exec.Command("stat", "-f", "%Su", "/dev/console").Output() + if err != nil { + return "", fmt.Errorf("get console user: %w", err) + } + + username := strings.TrimSpace(string(output)) + switch username { + case "", "root", "loginwindow", "_mbsetupuser": + return "", fmt.Errorf("no active GUI user session, console user: %q", username) + } + + return username, nil +} + func (u *Installer) installPkgFile(ctx context.Context, path string) error { log.Infof("installing pkg file: %s", path) diff --git a/client/internal/updater/installer/installer_run_windows.go b/client/internal/updater/installer/installer_run_windows.go index 70c7e32cf..b2ecf3299 100644 --- a/client/internal/updater/installer/installer_run_windows.go +++ b/client/internal/updater/installer/installer_run_windows.go @@ -2,6 +2,7 @@ package installer import ( "context" + "errors" "fmt" "os" "os/exec" @@ -22,6 +23,12 @@ const ( msiLogFile = "msi.log" + // ERROR_SUCCESS_REBOOT_REQUIRED and ERROR_SUCCESS_REBOOT_INITIATED + msiRebootRequired = 3010 + msiRebootInitiated = 1641 + + processExitWait = 10 * time.Second + msiDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_installer_%version_windows_%arch.msi" exeDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_installer_%version_windows_%arch.exe" ) @@ -38,6 +45,8 @@ var ( func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string, daemonFolder string) (resultErr error) { resultHandler := NewResultHandler(u.tempDir) + var uiSessions []uint32 + // Always ensure daemon and UI are restarted after setup defer func() { log.Infof("starting daemon back") @@ -46,7 +55,7 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string } log.Infof("starting UI back") - if err := u.startUIAsUser(daemonFolder); err != nil { + if err := u.startUI(daemonFolder, uiSessions); err != nil { log.Errorf("failed to start UI: %v", err) } @@ -75,6 +84,14 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string return } + // The UI holds an open handle on its own image. Left running, Restart Manager + // cannot shut it down (msiexec runs as LocalSystem here, the UI as the + // interactive user), so the MSI falls back to replacing the file on reboot and + // marks the install as restart-required. The deferred close-application action + // in the package runs too late to prevent that, it happens after + // InstallValidate has already registered the file as in use. + uiSessions = killUI() + var cmd *exec.Cmd switch installerType { case TypeExe: @@ -84,7 +101,9 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string installerDir := filepath.Dir(installerFile) logPath := filepath.Join(installerDir, msiLogFile) log.Infof("run msi installer: %s", installerFile) - cmd = exec.CommandContext(ctx, "msiexec.exe", "/i", filepath.Base(installerFile), "/quiet", "/qn", "/l*v", logPath) + // REBOOT=ReallySuppress: a silent install has no way to ask, so without it + // msiexec reboots the machine on its own if it decides one is needed. + cmd = exec.CommandContext(ctx, "msiexec.exe", "/i", filepath.Base(installerFile), "/qn", "/norestart", "REBOOT=ReallySuppress", "/l*v", logPath) } cmd.Dir = filepath.Dir(installerFile) @@ -95,9 +114,13 @@ func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string } log.Infof("installer started with PID %d", cmd.Process.Pid) - if resultErr = cmd.Wait(); resultErr != nil { - log.Errorf("installer process finished with error: %v", resultErr) - return + if err := cmd.Wait(); err != nil { + if !isRebootPending(err) { + resultErr = err + log.Errorf("installer process finished with error: %v", err) + return + } + log.Warnf("installer completed but reported a pending reboot, some files will be replaced on the next restart") } return nil @@ -117,16 +140,142 @@ func (u *Installer) startDaemon(daemonFolder string) error { return nil } -func (u *Installer) startUIAsUser(daemonFolder string) error { +func (u *Installer) startUI(daemonFolder string, sessionIDs []uint32) error { uiPath := filepath.Join(daemonFolder, uiName) log.Infof("starting netbird-ui: %s", uiPath) - // Get the active console session ID - sessionID := windows.WTSGetActiveConsoleSessionId() - if sessionID == 0xFFFFFFFF { - return fmt.Errorf("no active user session found") + if len(sessionIDs) == 0 { + sessionID := windows.WTSGetActiveConsoleSessionId() + if sessionID == 0xFFFFFFFF { + return fmt.Errorf("no active user session found") + } + sessionIDs = []uint32{sessionID} } + var errs []error + for _, sessionID := range sessionIDs { + if err := startUIInSession(uiPath, sessionID); err != nil { + errs = append(errs, fmt.Errorf("session %d: %w", sessionID, err)) + continue + } + log.Infof("netbird-ui started successfully in session %d", sessionID) + } + return errors.Join(errs...) +} + +// isRebootPending reports whether the installer exit code means it succeeded but +// left work for the next restart. The reboot itself is suppressed, so this is not +// a failure. +func isRebootPending(err error) bool { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + return false + } + + switch exitErr.ExitCode() { + case msiRebootRequired, msiRebootInitiated: + return true + default: + return false + } +} + +// killUI terminates any running netbird-ui process and returns the IDs of the +// interactive sessions the terminated processes belonged to. Setup starts the +// UI again in those sessions once the installer is done. +func killUI() []uint32 { + pids, err := processIDsByName(uiName) + if err != nil { + log.Warnf("failed to look up %s processes: %v", uiName, err) + return nil + } + + sessions := make(map[uint32]struct{}) + for _, pid := range pids { + var sessionID uint32 + if err := windows.ProcessIdToSessionId(pid, &sessionID); err != nil { + log.Warnf("failed to look up session of %s (PID %d): %v", uiName, pid, err) + } + + if err := terminateProcess(pid); err != nil { + log.Warnf("failed to terminate %s (PID %d): %v", uiName, pid, err) + continue + } + log.Infof("terminated %s (PID %d) in session %d", uiName, pid, sessionID) + + if sessionID != 0 { + sessions[sessionID] = struct{}{} + } + } + + sessionIDs := make([]uint32, 0, len(sessions)) + for sessionID := range sessions { + sessionIDs = append(sessionIDs, sessionID) + } + return sessionIDs +} + +func processIDsByName(name string) ([]uint32, error) { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, fmt.Errorf("create process snapshot: %w", err) + } + defer func() { + if err := windows.CloseHandle(snapshot); err != nil { + log.Warnf("failed to close process snapshot: %v", err) + } + }() + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + + var pids []uint32 + for err = windows.Process32First(snapshot, &entry); err == nil; err = windows.Process32Next(snapshot, &entry) { + if strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), name) { + pids = append(pids, entry.ProcessID) + } + } + if !errors.Is(err, windows.ERROR_NO_MORE_FILES) { + return nil, fmt.Errorf("enumerate processes: %w", err) + } + + return pids, nil +} + +func terminateProcess(pid uint32) error { + handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid) + if err != nil { + // The process may have exited between enumeration and now. + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return nil + } + return fmt.Errorf("open process: %w", err) + } + defer func() { + if err := windows.CloseHandle(handle); err != nil { + log.Warnf("failed to close process handle: %v", err) + } + }() + + if err := windows.TerminateProcess(handle, 0); err != nil { + return fmt.Errorf("terminate process: %w", err) + } + + // Wait for the handle to signal so the image file is released before the + // installer tries to overwrite it. A timeout is reported through the returned + // event, not through err, which stays nil unless the wait itself failed. + event, err := windows.WaitForSingleObject(handle, uint32(processExitWait.Milliseconds())) + if err != nil { + return fmt.Errorf("wait for process exit: %w", err) + } + if event != windows.WAIT_OBJECT_0 { + return fmt.Errorf("wait for process exit: unexpected wait result %#x", event) + } + + return nil +} + +func startUIInSession(uiPath string, sessionID uint32) error { // Get the user token for that session var userToken windows.Token err := windows.WTSQueryUserToken(sessionID, &userToken) @@ -158,6 +307,16 @@ func (u *Installer) startUIAsUser(daemonFolder string) error { } }() + var env *uint16 + if err := windows.CreateEnvironmentBlock(&env, primaryToken, false); err != nil { + return fmt.Errorf("create environment block: %w", err) + } + defer func() { + if err := windows.DestroyEnvironmentBlock(env); err != nil { + log.Warnf("failed to destroy environment block: %v", err) + } + }() + // Prepare startup info var si windows.StartupInfo si.Cb = uint32(unsafe.Sizeof(si)) @@ -180,7 +339,7 @@ func (u *Installer) startUIAsUser(daemonFolder string) error { nil, false, creationFlags, - nil, + env, nil, &si, &pi, @@ -197,7 +356,6 @@ func (u *Installer) startUIAsUser(daemonFolder string) error { log.Warnf("failed to close thread handle: %v", err) } - log.Infof("netbird-ui started successfully in session %d", sessionID) return nil } diff --git a/client/internal/updater/installer/installer_run_windows_test.go b/client/internal/updater/installer/installer_run_windows_test.go new file mode 100644 index 000000000..6a4540610 --- /dev/null +++ b/client/internal/updater/installer/installer_run_windows_test.go @@ -0,0 +1,108 @@ +package installer + +import ( + "errors" + "os/exec" + "slices" + "strconv" + "testing" +) + +// exitErrorWithCode returns a real *exec.ExitError carrying the given exit code. +func exitErrorWithCode(t *testing.T, code int) error { + t.Helper() + + err := exec.Command("cmd.exe", "/c", "exit "+strconv.Itoa(code)).Run() + if err == nil { + t.Fatalf("expected a non-zero exit for code %d", code) + } + return err +} + +func TestIsRebootPending(t *testing.T) { + tests := []struct { + name string + code int + want bool + }{ + {name: "reboot required", code: msiRebootRequired, want: true}, + {name: "reboot initiated", code: msiRebootInitiated, want: true}, + {name: "generic failure", code: 1603, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isRebootPending(exitErrorWithCode(t, tt.code)); got != tt.want { + t.Errorf("isRebootPending(exit %d) = %v, want %v", tt.code, got, tt.want) + } + }) + } +} + +// TestProcessIDsByNameAndTerminate spawns a long-running system process, finds it +// by name and terminates it, covering the path the updater uses to release the UI +// image file before the installer replaces it. +func TestProcessIDsByNameAndTerminate(t *testing.T) { + cmd := exec.Command("ping.exe", "-n", "60", "127.0.0.1") + if err := cmd.Start(); err != nil { + t.Fatalf("start ping: %v", err) + } + + pid := uint32(cmd.Process.Pid) + killed := false + t.Cleanup(func() { + if !killed { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + // Name matching must be case-insensitive: the snapshot reports PING.EXE. + pids, err := processIDsByName("ping.exe") + if err != nil { + t.Fatalf("processIDsByName: %v", err) + } + + if !slices.Contains(pids, pid) { + t.Fatalf("PID %d not among the ping.exe processes found: %v", pid, pids) + } + + if err := terminateProcess(pid); err != nil { + t.Fatalf("terminateProcess: %v", err) + } + killed = true + + // terminateProcess only returns once the handle has signalled, so the process + // is already gone and Wait must not block. It exits with the code passed to + // TerminateProcess, which is 0, so Wait reports no error. + if err := cmd.Wait(); err != nil { + t.Fatalf("wait for terminated ping: %v", err) + } + if !cmd.ProcessState.Exited() { + t.Error("process did not exit after terminateProcess") + } + + remaining, err := processIDsByName("ping.exe") + if err != nil { + t.Fatalf("processIDsByName after terminate: %v", err) + } + if slices.Contains(remaining, pid) { + t.Errorf("PID %d still listed after terminateProcess", pid) + } +} + +func TestProcessIDsByNameNoMatch(t *testing.T) { + pids, err := processIDsByName("netbird-nonexistent-process.exe") + if err != nil { + t.Fatalf("processIDsByName: %v", err) + } + if len(pids) != 0 { + t.Errorf("expected no matches, got %v", pids) + } +} + +func TestIsRebootPendingNonExitError(t *testing.T) { + if isRebootPending(errors.New("start installer: file not found")) { + t.Error("a non-exit error must not be treated as a pending reboot") + } +} diff --git a/client/internal/updater/installer/result.go b/client/internal/updater/installer/result.go index 526c3eb53..55a0d8ac8 100644 --- a/client/internal/updater/installer/result.go +++ b/client/internal/updater/installer/result.go @@ -54,6 +54,12 @@ func (rh *ResultHandler) GetErrorResultReason() string { return "" } +// ClearStaleResult removes a result file left over from a previous installation +// attempt so result watchers cannot read an outdated outcome for the current attempt. +func (rh *ResultHandler) ClearStaleResult() error { + return rh.cleanup() +} + func (rh *ResultHandler) WriteSuccess() error { result := Result{ Success: true, diff --git a/client/internal/updater/manager.go b/client/internal/updater/manager.go index 7fc300739..1b69368d0 100644 --- a/client/internal/updater/manager.go +++ b/client/internal/updater/manager.go @@ -435,7 +435,7 @@ func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error } inst := installer.New() - if err := inst.RunInstallation(ctx, pendingVersion.String()); err != nil { + if err := inst.RunInstallation(ctx, pendingVersion.String()); err != nil { //nolint:staticcheck // always errors on platforms without an installer log.Errorf("error triggering update: %v", err) m.statusRecorder.PublishEvent( cProto.SystemEvent_ERROR, diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 359a83556..f92f085ab 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -13,8 +13,8 @@ import ( "time" log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" + nbAnonymize "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/debug" @@ -22,6 +22,8 @@ import ( "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/netstate" + "github.com/netbirdio/netbird/client/netsweep" "github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/formatter" "github.com/netbirdio/netbird/route" @@ -29,10 +31,12 @@ import ( types "github.com/netbirdio/netbird/upload-server/types" ) -// ConnectionListener export internal Listener for mobile -type ConnectionListener interface { - peer.Listener -} +// AnonymizeLevelDefault and AnonymizeLevelStrict are the accepted +// anonymizeLevel values for DebugBundle. +const ( + AnonymizeLevelDefault = nbAnonymize.LevelDefaultString + AnonymizeLevelStrict = nbAnonymize.LevelStrictString +) // RouteListener export internal RouteListener for mobile type NetworkChangeListener interface { @@ -80,6 +84,12 @@ type Client struct { onHostDnsFn func([]string) dnsManager dns.IosDnsManager loginComplete bool + // netState outlives engine restarts: it mirrors the OS connectivity, not + // the engine lifecycle. Run injects it into each new ConnectClient, which + // distributes it to every reconnection loop. + netState *netstate.State + // sweeper also outlives engine restarts; NotifyNetworkChange sweeps it. + sweeper *netsweep.Sweeper // preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked) preloadedConfig *profilemanager.Config @@ -102,6 +112,8 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV ctxCancelLock: &sync.Mutex{}, networkChangeListener: networkChangeListener, dnsManager: dnsManager, + netState: netstate.New(), + sweeper: netsweep.New(), } } @@ -159,19 +171,26 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { defer c.ctxCancel() c.ctxCancelLock.Unlock() - auth := NewAuthWithConfig(ctx, cfg) - err = auth.LoginSync() - if err != nil { - return err - } - - log.Infof("Auth successful") + // No login pre-flight here. The engine's own loginToManagement (connect.go) performs + // the authoritative Login immediately before the first Sync, so a LoginSync() call at + // this point only duplicated it — costing two extra Login RPCs (IsLoginRequired + + // Login) on every engine start, since IsLoginRequired is itself a full Login RPC. + // + // Auth failures still reach the caller through the engine path: loginToManagement + // returns PermissionDenied, which marks the shared status recorder + // (MarkManagementDisconnected) and fires ClientStop → onDisconnected, where + // IsLoginRequiredCached() reports login-required. The error is also returned out of Run(). + // + // A pre-flight was also actively harmful when the server is unreachable: its 2-minute + // backoff blocked the start and then reported "login required" for what was really a + // timeout. The engine instead keeps retrying and recovers when the server returns. // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) c.onHostDnsFn = func([]string) {} cfg.WgIface = interfaceName - connectClient := internal.NewConnectClient(ctx, cfg, c.recorder) + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, + internal.WithNetworkState(c.netState), internal.WithSweeper(c.sweeper)) c.setState(cfg, connectClient) // Persist the latest sync response so DebugBundle can include the network // map. On iOS this is backed by disk to keep it out of the constrained @@ -180,6 +199,25 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error { return connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile, c.cacheDir, c.logFilePath) } +// SetNetworkAvailable feeds OS-reported network availability into the client +// (e.g. from NWPathMonitor). While unavailable, the internal reconnect loops +// suspend their attempts and the connection listener reports NoNetwork +// instead of Connecting; when availability returns, the loops resume +// immediately with a fresh backoff. +func (c *Client) SetNetworkAvailable(available bool) { + c.netState.Set(available) + c.recorder.SetNetworkAvailable(available) +} + +// NotifyNetworkChange marks the management, signal and relay connections +// stale after the OS switched networks and schedules a sweep that cuts +// whatever has not redialed on the new network by then. The engine and the +// TUN device stay untouched. +func (c *Client) NotifyNetworkChange() { + c.sweeper.MarkNetworkChange() + log.Infof("network change: connections marked stale") +} + // Stop the internal client and free the resources func (c *Client) Stop() { c.ctxCancelLock.Lock() @@ -195,8 +233,10 @@ func (c *Client) Stop() { // DebugBundle generates a debug bundle, uploads it and returns the upload key. // It works with or without a running engine: when the engine is up it reuses // the live config, sync response and client metrics; otherwise it loads the -// config from disk (or the preloaded tvOS config). -func (c *Client) DebugBundle(anonymize bool) (string, error) { +// config from disk (or the preloaded tvOS config). anonymizeLevel is "default" +// or "strict"; strict also anonymizes internal IP ranges, peer names, and +// WireGuard public keys, and implies anonymize. +func (c *Client) DebugBundle(anonymize bool, anonymizeLevel string) (string, error) { cfg, cc := c.stateSnapshot() // If the engine hasn't been started, load config so we can reach management. @@ -233,6 +273,9 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) { deps.SyncResponse = resp if e := cc.Engine(); e != nil { + deps.RefreshStatus = func() { + e.RunHealthProbes(context.Background(), true) + } if cm := e.GetClientMetrics(); cm != nil { deps.ClientMetrics = cm } @@ -243,6 +286,7 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) { deps, debug.BundleConfig{ Anonymize: anonymize, + AnonymizeLevel: nbAnonymize.ParseLevel(anonymizeLevel), IncludeSystemInfo: true, }, ) @@ -260,7 +304,7 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) { uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path) + key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false) if err != nil { return "", fmt.Errorf("upload debug bundle: %w", err) } @@ -312,7 +356,11 @@ func (c *Client) GetStatusDetails() *StatusDetails { // SetConnectionListener set the network connection listener func (c *Client) SetConnectionListener(listener ConnectionListener) { - c.recorder.SetConnectionListener(listener) + if listener == nil { + c.recorder.RemoveConnectionListener() + return + } + c.recorder.SetConnectionListener(connectionListenerAdapter{listener}) } // RemoveConnectionListener remove connection listener @@ -634,23 +682,18 @@ func (c *Client) SelectRoute(id string) error { } routeManager := engine.GetRouteManager() - routeSelector := routeManager.GetRouteSelector() if id == "All" { log.Debugf("select all routes") - routeSelector.SelectAllRoutes() - } else { - log.Debugf("select route with id: %s", id) - routes := toNetIDs([]string{id}) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - if err := routeSelector.SelectRoutes(routes, true, maps.Keys(routesMap)); err != nil { - log.Debugf("error when selecting routes: %s", err) - return fmt.Errorf("select routes: %w", err) - } + routeManager.SelectAllRoutes() + return nil } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) - return nil + log.Debugf("select route with id: %s", id) + if err := routeManager.SelectRoutes(toNetIDs([]string{id}), true); err != nil { + log.Debugf("error when selecting routes: %s", err) + return err + } + return nil } func (c *Client) DeselectRoute(id string) error { @@ -664,21 +707,17 @@ func (c *Client) DeselectRoute(id string) error { } routeManager := engine.GetRouteManager() - routeSelector := routeManager.GetRouteSelector() if id == "All" { log.Debugf("deselect all routes") - routeSelector.DeselectAllRoutes() - } else { - log.Debugf("deselect route with id: %s", id) - routes := toNetIDs([]string{id}) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - if err := routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil { - log.Debugf("error when deselecting routes: %s", err) - return fmt.Errorf("deselect routes: %w", err) - } + routeManager.DeselectAllRoutes() + return nil + } + + log.Debugf("deselect route with id: %s", id) + if err := routeManager.DeselectRoutes(toNetIDs([]string{id})); err != nil { + log.Debugf("error when deselecting routes: %s", err) + return err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) return nil } diff --git a/client/ios/NetBirdSDK/connection_listener.go b/client/ios/NetBirdSDK/connection_listener.go new file mode 100644 index 000000000..d792537ba --- /dev/null +++ b/client/ios/NetBirdSDK/connection_listener.go @@ -0,0 +1,43 @@ +//go:build ios + +package NetBirdSDK + +import ( + "github.com/netbirdio/netbird/client/internal/peer" +) + +// Client state values, re-exported as basic constants so gomobile emits them +// into the generated bindings. They mirror peer.ClientState*: append-only, +// never reorder. +const ( + ClientStateDisconnected = int(peer.ClientStateDisconnected) + ClientStateConnected = int(peer.ClientStateConnected) + ClientStateConnecting = int(peer.ClientStateConnecting) + ClientStateDisconnecting = int(peer.ClientStateDisconnecting) + ClientStateNoNetwork = int(peer.ClientStateNoNetwork) +) + +// ConnectionListener export internal Listener for mobile. +// +// It intentionally lacks OnStateChanged for now: adding a method to a gomobile +// interface breaks every Swift implementation, so the iOS app keeps building +// against the legacy per-state callbacks. A follow-up will extend it together +// with the app. +type ConnectionListener interface { + OnConnected() + OnDisconnected() + OnConnecting() + OnDisconnecting() + OnAddressChanged(string, string) + OnPeersListChanged(int) +} + +// connectionListenerAdapter adapts the gomobile-facing ConnectionListener to +// peer.Listener. +type connectionListenerAdapter struct { + ConnectionListener +} + +// OnStateChanged is dropped on iOS until the app adopts the state callback; +// the legacy per-state callbacks continue to fire. +func (a connectionListenerAdapter) OnStateChanged(peer.ClientState) {} diff --git a/client/ios/NetBirdSDK/env_list.go b/client/ios/NetBirdSDK/env_list.go index 88ac97957..a3ffa0ebe 100644 --- a/client/ios/NetBirdSDK/env_list.go +++ b/client/ios/NetBirdSDK/env_list.go @@ -38,7 +38,7 @@ func GetEnvKeyNBForceRelay() string { // GetEnvKeyNBLazyConn Exports the environment variable for the iOS client func GetEnvKeyNBLazyConn() string { - return lazyconn.EnvEnableLazyConn + return lazyconn.EnvLazyConn } // GetEnvKeyNBInactivityThreshold Exports the environment variable for the iOS client diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 5e9b8fe1b..42a575359 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 } @@ -43,22 +44,42 @@ type Auth struct { // NewAuth instantiate Auth struct and validate the management URL func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { inputCfg := profilemanager.ConfigInput{ + ConfigPath: cfgPath, ManagementURL: mgmURL, } - cfg, err := profilemanager.CreateInMemoryConfig(inputCfg) + // Load the existing config when a config file is already present so an + // interactive re-login reuses the peer's persisted WireGuard private key + // (and thus its identity) instead of generating a fresh one. Generating a + // new key registers a brand-new peer on the management server on every + // re-auth (named after the fallback hostname). Only fall back to a fresh + // in-memory config for the first-time login when no config file exists yet. + // DirectUpdateOrCreateConfig uses non-atomic writes so it also works inside + // the tvOS App Group sandbox where atomic temp-file+rename is blocked. + var cfg *profilemanager.Config + var err error + if cfgPath != "" { + cfg, err = profilemanager.DirectUpdateOrCreateConfig(inputCfg) + } else { + cfg, err = profilemanager.CreateInMemoryConfig(inputCfg) + } if err != nil { return nil, err } - // Preserve the existing profile config (name, keys) and override only the management URL. - if existing, err := profilemanager.GetConfig(cfgPath); err == nil { - existing.ManagementURL = cfg.ManagementURL - cfg = existing - } + // 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 @@ -66,12 +87,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. @@ -189,17 +222,36 @@ func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener, forceDevic // LoginWithDeviceName performs interactive login with device authentication support // The deviceName parameter allows specifying a custom device name (required for tvOS) func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) { + a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, false) +} + +// LoginInteractive performs the same interactive login as LoginWithDeviceName but skips the +// IsLoginRequired() pre-flight and goes straight to the browser / device-code flow. +// +// IsLoginRequired() is itself a full Login RPC against the management server, so when the +// caller has ALREADY established that login is required it is a pure duplicate. On iOS the +// main app decides to show the browser based on its own isLoginRequired() check and then +// calls straight into this method, so re-asking the server would add another Login RPC to +// every interactive login. +// +// Use LoginWithDeviceName when the auth state is unknown and a silent (browser-less) login +// must still be possible; use this when the browser is going to be shown regardless. +func (a *Auth) LoginInteractive(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string) { + a.startLogin(resultListener, urlOpener, forceDeviceAuth, deviceName, true) +} + +func (a *Auth) startLogin(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) { if resultListener == nil { - log.Errorf("LoginWithDeviceName: resultListener is nil") + log.Errorf("startLogin: resultListener is nil") return } if urlOpener == nil { - log.Errorf("LoginWithDeviceName: urlOpener is nil") + log.Errorf("startLogin: urlOpener is nil") resultListener.OnError(fmt.Errorf("urlOpener is nil")) return } go func() { - err := a.login(urlOpener, forceDeviceAuth, deviceName) + err := a.login(urlOpener, forceDeviceAuth, deviceName, skipLoginCheck) if err != nil { resultListener.OnError(err) } else { @@ -208,7 +260,7 @@ func (a *Auth) LoginWithDeviceName(resultListener ErrListener, urlOpener URLOpen }() } -func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string) error { +func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string, skipLoginCheck bool) error { // Create context with device name if provided ctx := a.ctx if deviceName != "" { @@ -222,10 +274,13 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin } defer authClient.Close() - // check if we need to generate JWT token - needsLogin, err := authClient.IsLoginRequired(ctx) - if err != nil { - return fmt.Errorf("failed to check login requirement: %v", err) + // check if we need to generate JWT token (skipped when the caller already knows) + needsLogin := true + if !skipLoginCheck { + needsLogin, err = authClient.IsLoginRequired(ctx) + if err != nil { + return fmt.Errorf("failed to check login requirement: %v", err) + } } jwtToken := "" @@ -268,7 +323,7 @@ func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName strin const authInfoRequestTimeout = 30 * time.Second func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, forceDeviceAuth bool) (*auth.TokenInfo, error) { - oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth) + oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, forceDeviceAuth, "") if err != nil { return nil, fmt.Errorf("failed to get OAuth flow: %v", err) } diff --git a/client/ios/NetBirdSDK/version.go b/client/ios/NetBirdSDK/version.go new file mode 100644 index 000000000..606ad18e2 --- /dev/null +++ b/client/ios/NetBirdSDK/version.go @@ -0,0 +1,12 @@ +//go:build ios + +package NetBirdSDK + +import "github.com/netbirdio/netbird/version" + +// GoClientVersion returns the NetBird Go client version that was baked into +// the framework at compile time via +// -ldflags "-X github.com/netbirdio/netbird/version.version=". +func GoClientVersion() string { + return version.NetbirdVersion() +} diff --git a/client/jobexec/executor.go b/client/jobexec/executor.go index e29cc8840..9401acacc 100644 --- a/client/jobexec/executor.go +++ b/client/jobexec/executor.go @@ -54,7 +54,7 @@ func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug. } }() - key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path) + key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path, false) if err != nil { log.Errorf("failed to upload debug bundle: %v", err) return "", fmt.Errorf("upload debug bundle: %w", err) diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go index 6e7ab19cb..29288b511 100644 --- a/client/mdm/canonical_loaders.go +++ b/client/mdm/canonical_loaders.go @@ -15,18 +15,21 @@ var allKeys = []string{ KeyDisableUpdateSettings, KeyDisableProfiles, KeyDisableNetworks, + KeyDisableAdvancedView, KeyDisableClientRoutes, KeyDisableServerRoutes, KeyBlockInbound, KeyDisableMetricsCollection, KeyAllowServerSSH, KeyDisableAutoConnect, + KeyDisableAutostart, KeyPreSharedKey, KeyRosenpassEnabled, KeyRosenpassPermissive, KeyWireguardPort, KeySplitTunnelMode, KeySplitTunnelApps, + KeyLazyConnection, } // canonicalKey maps the lowercase form of a managed-config value name to diff --git a/client/mdm/policy.go b/client/mdm/policy.go index 109fb322e..1feff28f8 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -11,6 +11,7 @@ package mdm import ( "sort" "strconv" + "strings" log "github.com/sirupsen/logrus" ) @@ -19,20 +20,33 @@ import ( // names (lowerCamelCase) so the daemon can map a Policy key directly to a // configuration field. const ( - KeyManagementURL = "managementURL" - KeyDisableUpdateSettings = "disableUpdateSettings" - KeyDisableProfiles = "disableProfiles" - KeyDisableNetworks = "disableNetworks" + KeyManagementURL = "managementURL" + KeyDisableUpdateSettings = "disableUpdateSettings" + KeyDisableProfiles = "disableProfiles" + KeyDisableNetworks = "disableNetworks" + // KeyDisableAdvancedView gates the advanced-view section in the + // upcoming UI revision. UI-only: NOT stored on Config, not + // applied by applyMDMPolicy, not rejectable via SetConfig. The + // daemon surfaces it through GetFeatures (tristate: present + // true / present false / absent) and the same key appears in + // GetConfigResponse.mDMManagedFields when set. + KeyDisableAdvancedView = "disableAdvancedView" KeyDisableClientRoutes = "disableClientRoutes" KeyDisableServerRoutes = "disableServerRoutes" KeyBlockInbound = "blockInbound" KeyDisableMetricsCollection = "disableMetricsCollection" KeyAllowServerSSH = "allowServerSSH" KeyDisableAutoConnect = "disableAutoConnect" - KeyPreSharedKey = "preSharedKey" - KeyRosenpassEnabled = "rosenpassEnabled" - KeyRosenpassPermissive = "rosenpassPermissive" - KeyWireguardPort = "wireguardPort" + // KeyDisableAutostart suppresses the GUI's fresh-install + // launch-on-login default and marks the Settings toggle as + // MDM-managed. UI-only: NOT stored on Config and not applied by + // applyMDMPolicy; the GUI reads it directly and it appears in + // GetConfigResponse.mDMManagedFields when set. + KeyDisableAutostart = "disableAutostart" + KeyPreSharedKey = "preSharedKey" + KeyRosenpassEnabled = "rosenpassEnabled" + KeyRosenpassPermissive = "rosenpassPermissive" + KeyWireguardPort = "wireguardPort" // Split tunnel is modeled as a single conceptual policy with two // registry/plist values. KeySplitTunnelMode is the discriminator @@ -41,6 +55,11 @@ const ( // construction — only one mode can be set at a time. KeySplitTunnelMode = "splitTunnelMode" KeySplitTunnelApps = "splitTunnelApps" + + // KeyLazyConnection forces the lazy-connection feature on or off, overriding + // the management feature flag. Read as a bool (native bool, or on/off, + // true/false, 1/0, yes/no); absent = defer to management. + KeyLazyConnection = "lazyConnection" ) // Split-tunnel mode literals (KeySplitTunnelMode values). @@ -62,12 +81,13 @@ var boolStringLiterals = map[string]bool{ "true": true, "1": true, "yes": true, + "on": true, "false": false, "0": false, "no": false, + "off": false, } - // Policy holds MDM-managed settings read from the platform source. A nil or // empty Policy means no enforcement is active. type Policy struct { @@ -150,7 +170,8 @@ func (p *Policy) GetString(key string) (string, bool) { } // GetBool returns the managed value for key coerced to bool, and whether the -// key was set. Accepts native bool and string literals "true"/"false"/"1"/"0". +// key was set. Accepts native bool and string literals (true/false, 1/0, +// yes/no, on/off), case-insensitively and trimmed of surrounding whitespace. func (p *Policy) GetBool(key string) (bool, bool) { if p == nil { return false, false @@ -163,7 +184,7 @@ func (p *Policy) GetBool(key string) (bool, bool) { case bool: return t, true case string: - b, known := boolStringLiterals[t] + b, known := boolStringLiterals[strings.ToLower(strings.TrimSpace(t))] return b, known case int: return t != 0, true diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go index 47a6ed2c9..6cbe69776 100644 --- a/client/mdm/policy_test.go +++ b/client/mdm/policy_test.go @@ -31,8 +31,8 @@ func TestPolicy_Empty(t *testing.T) { func TestPolicy_HasKey(t *testing.T) { p := NewPolicy(map[string]any{ - KeyManagementURL: "https://corp.example.com", - KeyDisableProfiles: true, + KeyManagementURL: "https://corp.example.com", + KeyDisableProfiles: true, }) assert.False(t, p.IsEmpty()) assert.True(t, p.HasKey(KeyManagementURL)) @@ -53,8 +53,8 @@ func TestPolicy_ManagedKeysSorted(t *testing.T) { func TestPolicy_GetString(t *testing.T) { p := NewPolicy(map[string]any{ KeyManagementURL: "https://corp.example.com", - KeyDisableProfiles: true, // wrong type for GetString - KeyPreSharedKey: "", // empty rejected + KeyDisableProfiles: true, // wrong type for GetString + KeyPreSharedKey: "", // empty rejected }) v, ok := p.GetString(KeyManagementURL) assert.True(t, ok) @@ -85,6 +85,11 @@ func TestPolicy_GetBool(t *testing.T) { {"string 0", "0", false, true}, {"string yes", "yes", true, true}, {"string no", "no", false, true}, + {"string on", "on", true, true}, + {"string off", "off", false, true}, + {"mixed case On", "On", true, true}, + {"upper TRUE", "TRUE", true, true}, + {"padded yes", " yes ", true, true}, {"int nonzero", 1, true, true}, {"int zero", 0, false, true}, {"int64 nonzero", int64(2), true, true}, diff --git a/client/netbird.wxs b/client/netbird.wxs index 6f18b63b5..f30a7aa7e 100644 --- a/client/netbird.wxs +++ b/client/netbird.wxs @@ -13,9 +13,6 @@ - - - @@ -32,9 +29,6 @@ - - - + + - - - - - - - - - + + + + + + + + + + + + + + + - + diff --git a/client/netstate/netstate.go b/client/netstate/netstate.go new file mode 100644 index 000000000..0d7a1268b --- /dev/null +++ b/client/netstate/netstate.go @@ -0,0 +1,110 @@ +// Package netstate tracks OS-reported network availability for the client. +// +// A State instance is owned by the platform integration (e.g. the Android or +// iOS bindings, fed from ConnectivityManager callbacks or NWPathMonitor) and +// is injected into the connection retry loops (management, signal, relay, +// peer guards and the top-level connect loop), which consult it to avoid +// burning CPU and battery on reconnect attempts while the device has no +// network at all (e.g. airplane mode), and to reset their backoff as soon as +// the network returns. +// +// Consumers hold a *State that may be nil — every non-mobile platform leaves +// it unset. The read methods are safe on a nil receiver: they report online +// and never block, so consumers behave as if this package did not exist. +package netstate + +import ( + "context" + "sync" + + log "github.com/sirupsen/logrus" +) + +// State holds the OS-reported network availability. The zero value is not +// usable; create instances with New. +type State struct { + mu sync.Mutex + online bool + changed chan struct{} +} + +// New creates a State that starts online. Platforms without network tracking +// pass a nil *State instead: the read methods treat nil as always online and +// never block, so consumers need no nil guards. +func New() *State { + return &State{ + online: true, + changed: make(chan struct{}), + } +} + +// Set records whether the OS reports any usable network. Transitions wake up +// all Wait callers immediately. Unlike the read methods, Set is not nil-safe: +// it is only for the platform owner that created the State with New. +func (s *State) Set(online bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.online == online { + return + } + s.online = online + close(s.changed) + s.changed = make(chan struct{}) + log.Infof("OS network availability changed: online=%t", online) +} + +// IsOnline reports whether the OS reports at least one usable network. On a +// nil receiver — no State injected — it reports online. +func (s *State) IsOnline() bool { + if s == nil { + return true + } + s.mu.Lock() + defer s.mu.Unlock() + return s.online +} + +// Changed returns a channel closed on the next availability transition, for +// callers that already own a select loop and cannot block in Wait. Re-read it +// after every fire: each transition installs a fresh channel. On a nil +// receiver — no State injected — it returns nil, which blocks forever in a +// select, so the caller simply never observes a transition. +func (s *State) Changed() <-chan struct{} { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.changed +} + +// Wait blocks while the network is offline. It reports whether it had to +// wait, so callers can reset their backoff after an outage. It returns early +// with the context error when ctx is done. On a nil receiver — no State +// injected — it returns immediately. +func (s *State) Wait(ctx context.Context) (bool, error) { + if s == nil { + return false, nil + } + waited := false + for { + s.mu.Lock() + if s.online { + s.mu.Unlock() + return waited, nil + } + ch := s.changed + s.mu.Unlock() + + if !waited { + waited = true + log.Debugf("network is offline, pausing connection attempts") + } + + select { + case <-ctx.Done(): + return waited, ctx.Err() + case <-ch: + } + } +} diff --git a/client/netstate/netstate_test.go b/client/netstate/netstate_test.go new file mode 100644 index 000000000..ea7015761 --- /dev/null +++ b/client/netstate/netstate_test.go @@ -0,0 +1,170 @@ +package netstate + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewStateIsOnline(t *testing.T) { + assert.True(t, New().IsOnline(), "a fresh State should start online") +} + +func TestSetTogglesOnlineState(t *testing.T) { + s := New() + + s.Set(false) + assert.False(t, s.IsOnline(), "state should be offline after Set(false)") + + s.Set(true) + assert.True(t, s.IsOnline(), "state should be online after Set(true)") +} + +func TestWaitReturnsImmediatelyWhenOnline(t *testing.T) { + s := New() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + waited, err := s.Wait(ctx) + require.NoError(t, err) + assert.False(t, waited, "Wait should not block when the network is online") +} + +func TestWaitBlocksUntilOnline(t *testing.T) { + s := New() + s.Set(false) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + result := make(chan bool, 1) + go func() { + waited, err := s.Wait(ctx) + if err != nil { + result <- false + return + } + result <- waited + }() + + // Verify Wait is actually blocking while offline + select { + case <-result: + t.Fatal("Wait should block while the network is offline") + case <-time.After(100 * time.Millisecond): + } + + s.Set(true) + + select { + case waited := <-result: + assert.True(t, waited, "Wait should report that it had to wait for the network") + case <-time.After(2 * time.Second): + t.Fatal("Wait should return promptly after the network becomes available") + } +} + +func TestWaitReturnsOnContextCancel(t *testing.T) { + s := New() + s.Set(false) + + ctx, cancel := context.WithCancel(context.Background()) + + result := make(chan error, 1) + go func() { + _, err := s.Wait(ctx) + result <- err + }() + + cancel() + + select { + case err := <-result: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(2 * time.Second): + t.Fatal("Wait should return promptly after context cancellation") + } +} + +func TestWaitWakesAllWaiters(t *testing.T) { + s := New() + s.Set(false) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + const waiters = 10 + var wg sync.WaitGroup + results := make(chan bool, waiters) + for i := 0; i < waiters; i++ { + wg.Add(1) + go func() { + defer wg.Done() + waited, err := s.Wait(ctx) + if err != nil { + results <- false + return + } + results <- waited + }() + } + + time.Sleep(100 * time.Millisecond) + s.Set(true) + wg.Wait() + + close(results) + count := 0 + for waited := range results { + assert.True(t, waited, "every waiter should report that it waited") + count++ + } + assert.Equal(t, waiters, count, "all waiters should have returned") +} + +func TestNilStateReadsAreNoops(t *testing.T) { + var s *State + + assert.True(t, s.IsOnline(), "nil State should report online") + + waited, err := s.Wait(context.Background()) + require.NoError(t, err) + assert.False(t, waited, "nil State's Wait should not block") +} + +func TestConcurrentSetAndWait(t *testing.T) { + s := New() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + s.Set(j%2 == 0) + s.IsOnline() + } + }() + } + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + if _, err := s.Wait(ctx); err != nil { + return + } + } + }() + } + + wg.Wait() +} diff --git a/client/netsweep/netsweep.go b/client/netsweep/netsweep.go new file mode 100644 index 000000000..46bc0a709 --- /dev/null +++ b/client/netsweep/netsweep.go @@ -0,0 +1,267 @@ +// Package netsweep cuts network-bound activity when the OS switches networks: +// a sweep closes the registered connections and aborts the in-flight dials, so +// their owners redial immediately instead of waiting for the old sockets to +// time out. +// +// A nil *Sweeper disables everything: all methods are nil-safe no-ops. +package netsweep + +import ( + "context" + "errors" + "net" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/netstate" +) + +// DefaultSweepDelay absorbs network flapping while the OS settles on a +// default network before the stale registrations are cut. +const DefaultSweepDelay = 500 * time.Millisecond + +const recentMarkWindow = 3 * time.Second + +// Config customizes a Sweeper. The zero value applies the defaults. +type Config struct { + // SweepDelay overrides DefaultSweepDelay when positive. + SweepDelay time.Duration +} + +// ErrSwept reports that a dial finished after a network change swept its +// registration. The connection is already closed; the caller must treat it +// as a failed dial and redial on the new network. +var ErrSwept = errors.New("netsweep: connection swept by network change") + +// sweepID identifies one registration in a sweeper. Connections and dials +// draw from the same counter, so an id is unique across both registries. +type sweepID uint64 + +type connEntry struct { + conn net.Conn + gen uint64 +} + +// Dial tracks one dial from start to connection registration. It hands the +// dialed connection to the sweeper atomically, so a sweep can never fall +// between the dial finishing and the connection being registered. +type Dial struct { + sweeper *Sweeper + ctx context.Context + cancel context.CancelFunc + id sweepID + done bool // set by a sweep, WrapConn or Release; guarded by sweeper.mu + gen uint64 +} + +// Ctx returns the dial's context. A sweep cancels it, so a dial started on the +// old network aborts instead of waiting out its handshake timeout. +func (d *Dial) Ctx() context.Context { + return d.ctx +} + +// Release ends the dial's registration and cancels its context. It is +// idempotent and safe after WrapConn, so callers can defer it. +func (d *Dial) Release() { + s := d.sweeper + if s == nil { + return + } + + s.mu.Lock() + d.done = true + delete(s.dials, d.id) + s.mu.Unlock() + + d.cancel() +} + +// sweptConn deregisters itself from the sweeper when closed. +type sweptConn struct { + net.Conn + sweeper *Sweeper + id sweepID +} + +func (c *sweptConn) Close() error { + c.sweeper.deregister(c.id) + return c.Conn.Close() +} + +// Sweeper registers live connections and in-flight dials so the +// network-change sweep can cut everything registered before the change. +type Sweeper struct { + mu sync.Mutex + conns map[sweepID]connEntry + dials map[sweepID]*Dial + nextID sweepID + gen uint64 + timer *time.Timer + sweepDelay time.Duration + lastMark time.Time +} + +// New creates an empty sweeper with the default configuration. +func New() *Sweeper { + return NewWithConfig(Config{}) +} + +// NewWithConfig creates an empty sweeper customized by cfg. +func NewWithConfig(cfg Config) *Sweeper { + delay := cfg.SweepDelay + if delay <= 0 { + delay = DefaultSweepDelay + } + return &Sweeper{ + conns: make(map[sweepID]connEntry), + dials: make(map[sweepID]*Dial), + sweepDelay: delay, + } +} + +// StartDial registers an in-flight dial. Dial with Ctx, hand the result to +// WrapConn, and Release the dial when the attempt is over, typically deferred. +func (s *Sweeper) StartDial(ctx context.Context) *Dial { + if s == nil { + return &Dial{ctx: ctx} + } + + ctx, cancel := context.WithCancel(ctx) + d := &Dial{sweeper: s, ctx: ctx, cancel: cancel} + + s.mu.Lock() + d.id = s.nextID + s.nextID++ + d.gen = s.gen + s.dials[d.id] = d + s.mu.Unlock() + + return d +} + +// WrapConn hands conn over to the sweeper. If a sweep ran since StartDial, +// the connection belongs to the old network: it is closed and ErrSwept is +// returned. Otherwise conn is registered against the next sweep and returned +// wrapped, deregistering itself on Close. Call it once, before Release. +func (d *Dial) WrapConn(conn net.Conn) (net.Conn, error) { + s := d.sweeper + if s == nil { + return conn, nil + } + + s.mu.Lock() + if d.done { + s.mu.Unlock() + if err := conn.Close(); err != nil { + log.Debugf("swept dial close error: %v", err) + } + return nil, ErrSwept + } + d.done = true + delete(s.dials, d.id) + id := s.nextID + s.nextID++ + // The conn inherits the dial's generation: the socket was bound to the + // network that was default when the dial started, not when it finished. + s.conns[id] = connEntry{conn: conn, gen: d.gen} + s.mu.Unlock() + + return &sweptConn{Conn: conn, sweeper: s, id: id}, nil +} + +// MarkNetworkChange records that the OS switched networks: everything +// registered so far becomes stale, and a sweep is (re)scheduled after the +// configured delay to cut whatever is still stale by then. Owners that +// redialed in the meantime hold fresh-generation registrations and survive, +// so no cancellation is needed around the sweep. +func (s *Sweeper) MarkNetworkChange() { + if s == nil { + return + } + + s.mu.Lock() + s.gen++ + cutoff := s.gen + s.lastMark = time.Now() + if s.timer != nil { + s.timer.Stop() + } + s.timer = time.AfterFunc(s.sweepDelay, func() { + n := s.sweep(cutoff) + log.Infof("network change sweep: closed %d stale connections", n) + }) + s.mu.Unlock() +} + +// QuickRetryBackoff wraps bo so that after each Reset the first retry comes +// quickly when the disconnect followed a recent network change and the +// network is online. Any other failure keeps bo's spread, so the clients of +// a restarted server still scatter their reconnects. A nil sweeper returns +// bo unchanged. +func (s *Sweeper) QuickRetryBackoff(ctx context.Context, bo backoff.BackOff, netState *netstate.State) backoff.BackOff { + if s == nil { + return bo + } + return backoff.WithContext(newQuickRetryBackoff(bo, s, netState), ctx) +} + +func (s *Sweeper) markedRecently() bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return !s.lastMark.IsZero() && time.Since(s.lastMark) < recentMarkWindow +} + +// sweep closes the registered connections and aborts the in-flight dials +// older than cutoff, and returns how many connections it closed. A dial +// whose connection was not yet handed to WrapConn is marked, so the late +// WrapConn closes it instead of registering it. +func (s *Sweeper) sweep(cutoff uint64) int { + if s == nil { + return 0 + } + + s.mu.Lock() + var conns []net.Conn + for id, e := range s.conns { + if e.gen < cutoff { + delete(s.conns, id) + conns = append(conns, e.conn) + } + } + var dials []*Dial + for id, d := range s.dials { + if d.gen < cutoff { + d.done = true + delete(s.dials, id) + dials = append(dials, d) + } + } + s.mu.Unlock() + + if len(dials) > 0 { + log.Debugf("aborting %d in-flight dials", len(dials)) + for _, d := range dials { + d.cancel() + } + } + + for _, conn := range conns { + log.Debugf("sweeping connection %s -> %s", conn.LocalAddr(), conn.RemoteAddr()) + if err := conn.Close(); err != nil { + log.Debugf("swept connection close error: %v", err) + } + } + return len(conns) +} + +func (s *Sweeper) deregister(id sweepID) { + s.mu.Lock() + delete(s.conns, id) + s.mu.Unlock() +} diff --git a/client/netsweep/netsweep_test.go b/client/netsweep/netsweep_test.go new file mode 100644 index 000000000..88d660c2d --- /dev/null +++ b/client/netsweep/netsweep_test.go @@ -0,0 +1,241 @@ +package netsweep + +import ( + "context" + "math" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSweepClosesRegisteredConns(t *testing.T) { + sweeper := New() + + c1 := wrap(t, sweeper, connPair(t)) + c2 := wrap(t, sweeper, connPair(t)) + + assert.Equal(t, 2, sweeper.sweepAll(), "both live connections should be closed") + + // The wrappers must report closed now. + buf := make([]byte, 1) + _, err := c1.Read(buf) + assert.Error(t, err, "first connection should be unusable after the sweep") + _, err = c2.Read(buf) + assert.Error(t, err, "second connection should be unusable after the sweep") + + assert.Equal(t, 0, sweeper.sweepAll(), "second sweep should find nothing") +} + +func TestCloseDeregisters(t *testing.T) { + sweeper := New() + + conn := wrap(t, sweeper, connPair(t)) + require.NoError(t, conn.Close()) + + assert.Equal(t, 0, sweeper.sweepAll(), "closed connection must leave the registry") +} + +func TestCloseIsIdempotent(t *testing.T) { + sweeper := New() + + conn := wrap(t, sweeper, connPair(t)) + require.NoError(t, conn.Close()) + assert.Error(t, conn.Close(), "double close surfaces the underlying error but must not panic") +} + +func TestSweepOnlyAffectsOlderConns(t *testing.T) { + sweeper := New() + + _ = wrap(t, sweeper, connPair(t)) + assert.Equal(t, 1, sweeper.sweepAll()) + + // A connection dialed after the sweep must survive until the next one. + _ = wrap(t, sweeper, connPair(t)) + assert.Equal(t, 1, sweeper.sweepAll(), "post-sweep connection belongs to the next sweep") +} + +func TestSweepAbortsInFlightDials(t *testing.T) { + sweeper := New() + + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + sweeper.sweepAll() + + assert.ErrorIs(t, dial.Ctx().Err(), context.Canceled, "sweep must cancel the in-flight dial context") +} + +func TestReleasedDialIsNotAborted(t *testing.T) { + sweeper := New() + + // Simulate a dial that finished before the sweep. + released := sweeper.StartDial(context.Background()) + released.Release() + + // A dial still in flight during the sweep. + pending := sweeper.StartDial(context.Background()) + defer pending.Release() + + sweeper.sweepAll() + assert.ErrorIs(t, pending.Ctx().Err(), context.Canceled, "pending dial must be aborted") +} + +func TestSweepBetweenDialAndHandoffClosesConn(t *testing.T) { + sweeper := New() + + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + // The dial succeeds on the old network, then the sweep lands before the + // connection is handed over. + conn := connPair(t) + assert.Equal(t, 0, sweeper.sweepAll(), "the connection is not registered yet") + + wrapped, err := dial.WrapConn(conn) + require.ErrorIs(t, err, ErrSwept) + require.Nil(t, wrapped) + + buf := make([]byte, 1) + _, err = conn.Read(buf) + assert.Error(t, err, "the old-network connection must be closed, not leaked") + + assert.Equal(t, 0, sweeper.sweepAll(), "nothing may leak into the next sweep") +} + +func TestMarkNetworkChangeSparesFreshConns(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 10 * time.Millisecond}) + + stale := wrap(t, sweeper, connPair(t)) + sweeper.MarkNetworkChange() + _ = wrap(t, sweeper, connPair(t)) + + _ = stale.SetReadDeadline(time.Now().Add(time.Second)) + buf := make([]byte, 1) + _, err := stale.Read(buf) + require.ErrorIs(t, err, net.ErrClosed, "stale connection must be closed by the delayed sweep") + + assert.Equal(t, 1, sweeper.sweepAll(), "the fresh connection must survive the stale sweep") +} + +func TestMarkNetworkChangeAbortsStaleDials(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 10 * time.Millisecond}) + + stale := sweeper.StartDial(context.Background()) + defer stale.Release() + sweeper.MarkNetworkChange() + fresh := sweeper.StartDial(context.Background()) + defer fresh.Release() + + assert.Eventually(t, func() bool { + return stale.Ctx().Err() != nil + }, time.Second, 5*time.Millisecond, "stale dial must be aborted by the delayed sweep") + assert.NoError(t, fresh.Ctx().Err(), "post-mark dial must not be aborted") +} + +func TestConnInheritsDialGeneration(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 20 * time.Millisecond}) + + // The dial starts before the network change but completes after it: the + // socket is bound to the old network, so the sweep must still cut it. + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + sweeper.MarkNetworkChange() + + wrapped, err := dial.WrapConn(connPair(t)) + require.NoError(t, err) + + _ = wrapped.SetReadDeadline(time.Now().Add(time.Second)) + buf := make([]byte, 1) + _, err = wrapped.Read(buf) + require.ErrorIs(t, err, net.ErrClosed, "old-generation connection must be swept") +} + +func TestRepeatedMarksCoalesce(t *testing.T) { + sweeper := NewWithConfig(Config{SweepDelay: 20 * time.Millisecond}) + + first := wrap(t, sweeper, connPair(t)) + sweeper.MarkNetworkChange() + second := wrap(t, sweeper, connPair(t)) + sweeper.MarkNetworkChange() + _ = wrap(t, sweeper, connPair(t)) + + buf := make([]byte, 1) + for _, conn := range []net.Conn{first, second} { + _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + _, err := conn.Read(buf) + require.ErrorIs(t, err, net.ErrClosed, "every pre-mark connection must be swept by the rescheduled sweep") + } + assert.Equal(t, 1, sweeper.sweepAll(), "only the newest-generation connection may remain") +} + +func TestNilSweeperIsNoop(t *testing.T) { + var sweeper *Sweeper + + conn := connPair(t) + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + wrapped, err := dial.WrapConn(conn) + require.NoError(t, err) + assert.Equal(t, conn, wrapped, "nil sweeper must return the conn unchanged") + assert.NoError(t, dial.Ctx().Err(), "nil sweeper must not cancel the dial context") + assert.Equal(t, 0, sweeper.sweepAll(), "nil sweeper closes nothing") +} + +// wrap registers conn with the sweeper through a completed dial. +func wrap(t *testing.T, sweeper *Sweeper, conn net.Conn) net.Conn { + t.Helper() + + dial := sweeper.StartDial(context.Background()) + defer dial.Release() + + wrapped, err := dial.WrapConn(conn) + require.NoError(t, err) + return wrapped +} + +// connPair dials a loopback TCP connection and keeps the accepted peer open +// until the test ends: a peer that closed early would make the connection +// unreadable on its own, so a read error after the sweep would prove nothing. +func connPair(t *testing.T) net.Conn { + t.Helper() + + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + if err := l.Close(); err != nil { + t.Logf("listener close error: %v", err) + } + }) + + accepted := make(chan net.Conn, 1) + go func() { + conn, err := l.Accept() + if err != nil { + close(accepted) + return + } + accepted <- conn + }() + + conn, err := net.Dial("tcp", l.Addr().String()) + require.NoError(t, err) + + peer, ok := <-accepted + require.True(t, ok, "listener must accept the dialed connection") + t.Cleanup(func() { + if err := peer.Close(); err != nil { + t.Logf("peer close error: %v", err) + } + }) + + return conn +} + +// sweepAll cuts every registration regardless of generation. +func (s *Sweeper) sweepAll() int { + return s.sweep(math.MaxUint64) +} diff --git a/client/netsweep/quick_retry.go b/client/netsweep/quick_retry.go new file mode 100644 index 000000000..524a5c50c --- /dev/null +++ b/client/netsweep/quick_retry.go @@ -0,0 +1,39 @@ +package netsweep + +import ( + "time" + + "github.com/cenkalti/backoff/v4" + + "github.com/netbirdio/netbird/client/netstate" +) + +const quickRetryDelay = 200 * time.Millisecond + +type quickRetryBackoff struct { + backoff.BackOff + sweeper *Sweeper + netState *netstate.State + used bool +} + +func newQuickRetryBackoff(bo backoff.BackOff, sweeper *Sweeper, netState *netstate.State) *quickRetryBackoff { + return &quickRetryBackoff{ + BackOff: bo, + sweeper: sweeper, + netState: netState, + } +} + +func (b *quickRetryBackoff) NextBackOff() time.Duration { + if !b.used && b.sweeper.markedRecently() && b.netState.IsOnline() { + b.used = true + return quickRetryDelay + } + return b.BackOff.NextBackOff() +} + +func (b *quickRetryBackoff) Reset() { + b.used = false + b.BackOff.Reset() +} diff --git a/client/netsweep/quick_retry_test.go b/client/netsweep/quick_retry_test.go new file mode 100644 index 000000000..5505862c5 --- /dev/null +++ b/client/netsweep/quick_retry_test.go @@ -0,0 +1,58 @@ +package netsweep + +import ( + "context" + "testing" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/stretchr/testify/assert" +) + +func TestQuickRetryAfterRecentMark(t *testing.T) { + sweeper := New() + sweeper.MarkNetworkChange() + + slow := backoff.NewConstantBackOff(5 * time.Second) + bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil) + + assert.Equal(t, quickRetryDelay, bo.NextBackOff(), "first retry after a mark must be quick") + assert.Equal(t, 5*time.Second, bo.NextBackOff(), "second retry must fall back to the wrapped backoff") + + bo.Reset() + assert.Equal(t, quickRetryDelay, bo.NextBackOff(), "reset must re-arm the quick retry") +} + +func TestQuickRetryWithoutMarkKeepsSpread(t *testing.T) { + sweeper := New() + + slow := backoff.NewConstantBackOff(5 * time.Second) + bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil) + + assert.Equal(t, 5*time.Second, bo.NextBackOff(), "without a mark the wrapped backoff decides") + + sweeper.mu.Lock() + sweeper.lastMark = time.Now().Add(-recentMarkWindow) + sweeper.mu.Unlock() + assert.Equal(t, 5*time.Second, bo.NextBackOff(), "a stale mark must not trigger the quick retry") +} + +func TestQuickRetryNilSweeperPassthrough(t *testing.T) { + var sweeper *Sweeper + + slow := backoff.NewConstantBackOff(5 * time.Second) + bo := sweeper.QuickRetryBackoff(context.Background(), slow, nil) + + assert.Equal(t, backoff.BackOff(slow), bo, "nil sweeper must return the backoff unchanged") +} + +func TestQuickRetryHonorsContext(t *testing.T) { + sweeper := New() + sweeper.MarkNetworkChange() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + bo := sweeper.QuickRetryBackoff(ctx, backoff.NewConstantBackOff(time.Millisecond), nil) + + assert.Equal(t, backoff.Stop, bo.NextBackOff(), "cancelled context must stop the retry loop") +} diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 488b0186c..b438a310a 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -192,7 +192,7 @@ func (x SystemEvent_Severity) Number() protoreflect.EnumNumber { // Deprecated: Use SystemEvent_Severity.Descriptor instead. func (SystemEvent_Severity) EnumDescriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{51, 0} + return file_daemon_proto_rawDescGZIP(), []int{53, 0} } type SystemEvent_Category int32 @@ -247,7 +247,7 @@ func (x SystemEvent_Category) Number() protoreflect.EnumNumber { // Deprecated: Use SystemEvent_Category.Descriptor instead. func (SystemEvent_Category) EnumDescriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{51, 1} + return file_daemon_proto_rawDescGZIP(), []int{53, 1} } type EmptyRequest struct { @@ -823,9 +823,15 @@ func (x *WaitSSOLoginResponse) GetEmail() string { } type UpRequest 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 *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"` + // async instructs the daemon to start the connection attempt and return + // immediately without waiting for the engine to become ready. Status updates + // are delivered via the SubscribeStatus stream. When false (the default) the + // RPC blocks until the engine is running or gives up, which is the behaviour + // needed by the CLI. + Async bool `protobuf:"varint,4,opt,name=async,proto3" json:"async,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -874,6 +880,13 @@ func (x *UpRequest) GetUsername() string { return "" } +func (x *UpRequest) GetAsync() bool { + if x != nil { + return x.Async + } + return false +} + type UpResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -978,8 +991,12 @@ type StatusResponse struct { FullStatus *FullStatus `protobuf:"bytes,2,opt,name=fullStatus,proto3" json:"fullStatus,omitempty"` // NetBird daemon version DaemonVersion string `protobuf:"bytes,3,opt,name=daemonVersion,proto3" json:"daemonVersion,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Absolute UTC instant at which the peer's SSO session expires. + // Unset when the peer is not SSO-registered or login expiration is disabled. + // The UI derives "warning active" from this value and its own clock. + SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *StatusResponse) Reset() { @@ -1033,6 +1050,13 @@ func (x *StatusResponse) GetDaemonVersion() string { return "" } +func (x *StatusResponse) GetSessionExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.SessionExpiresAt + } + return nil +} + type DownRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -2129,8 +2153,13 @@ type FullStatus struct { Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"` LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"` SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // networksRevision bumps whenever the set of routed networks (route and + // exit-node candidates) or their selected state changes. The UI fingerprints + // on it to know when to re-fetch ListNetworks via the push stream, instead + // of polling on every status snapshot. + NetworksRevision uint64 `protobuf:"varint,11,opt,name=networksRevision,proto3" json:"networksRevision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *FullStatus) Reset() { @@ -2233,6 +2262,13 @@ func (x *FullStatus) GetSshServerState() *SSHServerState { return nil } +func (x *FullStatus) GetNetworksRevision() uint64 { + if x != nil { + return x.NetworksRevision + } + return 0 +} + // Networks type ListNetworksRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2735,14 +2771,23 @@ func (x *ForwardingRulesResponse) GetRules() []*ForwardingRule { // DebugBundler type DebugBundleRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"` - SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"` - UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"` - LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"` - CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"` + SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"` + UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"` + LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"` + CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"` + // uploadInsecure allows uploading to an http endpoint or one with an + // untrusted TLS certificate. Restricted to privileged callers; for + // self-hosted upload servers. + UploadInsecure bool `protobuf:"varint,7,opt,name=uploadInsecure,proto3" json:"uploadInsecure,omitempty"` + // anonymizeLevel selects how much the anonymizer redacts: "default" + // (or empty) keeps internal IP ranges, "strict" also anonymizes them. + // Unknown values are treated as "strict". Only meaningful with anonymize; + // "strict" implies it. + AnonymizeLevel string `protobuf:"bytes,8,opt,name=anonymizeLevel,proto3" json:"anonymizeLevel,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DebugBundleRequest) Reset() { @@ -2810,6 +2855,20 @@ func (x *DebugBundleRequest) GetCliVersion() string { return "" } +func (x *DebugBundleRequest) GetUploadInsecure() bool { + if x != nil { + return x.UploadInsecure + } + return false +} + +func (x *DebugBundleRequest) GetAnonymizeLevel() string { + if x != nil { + return x.AnonymizeLevel + } + return "" +} + type DebugBundleResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` @@ -3030,6 +3089,86 @@ func (*SetLogLevelResponse) Descriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{36} } +type RegisterUILogRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterUILogRequest) Reset() { + *x = RegisterUILogRequest{} + mi := &file_daemon_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterUILogRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterUILogRequest) ProtoMessage() {} + +func (x *RegisterUILogRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[37] + 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 RegisterUILogRequest.ProtoReflect.Descriptor instead. +func (*RegisterUILogRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{37} +} + +func (x *RegisterUILogRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type RegisterUILogResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterUILogResponse) Reset() { + *x = RegisterUILogResponse{} + mi := &file_daemon_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterUILogResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterUILogResponse) ProtoMessage() {} + +func (x *RegisterUILogResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[38] + 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 RegisterUILogResponse.ProtoReflect.Descriptor instead. +func (*RegisterUILogResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{38} +} + // State represents a daemon state entry type State struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3040,7 +3179,7 @@ type State struct { func (x *State) Reset() { *x = State{} - mi := &file_daemon_proto_msgTypes[37] + mi := &file_daemon_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3052,7 +3191,7 @@ func (x *State) String() string { func (*State) ProtoMessage() {} func (x *State) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[37] + mi := &file_daemon_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3065,7 +3204,7 @@ func (x *State) ProtoReflect() protoreflect.Message { // Deprecated: Use State.ProtoReflect.Descriptor instead. func (*State) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{37} + return file_daemon_proto_rawDescGZIP(), []int{39} } func (x *State) GetName() string { @@ -3084,7 +3223,7 @@ type ListStatesRequest struct { func (x *ListStatesRequest) Reset() { *x = ListStatesRequest{} - mi := &file_daemon_proto_msgTypes[38] + mi := &file_daemon_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3096,7 +3235,7 @@ func (x *ListStatesRequest) String() string { func (*ListStatesRequest) ProtoMessage() {} func (x *ListStatesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[38] + mi := &file_daemon_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3109,7 +3248,7 @@ func (x *ListStatesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStatesRequest.ProtoReflect.Descriptor instead. func (*ListStatesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{38} + return file_daemon_proto_rawDescGZIP(), []int{40} } // ListStatesResponse contains a list of states @@ -3122,7 +3261,7 @@ type ListStatesResponse struct { func (x *ListStatesResponse) Reset() { *x = ListStatesResponse{} - mi := &file_daemon_proto_msgTypes[39] + mi := &file_daemon_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3134,7 +3273,7 @@ func (x *ListStatesResponse) String() string { func (*ListStatesResponse) ProtoMessage() {} func (x *ListStatesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[39] + mi := &file_daemon_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3147,7 +3286,7 @@ func (x *ListStatesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStatesResponse.ProtoReflect.Descriptor instead. func (*ListStatesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{39} + return file_daemon_proto_rawDescGZIP(), []int{41} } func (x *ListStatesResponse) GetStates() []*State { @@ -3168,7 +3307,7 @@ type CleanStateRequest struct { func (x *CleanStateRequest) Reset() { *x = CleanStateRequest{} - mi := &file_daemon_proto_msgTypes[40] + mi := &file_daemon_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3180,7 +3319,7 @@ func (x *CleanStateRequest) String() string { func (*CleanStateRequest) ProtoMessage() {} func (x *CleanStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[40] + mi := &file_daemon_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3193,7 +3332,7 @@ func (x *CleanStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CleanStateRequest.ProtoReflect.Descriptor instead. func (*CleanStateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{40} + return file_daemon_proto_rawDescGZIP(), []int{42} } func (x *CleanStateRequest) GetStateName() string { @@ -3220,7 +3359,7 @@ type CleanStateResponse struct { func (x *CleanStateResponse) Reset() { *x = CleanStateResponse{} - mi := &file_daemon_proto_msgTypes[41] + mi := &file_daemon_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3232,7 +3371,7 @@ func (x *CleanStateResponse) String() string { func (*CleanStateResponse) ProtoMessage() {} func (x *CleanStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[41] + mi := &file_daemon_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3245,7 +3384,7 @@ func (x *CleanStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CleanStateResponse.ProtoReflect.Descriptor instead. func (*CleanStateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{41} + return file_daemon_proto_rawDescGZIP(), []int{43} } func (x *CleanStateResponse) GetCleanedStates() int32 { @@ -3266,7 +3405,7 @@ type DeleteStateRequest struct { func (x *DeleteStateRequest) Reset() { *x = DeleteStateRequest{} - mi := &file_daemon_proto_msgTypes[42] + mi := &file_daemon_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3278,7 +3417,7 @@ func (x *DeleteStateRequest) String() string { func (*DeleteStateRequest) ProtoMessage() {} func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[42] + mi := &file_daemon_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3291,7 +3430,7 @@ func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateRequest.ProtoReflect.Descriptor instead. func (*DeleteStateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{42} + return file_daemon_proto_rawDescGZIP(), []int{44} } func (x *DeleteStateRequest) GetStateName() string { @@ -3318,7 +3457,7 @@ type DeleteStateResponse struct { func (x *DeleteStateResponse) Reset() { *x = DeleteStateResponse{} - mi := &file_daemon_proto_msgTypes[43] + mi := &file_daemon_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3330,7 +3469,7 @@ func (x *DeleteStateResponse) String() string { func (*DeleteStateResponse) ProtoMessage() {} func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[43] + mi := &file_daemon_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3343,7 +3482,7 @@ func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateResponse.ProtoReflect.Descriptor instead. func (*DeleteStateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{43} + return file_daemon_proto_rawDescGZIP(), []int{45} } func (x *DeleteStateResponse) GetDeletedStates() int32 { @@ -3362,7 +3501,7 @@ type SetSyncResponsePersistenceRequest struct { func (x *SetSyncResponsePersistenceRequest) Reset() { *x = SetSyncResponsePersistenceRequest{} - mi := &file_daemon_proto_msgTypes[44] + mi := &file_daemon_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3374,7 +3513,7 @@ func (x *SetSyncResponsePersistenceRequest) String() string { func (*SetSyncResponsePersistenceRequest) ProtoMessage() {} func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[44] + mi := &file_daemon_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3387,7 +3526,7 @@ func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message // Deprecated: Use SetSyncResponsePersistenceRequest.ProtoReflect.Descriptor instead. func (*SetSyncResponsePersistenceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{44} + return file_daemon_proto_rawDescGZIP(), []int{46} } func (x *SetSyncResponsePersistenceRequest) GetEnabled() bool { @@ -3405,7 +3544,7 @@ type SetSyncResponsePersistenceResponse struct { func (x *SetSyncResponsePersistenceResponse) Reset() { *x = SetSyncResponsePersistenceResponse{} - mi := &file_daemon_proto_msgTypes[45] + mi := &file_daemon_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3417,7 +3556,7 @@ func (x *SetSyncResponsePersistenceResponse) String() string { func (*SetSyncResponsePersistenceResponse) ProtoMessage() {} func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[45] + mi := &file_daemon_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3430,7 +3569,7 @@ func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message // Deprecated: Use SetSyncResponsePersistenceResponse.ProtoReflect.Descriptor instead. func (*SetSyncResponsePersistenceResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{45} + return file_daemon_proto_rawDescGZIP(), []int{47} } type TCPFlags struct { @@ -3447,7 +3586,7 @@ type TCPFlags struct { func (x *TCPFlags) Reset() { *x = TCPFlags{} - mi := &file_daemon_proto_msgTypes[46] + mi := &file_daemon_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3459,7 +3598,7 @@ func (x *TCPFlags) String() string { func (*TCPFlags) ProtoMessage() {} func (x *TCPFlags) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[46] + mi := &file_daemon_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3472,7 +3611,7 @@ func (x *TCPFlags) ProtoReflect() protoreflect.Message { // Deprecated: Use TCPFlags.ProtoReflect.Descriptor instead. func (*TCPFlags) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{46} + return file_daemon_proto_rawDescGZIP(), []int{48} } func (x *TCPFlags) GetSyn() bool { @@ -3534,7 +3673,7 @@ type TracePacketRequest struct { func (x *TracePacketRequest) Reset() { *x = TracePacketRequest{} - mi := &file_daemon_proto_msgTypes[47] + mi := &file_daemon_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3546,7 +3685,7 @@ func (x *TracePacketRequest) String() string { func (*TracePacketRequest) ProtoMessage() {} func (x *TracePacketRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[47] + mi := &file_daemon_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3559,7 +3698,7 @@ func (x *TracePacketRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TracePacketRequest.ProtoReflect.Descriptor instead. func (*TracePacketRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{47} + return file_daemon_proto_rawDescGZIP(), []int{49} } func (x *TracePacketRequest) GetSourceIp() string { @@ -3637,7 +3776,7 @@ type TraceStage struct { func (x *TraceStage) Reset() { *x = TraceStage{} - mi := &file_daemon_proto_msgTypes[48] + mi := &file_daemon_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3649,7 +3788,7 @@ func (x *TraceStage) String() string { func (*TraceStage) ProtoMessage() {} func (x *TraceStage) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[48] + mi := &file_daemon_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3662,7 +3801,7 @@ func (x *TraceStage) ProtoReflect() protoreflect.Message { // Deprecated: Use TraceStage.ProtoReflect.Descriptor instead. func (*TraceStage) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{48} + return file_daemon_proto_rawDescGZIP(), []int{50} } func (x *TraceStage) GetName() string { @@ -3703,7 +3842,7 @@ type TracePacketResponse struct { func (x *TracePacketResponse) Reset() { *x = TracePacketResponse{} - mi := &file_daemon_proto_msgTypes[49] + mi := &file_daemon_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3715,7 +3854,7 @@ func (x *TracePacketResponse) String() string { func (*TracePacketResponse) ProtoMessage() {} func (x *TracePacketResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[49] + mi := &file_daemon_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3728,7 +3867,7 @@ func (x *TracePacketResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TracePacketResponse.ProtoReflect.Descriptor instead. func (*TracePacketResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{49} + return file_daemon_proto_rawDescGZIP(), []int{51} } func (x *TracePacketResponse) GetStages() []*TraceStage { @@ -3753,7 +3892,7 @@ type SubscribeRequest struct { func (x *SubscribeRequest) Reset() { *x = SubscribeRequest{} - mi := &file_daemon_proto_msgTypes[50] + mi := &file_daemon_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3765,7 +3904,7 @@ func (x *SubscribeRequest) String() string { func (*SubscribeRequest) ProtoMessage() {} func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[50] + mi := &file_daemon_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3778,7 +3917,7 @@ func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead. func (*SubscribeRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{50} + return file_daemon_proto_rawDescGZIP(), []int{52} } type SystemEvent struct { @@ -3796,7 +3935,7 @@ type SystemEvent struct { func (x *SystemEvent) Reset() { *x = SystemEvent{} - mi := &file_daemon_proto_msgTypes[51] + mi := &file_daemon_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3808,7 +3947,7 @@ func (x *SystemEvent) String() string { func (*SystemEvent) ProtoMessage() {} func (x *SystemEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[51] + mi := &file_daemon_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3821,7 +3960,7 @@ func (x *SystemEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemEvent.ProtoReflect.Descriptor instead. func (*SystemEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{51} + return file_daemon_proto_rawDescGZIP(), []int{53} } func (x *SystemEvent) GetId() string { @@ -3881,7 +4020,7 @@ type GetEventsRequest struct { func (x *GetEventsRequest) Reset() { *x = GetEventsRequest{} - mi := &file_daemon_proto_msgTypes[52] + mi := &file_daemon_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3893,7 +4032,7 @@ func (x *GetEventsRequest) String() string { func (*GetEventsRequest) ProtoMessage() {} func (x *GetEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[52] + mi := &file_daemon_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3906,7 +4045,7 @@ func (x *GetEventsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEventsRequest.ProtoReflect.Descriptor instead. func (*GetEventsRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{52} + return file_daemon_proto_rawDescGZIP(), []int{54} } type GetEventsResponse struct { @@ -3918,7 +4057,7 @@ type GetEventsResponse struct { func (x *GetEventsResponse) Reset() { *x = GetEventsResponse{} - mi := &file_daemon_proto_msgTypes[53] + mi := &file_daemon_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3930,7 +4069,7 @@ func (x *GetEventsResponse) String() string { func (*GetEventsResponse) ProtoMessage() {} func (x *GetEventsResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[53] + mi := &file_daemon_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3943,7 +4082,7 @@ func (x *GetEventsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEventsResponse.ProtoReflect.Descriptor instead. func (*GetEventsResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{53} + return file_daemon_proto_rawDescGZIP(), []int{55} } func (x *GetEventsResponse) GetEvents() []*SystemEvent { @@ -3965,7 +4104,7 @@ type SwitchProfileRequest struct { func (x *SwitchProfileRequest) Reset() { *x = SwitchProfileRequest{} - mi := &file_daemon_proto_msgTypes[54] + mi := &file_daemon_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3977,7 +4116,7 @@ func (x *SwitchProfileRequest) String() string { func (*SwitchProfileRequest) ProtoMessage() {} func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[54] + mi := &file_daemon_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3990,7 +4129,7 @@ func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SwitchProfileRequest.ProtoReflect.Descriptor instead. func (*SwitchProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{54} + return file_daemon_proto_rawDescGZIP(), []int{56} } func (x *SwitchProfileRequest) GetProfileName() string { @@ -4019,7 +4158,7 @@ type SwitchProfileResponse struct { func (x *SwitchProfileResponse) Reset() { *x = SwitchProfileResponse{} - mi := &file_daemon_proto_msgTypes[55] + mi := &file_daemon_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4031,7 +4170,7 @@ func (x *SwitchProfileResponse) String() string { func (*SwitchProfileResponse) ProtoMessage() {} func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[55] + mi := &file_daemon_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4044,7 +4183,7 @@ func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SwitchProfileResponse.ProtoReflect.Descriptor instead. func (*SwitchProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{55} + return file_daemon_proto_rawDescGZIP(), []int{57} } func (x *SwitchProfileResponse) GetId() string { @@ -4100,7 +4239,7 @@ type SetConfigRequest struct { func (x *SetConfigRequest) Reset() { *x = SetConfigRequest{} - mi := &file_daemon_proto_msgTypes[56] + mi := &file_daemon_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4112,7 +4251,7 @@ func (x *SetConfigRequest) String() string { func (*SetConfigRequest) ProtoMessage() {} func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[56] + mi := &file_daemon_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4125,7 +4264,7 @@ func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetConfigRequest.ProtoReflect.Descriptor instead. func (*SetConfigRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{56} + return file_daemon_proto_rawDescGZIP(), []int{58} } func (x *SetConfigRequest) GetUsername() string { @@ -4381,7 +4520,7 @@ type SetConfigResponse struct { func (x *SetConfigResponse) Reset() { *x = SetConfigResponse{} - mi := &file_daemon_proto_msgTypes[57] + mi := &file_daemon_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4393,7 +4532,7 @@ func (x *SetConfigResponse) String() string { func (*SetConfigResponse) ProtoMessage() {} func (x *SetConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[57] + mi := &file_daemon_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4406,7 +4545,7 @@ func (x *SetConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetConfigResponse.ProtoReflect.Descriptor instead. func (*SetConfigResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{57} + return file_daemon_proto_rawDescGZIP(), []int{59} } type AddProfileRequest struct { @@ -4421,7 +4560,7 @@ type AddProfileRequest struct { func (x *AddProfileRequest) Reset() { *x = AddProfileRequest{} - mi := &file_daemon_proto_msgTypes[58] + mi := &file_daemon_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4433,7 +4572,7 @@ func (x *AddProfileRequest) String() string { func (*AddProfileRequest) ProtoMessage() {} func (x *AddProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[58] + mi := &file_daemon_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4446,7 +4585,7 @@ func (x *AddProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddProfileRequest.ProtoReflect.Descriptor instead. func (*AddProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{58} + return file_daemon_proto_rawDescGZIP(), []int{60} } func (x *AddProfileRequest) GetUsername() string { @@ -4474,7 +4613,7 @@ type AddProfileResponse struct { func (x *AddProfileResponse) Reset() { *x = AddProfileResponse{} - mi := &file_daemon_proto_msgTypes[59] + mi := &file_daemon_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4486,7 +4625,7 @@ func (x *AddProfileResponse) String() string { func (*AddProfileResponse) ProtoMessage() {} func (x *AddProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[59] + mi := &file_daemon_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4499,7 +4638,7 @@ func (x *AddProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddProfileResponse.ProtoReflect.Descriptor instead. func (*AddProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{59} + return file_daemon_proto_rawDescGZIP(), []int{61} } func (x *AddProfileResponse) GetId() string { @@ -4522,7 +4661,7 @@ type RenameProfileRequest struct { func (x *RenameProfileRequest) Reset() { *x = RenameProfileRequest{} - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4534,7 +4673,7 @@ func (x *RenameProfileRequest) String() string { func (*RenameProfileRequest) ProtoMessage() {} func (x *RenameProfileRequest) 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 { @@ -4547,7 +4686,7 @@ func (x *RenameProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RenameProfileRequest.ProtoReflect.Descriptor instead. func (*RenameProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{60} + return file_daemon_proto_rawDescGZIP(), []int{62} } func (x *RenameProfileRequest) GetUsername() string { @@ -4581,7 +4720,7 @@ type RenameProfileResponse struct { func (x *RenameProfileResponse) Reset() { *x = RenameProfileResponse{} - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4593,7 +4732,7 @@ func (x *RenameProfileResponse) String() string { func (*RenameProfileResponse) ProtoMessage() {} func (x *RenameProfileResponse) 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 { @@ -4606,7 +4745,7 @@ func (x *RenameProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RenameProfileResponse.ProtoReflect.Descriptor instead. func (*RenameProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{61} + return file_daemon_proto_rawDescGZIP(), []int{63} } func (x *RenameProfileResponse) GetOldProfileName() string { @@ -4628,7 +4767,7 @@ type RemoveProfileRequest struct { func (x *RemoveProfileRequest) Reset() { *x = RemoveProfileRequest{} - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4640,7 +4779,7 @@ func (x *RemoveProfileRequest) String() string { func (*RemoveProfileRequest) ProtoMessage() {} func (x *RemoveProfileRequest) 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 { @@ -4653,7 +4792,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead. func (*RemoveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{62} + return file_daemon_proto_rawDescGZIP(), []int{64} } func (x *RemoveProfileRequest) GetUsername() string { @@ -4681,7 +4820,7 @@ type RemoveProfileResponse struct { func (x *RemoveProfileResponse) Reset() { *x = RemoveProfileResponse{} - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4693,7 +4832,7 @@ func (x *RemoveProfileResponse) String() string { func (*RemoveProfileResponse) ProtoMessage() {} func (x *RemoveProfileResponse) 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 { @@ -4706,7 +4845,7 @@ func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileResponse.ProtoReflect.Descriptor instead. func (*RemoveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{63} + return file_daemon_proto_rawDescGZIP(), []int{65} } func (x *RemoveProfileResponse) GetId() string { @@ -4725,7 +4864,7 @@ type ListProfilesRequest struct { func (x *ListProfilesRequest) Reset() { *x = ListProfilesRequest{} - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4737,7 +4876,7 @@ func (x *ListProfilesRequest) String() string { func (*ListProfilesRequest) ProtoMessage() {} func (x *ListProfilesRequest) 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 { @@ -4750,7 +4889,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead. func (*ListProfilesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{64} + return file_daemon_proto_rawDescGZIP(), []int{66} } func (x *ListProfilesRequest) GetUsername() string { @@ -4769,7 +4908,7 @@ type ListProfilesResponse struct { func (x *ListProfilesResponse) Reset() { *x = ListProfilesResponse{} - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4781,7 +4920,7 @@ func (x *ListProfilesResponse) String() string { func (*ListProfilesResponse) ProtoMessage() {} func (x *ListProfilesResponse) 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 { @@ -4794,7 +4933,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProfilesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{65} + return file_daemon_proto_rawDescGZIP(), []int{67} } func (x *ListProfilesResponse) GetProfiles() []*Profile { @@ -4815,7 +4954,7 @@ type Profile struct { func (x *Profile) Reset() { *x = Profile{} - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4827,7 +4966,7 @@ func (x *Profile) String() string { func (*Profile) ProtoMessage() {} func (x *Profile) 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 { @@ -4840,7 +4979,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message { // Deprecated: Use Profile.ProtoReflect.Descriptor instead. func (*Profile) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{66} + return file_daemon_proto_rawDescGZIP(), []int{68} } func (x *Profile) GetName() string { @@ -4872,7 +5011,7 @@ type GetActiveProfileRequest struct { func (x *GetActiveProfileRequest) Reset() { *x = GetActiveProfileRequest{} - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4884,7 +5023,7 @@ func (x *GetActiveProfileRequest) String() string { func (*GetActiveProfileRequest) ProtoMessage() {} func (x *GetActiveProfileRequest) 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 { @@ -4897,7 +5036,7 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead. func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{67} + return file_daemon_proto_rawDescGZIP(), []int{69} } type GetActiveProfileResponse struct { @@ -4911,7 +5050,7 @@ type GetActiveProfileResponse struct { func (x *GetActiveProfileResponse) Reset() { *x = GetActiveProfileResponse{} - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4923,7 +5062,7 @@ func (x *GetActiveProfileResponse) String() string { func (*GetActiveProfileResponse) ProtoMessage() {} func (x *GetActiveProfileResponse) 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 { @@ -4936,7 +5075,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead. func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{68} + return file_daemon_proto_rawDescGZIP(), []int{70} } func (x *GetActiveProfileResponse) GetProfileName() string { @@ -4970,7 +5109,7 @@ type LogoutRequest struct { func (x *LogoutRequest) Reset() { *x = LogoutRequest{} - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4982,7 +5121,7 @@ func (x *LogoutRequest) String() string { func (*LogoutRequest) ProtoMessage() {} func (x *LogoutRequest) 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 { @@ -4995,7 +5134,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. func (*LogoutRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{69} + return file_daemon_proto_rawDescGZIP(), []int{71} } func (x *LogoutRequest) GetProfileName() string { @@ -5020,7 +5159,7 @@ type LogoutResponse struct { func (x *LogoutResponse) Reset() { *x = LogoutResponse{} - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5032,7 +5171,7 @@ func (x *LogoutResponse) String() string { func (*LogoutResponse) ProtoMessage() {} func (x *LogoutResponse) 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 { @@ -5045,7 +5184,79 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. func (*LogoutResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{70} + return file_daemon_proto_rawDescGZIP(), []int{72} +} + +type WailsUIReadyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WailsUIReadyRequest) Reset() { + *x = WailsUIReadyRequest{} + mi := &file_daemon_proto_msgTypes[73] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WailsUIReadyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WailsUIReadyRequest) ProtoMessage() {} + +func (x *WailsUIReadyRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[73] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WailsUIReadyRequest.ProtoReflect.Descriptor instead. +func (*WailsUIReadyRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{73} +} + +type WailsUIReadyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WailsUIReadyResponse) Reset() { + *x = WailsUIReadyResponse{} + mi := &file_daemon_proto_msgTypes[74] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WailsUIReadyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WailsUIReadyResponse) ProtoMessage() {} + +func (x *WailsUIReadyResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[74] + 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 WailsUIReadyResponse.ProtoReflect.Descriptor instead. +func (*WailsUIReadyResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{74} } type GetFeaturesRequest struct { @@ -5056,7 +5267,7 @@ type GetFeaturesRequest struct { func (x *GetFeaturesRequest) Reset() { *x = GetFeaturesRequest{} - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5068,7 +5279,7 @@ func (x *GetFeaturesRequest) String() string { func (*GetFeaturesRequest) ProtoMessage() {} func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5081,7 +5292,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead. func (*GetFeaturesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{71} + return file_daemon_proto_rawDescGZIP(), []int{75} } type GetFeaturesResponse struct { @@ -5089,13 +5300,19 @@ type GetFeaturesResponse struct { DisableProfiles bool `protobuf:"varint,1,opt,name=disable_profiles,json=disableProfiles,proto3" json:"disable_profiles,omitempty"` DisableUpdateSettings bool `protobuf:"varint,2,opt,name=disable_update_settings,json=disableUpdateSettings,proto3" json:"disable_update_settings,omitempty"` DisableNetworks bool `protobuf:"varint,3,opt,name=disable_networks,json=disableNetworks,proto3" json:"disable_networks,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // disableAdvancedView gates the upcoming UI revision's advanced + // section. Tristate: unset = no MDM directive, the UI applies its + // own default; true = MDM enforces disable; false = MDM enforces + // enable. Sourced exclusively from the MDM policy — no CLI / + // config flag backs this value. + DisableAdvancedView *bool `protobuf:"varint,4,opt,name=disable_advanced_view,json=disableAdvancedView,proto3,oneof" json:"disable_advanced_view,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetFeaturesResponse) Reset() { *x = GetFeaturesResponse{} - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5107,7 +5324,7 @@ func (x *GetFeaturesResponse) String() string { func (*GetFeaturesResponse) ProtoMessage() {} func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5120,7 +5337,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead. func (*GetFeaturesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{72} + return file_daemon_proto_rawDescGZIP(), []int{76} } func (x *GetFeaturesResponse) GetDisableProfiles() bool { @@ -5144,6 +5361,13 @@ func (x *GetFeaturesResponse) GetDisableNetworks() bool { return false } +func (x *GetFeaturesResponse) GetDisableAdvancedView() bool { + if x != nil && x.DisableAdvancedView != nil { + return *x.DisableAdvancedView + } + return false +} + // MDMManagedFieldsViolation is attached as a gRPC error detail on a // FailedPrecondition status returned from SetConfig (and similar mutating // RPCs) when the caller tries to modify one or more MDM-enforced fields. @@ -5158,7 +5382,7 @@ type MDMManagedFieldsViolation struct { func (x *MDMManagedFieldsViolation) Reset() { *x = MDMManagedFieldsViolation{} - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5170,7 +5394,7 @@ func (x *MDMManagedFieldsViolation) String() string { func (*MDMManagedFieldsViolation) ProtoMessage() {} func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5183,7 +5407,7 @@ func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message { // Deprecated: Use MDMManagedFieldsViolation.ProtoReflect.Descriptor instead. func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{73} + return file_daemon_proto_rawDescGZIP(), []int{77} } func (x *MDMManagedFieldsViolation) GetFields() []string { @@ -5201,7 +5425,7 @@ type TriggerUpdateRequest struct { func (x *TriggerUpdateRequest) Reset() { *x = TriggerUpdateRequest{} - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5213,7 +5437,7 @@ func (x *TriggerUpdateRequest) String() string { func (*TriggerUpdateRequest) ProtoMessage() {} func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5226,7 +5450,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead. func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{74} + return file_daemon_proto_rawDescGZIP(), []int{78} } type TriggerUpdateResponse struct { @@ -5239,7 +5463,7 @@ type TriggerUpdateResponse struct { func (x *TriggerUpdateResponse) Reset() { *x = TriggerUpdateResponse{} - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5251,7 +5475,7 @@ func (x *TriggerUpdateResponse) String() string { func (*TriggerUpdateResponse) ProtoMessage() {} func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5264,7 +5488,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead. func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{75} + return file_daemon_proto_rawDescGZIP(), []int{79} } func (x *TriggerUpdateResponse) GetSuccess() bool { @@ -5292,7 +5516,7 @@ type GetPeerSSHHostKeyRequest struct { func (x *GetPeerSSHHostKeyRequest) Reset() { *x = GetPeerSSHHostKeyRequest{} - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5304,7 +5528,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string { func (*GetPeerSSHHostKeyRequest) ProtoMessage() {} func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5317,7 +5541,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{76} + return file_daemon_proto_rawDescGZIP(), []int{80} } func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string { @@ -5344,7 +5568,7 @@ type GetPeerSSHHostKeyResponse struct { func (x *GetPeerSSHHostKeyResponse) Reset() { *x = GetPeerSSHHostKeyResponse{} - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5356,7 +5580,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string { func (*GetPeerSSHHostKeyResponse) ProtoMessage() {} func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5369,7 +5593,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{77} + return file_daemon_proto_rawDescGZIP(), []int{81} } func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte { @@ -5404,14 +5628,18 @@ func (x *GetPeerSSHHostKeyResponse) GetFound() bool { type RequestJWTAuthRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // hint for OIDC login_hint parameter (typically email address) - Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + HasGraphicalSession bool `protobuf:"varint,2,opt,name=hasGraphicalSession,proto3" json:"hasGraphicalSession,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RequestJWTAuthRequest) Reset() { *x = RequestJWTAuthRequest{} - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5423,7 +5651,7 @@ func (x *RequestJWTAuthRequest) String() string { func (*RequestJWTAuthRequest) ProtoMessage() {} func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5436,7 +5664,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead. func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{78} + return file_daemon_proto_rawDescGZIP(), []int{82} } func (x *RequestJWTAuthRequest) GetHint() string { @@ -5446,6 +5674,13 @@ func (x *RequestJWTAuthRequest) GetHint() string { return "" } +func (x *RequestJWTAuthRequest) GetHasGraphicalSession() bool { + if x != nil { + return x.HasGraphicalSession + } + return false +} + // RequestJWTAuthResponse contains authentication flow information type RequestJWTAuthResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -5469,7 +5704,7 @@ type RequestJWTAuthResponse struct { func (x *RequestJWTAuthResponse) Reset() { *x = RequestJWTAuthResponse{} - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5481,7 +5716,7 @@ func (x *RequestJWTAuthResponse) String() string { func (*RequestJWTAuthResponse) ProtoMessage() {} func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5494,7 +5729,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead. func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{79} + return file_daemon_proto_rawDescGZIP(), []int{83} } func (x *RequestJWTAuthResponse) GetVerificationURI() string { @@ -5559,7 +5794,7 @@ type WaitJWTTokenRequest struct { func (x *WaitJWTTokenRequest) Reset() { *x = WaitJWTTokenRequest{} - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5571,7 +5806,7 @@ func (x *WaitJWTTokenRequest) String() string { func (*WaitJWTTokenRequest) ProtoMessage() {} func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5584,7 +5819,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead. func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{80} + return file_daemon_proto_rawDescGZIP(), []int{84} } func (x *WaitJWTTokenRequest) GetDeviceCode() string { @@ -5616,7 +5851,7 @@ type WaitJWTTokenResponse struct { func (x *WaitJWTTokenResponse) Reset() { *x = WaitJWTTokenResponse{} - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5628,7 +5863,7 @@ func (x *WaitJWTTokenResponse) String() string { func (*WaitJWTTokenResponse) ProtoMessage() {} func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5641,7 +5876,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead. func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{81} + return file_daemon_proto_rawDescGZIP(), []int{85} } func (x *WaitJWTTokenResponse) GetToken() string { @@ -5665,6 +5900,329 @@ func (x *WaitJWTTokenResponse) GetExpiresIn() int64 { return 0 } +// RequestExtendAuthSessionRequest kicks off the session-extension SSO flow. +type RequestExtendAuthSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional OIDC login_hint (typically the user's email) to pre-fill the + // IdP login form. + Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"` + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + HasGraphicalSession bool `protobuf:"varint,2,opt,name=hasGraphicalSession,proto3" json:"hasGraphicalSession,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestExtendAuthSessionRequest) Reset() { + *x = RequestExtendAuthSessionRequest{} + mi := &file_daemon_proto_msgTypes[86] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestExtendAuthSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestExtendAuthSessionRequest) ProtoMessage() {} + +func (x *RequestExtendAuthSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[86] + 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 RequestExtendAuthSessionRequest.ProtoReflect.Descriptor instead. +func (*RequestExtendAuthSessionRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{86} +} + +func (x *RequestExtendAuthSessionRequest) GetHint() string { + if x != nil && x.Hint != nil { + return *x.Hint + } + return "" +} + +func (x *RequestExtendAuthSessionRequest) GetHasGraphicalSession() bool { + if x != nil { + return x.HasGraphicalSession + } + return false +} + +// RequestExtendAuthSessionResponse carries the verification URI the UI +// should open in a browser. The daemon retains the flow state and resolves +// it via WaitExtendAuthSession. +type RequestExtendAuthSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // verification URI for the user to open in the browser + VerificationURI string `protobuf:"bytes,1,opt,name=verificationURI,proto3" json:"verificationURI,omitempty"` + // complete verification URI (with embedded user code) + VerificationURIComplete string `protobuf:"bytes,2,opt,name=verificationURIComplete,proto3" json:"verificationURIComplete,omitempty"` + // user code to enter on verification URI (for device-code flows) + UserCode string `protobuf:"bytes,3,opt,name=userCode,proto3" json:"userCode,omitempty"` + // device code for matching the WaitExtendAuthSession call to this flow + DeviceCode string `protobuf:"bytes,4,opt,name=deviceCode,proto3" json:"deviceCode,omitempty"` + // expiration time in seconds for the device code / PKCE flow + ExpiresIn int64 `protobuf:"varint,5,opt,name=expiresIn,proto3" json:"expiresIn,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestExtendAuthSessionResponse) Reset() { + *x = RequestExtendAuthSessionResponse{} + mi := &file_daemon_proto_msgTypes[87] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestExtendAuthSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestExtendAuthSessionResponse) ProtoMessage() {} + +func (x *RequestExtendAuthSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[87] + 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 RequestExtendAuthSessionResponse.ProtoReflect.Descriptor instead. +func (*RequestExtendAuthSessionResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{87} +} + +func (x *RequestExtendAuthSessionResponse) GetVerificationURI() string { + if x != nil { + return x.VerificationURI + } + return "" +} + +func (x *RequestExtendAuthSessionResponse) GetVerificationURIComplete() string { + if x != nil { + return x.VerificationURIComplete + } + return "" +} + +func (x *RequestExtendAuthSessionResponse) GetUserCode() string { + if x != nil { + return x.UserCode + } + return "" +} + +func (x *RequestExtendAuthSessionResponse) GetDeviceCode() string { + if x != nil { + return x.DeviceCode + } + return "" +} + +func (x *RequestExtendAuthSessionResponse) GetExpiresIn() int64 { + if x != nil { + return x.ExpiresIn + } + return 0 +} + +// WaitExtendAuthSessionRequest is sent by the UI after it opens the +// verification URI. The daemon blocks on this call until the user +// completes (or aborts) the SSO step. +type WaitExtendAuthSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // device code returned by RequestExtendAuthSession + DeviceCode string `protobuf:"bytes,1,opt,name=deviceCode,proto3" json:"deviceCode,omitempty"` + // user code for verification + UserCode string `protobuf:"bytes,2,opt,name=userCode,proto3" json:"userCode,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WaitExtendAuthSessionRequest) Reset() { + *x = WaitExtendAuthSessionRequest{} + mi := &file_daemon_proto_msgTypes[88] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WaitExtendAuthSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WaitExtendAuthSessionRequest) ProtoMessage() {} + +func (x *WaitExtendAuthSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[88] + 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 WaitExtendAuthSessionRequest.ProtoReflect.Descriptor instead. +func (*WaitExtendAuthSessionRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{88} +} + +func (x *WaitExtendAuthSessionRequest) GetDeviceCode() string { + if x != nil { + return x.DeviceCode + } + return "" +} + +func (x *WaitExtendAuthSessionRequest) GetUserCode() string { + if x != nil { + return x.UserCode + } + return "" +} + +// WaitExtendAuthSessionResponse carries the refreshed deadline returned +// by the management server. Unset when the management server reports the +// peer is not eligible for session extension. +type WaitExtendAuthSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionExpiresAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=sessionExpiresAt,proto3" json:"sessionExpiresAt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WaitExtendAuthSessionResponse) Reset() { + *x = WaitExtendAuthSessionResponse{} + mi := &file_daemon_proto_msgTypes[89] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WaitExtendAuthSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WaitExtendAuthSessionResponse) ProtoMessage() {} + +func (x *WaitExtendAuthSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[89] + 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 WaitExtendAuthSessionResponse.ProtoReflect.Descriptor instead. +func (*WaitExtendAuthSessionResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{89} +} + +func (x *WaitExtendAuthSessionResponse) GetSessionExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.SessionExpiresAt + } + return nil +} + +// DismissSessionWarningRequest is sent by the UI when the user clicks +// "Dismiss" on the T-WarningLead notification. +type DismissSessionWarningRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DismissSessionWarningRequest) Reset() { + *x = DismissSessionWarningRequest{} + mi := &file_daemon_proto_msgTypes[90] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DismissSessionWarningRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DismissSessionWarningRequest) ProtoMessage() {} + +func (x *DismissSessionWarningRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[90] + 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 DismissSessionWarningRequest.ProtoReflect.Descriptor instead. +func (*DismissSessionWarningRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{90} +} + +// DismissSessionWarningResponse acknowledges the dismissal. Carries no +// payload — the daemon's only obligation is to silence the upcoming +// T-FinalWarningLead fallback for the current deadline. +type DismissSessionWarningResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DismissSessionWarningResponse) Reset() { + *x = DismissSessionWarningResponse{} + mi := &file_daemon_proto_msgTypes[91] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DismissSessionWarningResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DismissSessionWarningResponse) ProtoMessage() {} + +func (x *DismissSessionWarningResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[91] + 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 DismissSessionWarningResponse.ProtoReflect.Descriptor instead. +func (*DismissSessionWarningResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{91} +} + // StartCPUProfileRequest for starting CPU profiling type StartCPUProfileRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -5674,7 +6232,7 @@ type StartCPUProfileRequest struct { func (x *StartCPUProfileRequest) Reset() { *x = StartCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5686,7 +6244,7 @@ func (x *StartCPUProfileRequest) String() string { func (*StartCPUProfileRequest) ProtoMessage() {} func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5699,7 +6257,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{82} + return file_daemon_proto_rawDescGZIP(), []int{92} } // StartCPUProfileResponse confirms CPU profiling has started @@ -5711,7 +6269,7 @@ type StartCPUProfileResponse struct { func (x *StartCPUProfileResponse) Reset() { *x = StartCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5723,7 +6281,7 @@ func (x *StartCPUProfileResponse) String() string { func (*StartCPUProfileResponse) ProtoMessage() {} func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5736,7 +6294,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{83} + return file_daemon_proto_rawDescGZIP(), []int{93} } // StopCPUProfileRequest for stopping CPU profiling @@ -5748,7 +6306,7 @@ type StopCPUProfileRequest struct { func (x *StopCPUProfileRequest) Reset() { *x = StopCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5760,7 +6318,7 @@ func (x *StopCPUProfileRequest) String() string { func (*StopCPUProfileRequest) ProtoMessage() {} func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5773,7 +6331,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{84} + return file_daemon_proto_rawDescGZIP(), []int{94} } // StopCPUProfileResponse confirms CPU profiling has stopped @@ -5785,7 +6343,7 @@ type StopCPUProfileResponse struct { func (x *StopCPUProfileResponse) Reset() { *x = StopCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5797,7 +6355,7 @@ func (x *StopCPUProfileResponse) String() string { func (*StopCPUProfileResponse) ProtoMessage() {} func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5810,7 +6368,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{85} + return file_daemon_proto_rawDescGZIP(), []int{95} } type InstallerResultRequest struct { @@ -5821,7 +6379,7 @@ type InstallerResultRequest struct { func (x *InstallerResultRequest) Reset() { *x = InstallerResultRequest{} - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5833,7 +6391,7 @@ func (x *InstallerResultRequest) String() string { func (*InstallerResultRequest) ProtoMessage() {} func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5846,7 +6404,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead. func (*InstallerResultRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{86} + return file_daemon_proto_rawDescGZIP(), []int{96} } type InstallerResultResponse struct { @@ -5859,7 +6417,7 @@ type InstallerResultResponse struct { func (x *InstallerResultResponse) Reset() { *x = InstallerResultResponse{} - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5871,7 +6429,7 @@ func (x *InstallerResultResponse) String() string { func (*InstallerResultResponse) ProtoMessage() {} func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5884,7 +6442,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead. func (*InstallerResultResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{87} + return file_daemon_proto_rawDescGZIP(), []int{97} } func (x *InstallerResultResponse) GetSuccess() bool { @@ -5917,7 +6475,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5929,7 +6487,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5942,7 +6500,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{88} + return file_daemon_proto_rawDescGZIP(), []int{98} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -6013,7 +6571,7 @@ type ExposeServiceEvent struct { func (x *ExposeServiceEvent) Reset() { *x = ExposeServiceEvent{} - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6025,7 +6583,7 @@ func (x *ExposeServiceEvent) String() string { func (*ExposeServiceEvent) ProtoMessage() {} func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6038,7 +6596,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead. func (*ExposeServiceEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{89} + return file_daemon_proto_rawDescGZIP(), []int{99} } func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event { @@ -6079,7 +6637,7 @@ type ExposeServiceReady struct { func (x *ExposeServiceReady) Reset() { *x = ExposeServiceReady{} - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6091,7 +6649,7 @@ func (x *ExposeServiceReady) String() string { func (*ExposeServiceReady) ProtoMessage() {} func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6104,7 +6662,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead. func (*ExposeServiceReady) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{90} + return file_daemon_proto_rawDescGZIP(), []int{100} } func (x *ExposeServiceReady) GetServiceName() string { @@ -6149,7 +6707,7 @@ type StartCaptureRequest struct { func (x *StartCaptureRequest) Reset() { *x = StartCaptureRequest{} - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6161,7 +6719,7 @@ func (x *StartCaptureRequest) String() string { func (*StartCaptureRequest) ProtoMessage() {} func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6174,7 +6732,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead. func (*StartCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{91} + return file_daemon_proto_rawDescGZIP(), []int{101} } func (x *StartCaptureRequest) GetTextOutput() bool { @@ -6228,7 +6786,7 @@ type CapturePacket struct { func (x *CapturePacket) Reset() { *x = CapturePacket{} - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6240,7 +6798,7 @@ func (x *CapturePacket) String() string { func (*CapturePacket) ProtoMessage() {} func (x *CapturePacket) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6253,7 +6811,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message { // Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead. func (*CapturePacket) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{92} + return file_daemon_proto_rawDescGZIP(), []int{102} } func (x *CapturePacket) GetData() []byte { @@ -6274,7 +6832,7 @@ type StartBundleCaptureRequest struct { func (x *StartBundleCaptureRequest) Reset() { *x = StartBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6286,7 +6844,7 @@ func (x *StartBundleCaptureRequest) String() string { func (*StartBundleCaptureRequest) ProtoMessage() {} func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6299,7 +6857,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{93} + return file_daemon_proto_rawDescGZIP(), []int{103} } func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration { @@ -6317,7 +6875,7 @@ type StartBundleCaptureResponse struct { func (x *StartBundleCaptureResponse) Reset() { *x = StartBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[94] + mi := &file_daemon_proto_msgTypes[104] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6329,7 +6887,7 @@ func (x *StartBundleCaptureResponse) String() string { func (*StartBundleCaptureResponse) ProtoMessage() {} func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[94] + mi := &file_daemon_proto_msgTypes[104] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6342,7 +6900,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{94} + return file_daemon_proto_rawDescGZIP(), []int{104} } type StopBundleCaptureRequest struct { @@ -6353,7 +6911,7 @@ type StopBundleCaptureRequest struct { func (x *StopBundleCaptureRequest) Reset() { *x = StopBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[95] + mi := &file_daemon_proto_msgTypes[105] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6365,7 +6923,7 @@ func (x *StopBundleCaptureRequest) String() string { func (*StopBundleCaptureRequest) ProtoMessage() {} func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[95] + mi := &file_daemon_proto_msgTypes[105] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6378,7 +6936,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{95} + return file_daemon_proto_rawDescGZIP(), []int{105} } type StopBundleCaptureResponse struct { @@ -6389,7 +6947,7 @@ type StopBundleCaptureResponse struct { func (x *StopBundleCaptureResponse) Reset() { *x = StopBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[96] + mi := &file_daemon_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6401,7 +6959,7 @@ func (x *StopBundleCaptureResponse) String() string { func (*StopBundleCaptureResponse) ProtoMessage() {} func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[96] + mi := &file_daemon_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6414,7 +6972,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{96} + return file_daemon_proto_rawDescGZIP(), []int{106} } type PortInfo_Range struct { @@ -6427,7 +6985,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[98] + mi := &file_daemon_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6439,7 +6997,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[98] + mi := &file_daemon_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6557,10 +7115,11 @@ const file_daemon_proto_rawDesc = "" + "\buserCode\x18\x01 \x01(\tR\buserCode\x12\x1a\n" + "\bhostname\x18\x02 \x01(\tR\bhostname\",\n" + "\x14WaitSSOLoginResponse\x12\x14\n" + - "\x05email\x18\x01 \x01(\tR\x05email\"v\n" + + "\x05email\x18\x01 \x01(\tR\x05email\"\x8c\x01\n" + "\tUpRequest\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" + + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01\x12\x14\n" + + "\x05async\x18\x04 \x01(\bR\x05asyncB\x0e\n" + "\f_profileNameB\v\n" + "\t_usernameJ\x04\b\x03\x10\x04\"\f\n" + "\n" + @@ -6569,13 +7128,14 @@ const file_daemon_proto_rawDesc = "" + "\x11getFullPeerStatus\x18\x01 \x01(\bR\x11getFullPeerStatus\x12(\n" + "\x0fshouldRunProbes\x18\x02 \x01(\bR\x0fshouldRunProbes\x12'\n" + "\fwaitForReady\x18\x03 \x01(\bH\x00R\fwaitForReady\x88\x01\x01B\x0f\n" + - "\r_waitForReady\"\x82\x01\n" + + "\r_waitForReady\"\xca\x01\n" + "\x0eStatusResponse\x12\x16\n" + "\x06status\x18\x01 \x01(\tR\x06status\x122\n" + "\n" + "fullStatus\x18\x02 \x01(\v2\x12.daemon.FullStatusR\n" + "fullStatus\x12$\n" + - "\rdaemonVersion\x18\x03 \x01(\tR\rdaemonVersion\"\r\n" + + "\rdaemonVersion\x18\x03 \x01(\tR\rdaemonVersion\x12F\n" + + "\x10sessionExpiresAt\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x10sessionExpiresAt\"\r\n" + "\vDownRequest\"\x0e\n" + "\fDownResponse\"P\n" + "\x10GetConfigRequest\x12 \n" + @@ -6676,7 +7236,7 @@ const file_daemon_proto_rawDesc = "" + "\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" + "\x0eSSHServerState\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + - "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xaf\x04\n" + + "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xdb\x04\n" + "\n" + "FullStatus\x12A\n" + "\x0fmanagementState\x18\x01 \x01(\v2\x17.daemon.ManagementStateR\x0fmanagementState\x125\n" + @@ -6690,7 +7250,8 @@ const file_daemon_proto_rawDesc = "" + "\x06events\x18\a \x03(\v2\x13.daemon.SystemEventR\x06events\x124\n" + "\x15lazyConnectionEnabled\x18\t \x01(\bR\x15lazyConnectionEnabled\x12>\n" + "\x0esshServerState\x18\n" + - " \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\"\x15\n" + + " \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\x12*\n" + + "\x10networksRevision\x18\v \x01(\x04R\x10networksRevision\"\x15\n" + "\x13ListNetworksRequest\"?\n" + "\x14ListNetworksResponse\x12'\n" + "\x06routes\x18\x01 \x03(\v2\x0f.daemon.NetworkR\x06routes\"a\n" + @@ -6726,7 +7287,7 @@ const file_daemon_proto_rawDesc = "" + "\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" + "\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" + "\x17ForwardingRulesResponse\x12,\n" + - "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xb4\x01\n" + + "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x84\x02\n" + "\x12DebugBundleRequest\x12\x1c\n" + "\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" + "\n" + @@ -6736,7 +7297,9 @@ const file_daemon_proto_rawDesc = "" + "\flogFileCount\x18\x05 \x01(\rR\flogFileCount\x12\x1e\n" + "\n" + "cliVersion\x18\x06 \x01(\tR\n" + - "cliVersion\"}\n" + + "cliVersion\x12&\n" + + "\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\x12&\n" + + "\x0eanonymizeLevel\x18\b \x01(\tR\x0eanonymizeLevel\"}\n" + "\x13DebugBundleResponse\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12 \n" + "\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" + @@ -6746,7 +7309,10 @@ const file_daemon_proto_rawDesc = "" + "\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\"<\n" + "\x12SetLogLevelRequest\x12&\n" + "\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\"\x15\n" + - "\x13SetLogLevelResponse\"\x1b\n" + + "\x13SetLogLevelResponse\"*\n" + + "\x14RegisterUILogRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"\x17\n" + + "\x15RegisterUILogResponse\"\x1b\n" + "\x05State\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"\x13\n" + "\x11ListStatesRequest\";\n" + @@ -6935,12 +7501,16 @@ const file_daemon_proto_rawDesc = "" + "\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" + "\f_profileNameB\v\n" + "\t_username\"\x10\n" + - "\x0eLogoutResponse\"\x14\n" + - "\x12GetFeaturesRequest\"\xa3\x01\n" + + "\x0eLogoutResponse\"\x15\n" + + "\x13WailsUIReadyRequest\"\x16\n" + + "\x14WailsUIReadyResponse\"\x14\n" + + "\x12GetFeaturesRequest\"\xf6\x01\n" + "\x13GetFeaturesResponse\x12)\n" + "\x10disable_profiles\x18\x01 \x01(\bR\x0fdisableProfiles\x126\n" + "\x17disable_update_settings\x18\x02 \x01(\bR\x15disableUpdateSettings\x12)\n" + - "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\"3\n" + + "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\x127\n" + + "\x15disable_advanced_view\x18\x04 \x01(\bH\x00R\x13disableAdvancedView\x88\x01\x01B\x18\n" + + "\x16_disable_advanced_view\"3\n" + "\x19MDMManagedFieldsViolation\x12\x16\n" + "\x06fields\x18\x01 \x03(\tR\x06fields\"\x16\n" + "\x14TriggerUpdateRequest\"M\n" + @@ -6955,9 +7525,10 @@ const file_daemon_proto_rawDesc = "" + "sshHostKey\x12\x16\n" + "\x06peerIP\x18\x02 \x01(\tR\x06peerIP\x12\x1a\n" + "\bpeerFQDN\x18\x03 \x01(\tR\bpeerFQDN\x12\x14\n" + - "\x05found\x18\x04 \x01(\bR\x05found\"9\n" + + "\x05found\x18\x04 \x01(\bR\x05found\"k\n" + "\x15RequestJWTAuthRequest\x12\x17\n" + - "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01B\a\n" + + "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01\x120\n" + + "\x13hasGraphicalSession\x18\x02 \x01(\bR\x13hasGraphicalSessionB\a\n" + "\x05_hint\"\x9a\x02\n" + "\x16RequestJWTAuthResponse\x12(\n" + "\x0fverificationURI\x18\x01 \x01(\tR\x0fverificationURI\x128\n" + @@ -6977,7 +7548,28 @@ const file_daemon_proto_rawDesc = "" + "\x14WaitJWTTokenResponse\x12\x14\n" + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1c\n" + "\ttokenType\x18\x02 \x01(\tR\ttokenType\x12\x1c\n" + - "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"\x18\n" + + "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"u\n" + + "\x1fRequestExtendAuthSessionRequest\x12\x17\n" + + "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01\x120\n" + + "\x13hasGraphicalSession\x18\x02 \x01(\bR\x13hasGraphicalSessionB\a\n" + + "\x05_hint\"\xe0\x01\n" + + " RequestExtendAuthSessionResponse\x12(\n" + + "\x0fverificationURI\x18\x01 \x01(\tR\x0fverificationURI\x128\n" + + "\x17verificationURIComplete\x18\x02 \x01(\tR\x17verificationURIComplete\x12\x1a\n" + + "\buserCode\x18\x03 \x01(\tR\buserCode\x12\x1e\n" + + "\n" + + "deviceCode\x18\x04 \x01(\tR\n" + + "deviceCode\x12\x1c\n" + + "\texpiresIn\x18\x05 \x01(\x03R\texpiresIn\"Z\n" + + "\x1cWaitExtendAuthSessionRequest\x12\x1e\n" + + "\n" + + "deviceCode\x18\x01 \x01(\tR\n" + + "deviceCode\x12\x1a\n" + + "\buserCode\x18\x02 \x01(\tR\buserCode\"g\n" + + "\x1dWaitExtendAuthSessionResponse\x12F\n" + + "\x10sessionExpiresAt\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x10sessionExpiresAt\"\x1e\n" + + "\x1cDismissSessionWarningRequest\"\x1f\n" + + "\x1dDismissSessionWarningResponse\"\x18\n" + "\x16StartCPUProfileRequest\"\x19\n" + "\x17StartCPUProfileResponse\"\x17\n" + "\x15StopCPUProfileRequest\"\x18\n" + @@ -7040,12 +7632,13 @@ const file_daemon_proto_rawDesc = "" + "\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" + "\n" + - "EXPOSE_TLS\x10\x042\xff\x17\n" + + "EXPOSE_TLS\x10\x042\xa3\x1c\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" + "\x02Up\x12\x11.daemon.UpRequest\x1a\x12.daemon.UpResponse\"\x00\x129\n" + - "\x06Status\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x00\x123\n" + + "\x06Status\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x00\x12D\n" + + "\x0fSubscribeStatus\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x000\x01\x123\n" + "\x04Down\x12\x13.daemon.DownRequest\x1a\x14.daemon.DownResponse\"\x00\x12B\n" + "\tGetConfig\x12\x18.daemon.GetConfigRequest\x1a\x19.daemon.GetConfigResponse\"\x00\x12K\n" + "\fListNetworks\x12\x1b.daemon.ListNetworksRequest\x1a\x1c.daemon.ListNetworksResponse\"\x00\x12Q\n" + @@ -7067,6 +7660,7 @@ const file_daemon_proto_rawDesc = "" + "\x11StopBundleCapture\x12 .daemon.StopBundleCaptureRequest\x1a!.daemon.StopBundleCaptureResponse\"\x00\x12D\n" + "\x0fSubscribeEvents\x12\x18.daemon.SubscribeRequest\x1a\x13.daemon.SystemEvent\"\x000\x01\x12B\n" + "\tGetEvents\x12\x18.daemon.GetEventsRequest\x1a\x19.daemon.GetEventsResponse\"\x00\x12N\n" + + "\rRegisterUILog\x12\x1c.daemon.RegisterUILogRequest\x1a\x1d.daemon.RegisterUILogResponse\"\x00\x12N\n" + "\rSwitchProfile\x12\x1c.daemon.SwitchProfileRequest\x1a\x1d.daemon.SwitchProfileResponse\"\x00\x12B\n" + "\tSetConfig\x12\x18.daemon.SetConfigRequest\x1a\x19.daemon.SetConfigResponse\"\x00\x12E\n" + "\n" + @@ -7080,11 +7674,15 @@ const file_daemon_proto_rawDesc = "" + "\rTriggerUpdate\x12\x1c.daemon.TriggerUpdateRequest\x1a\x1d.daemon.TriggerUpdateResponse\"\x00\x12Z\n" + "\x11GetPeerSSHHostKey\x12 .daemon.GetPeerSSHHostKeyRequest\x1a!.daemon.GetPeerSSHHostKeyResponse\"\x00\x12Q\n" + "\x0eRequestJWTAuth\x12\x1d.daemon.RequestJWTAuthRequest\x1a\x1e.daemon.RequestJWTAuthResponse\"\x00\x12K\n" + - "\fWaitJWTToken\x12\x1b.daemon.WaitJWTTokenRequest\x1a\x1c.daemon.WaitJWTTokenResponse\"\x00\x12T\n" + + "\fWaitJWTToken\x12\x1b.daemon.WaitJWTTokenRequest\x1a\x1c.daemon.WaitJWTTokenResponse\"\x00\x12o\n" + + "\x18RequestExtendAuthSession\x12'.daemon.RequestExtendAuthSessionRequest\x1a(.daemon.RequestExtendAuthSessionResponse\"\x00\x12f\n" + + "\x15WaitExtendAuthSession\x12$.daemon.WaitExtendAuthSessionRequest\x1a%.daemon.WaitExtendAuthSessionResponse\"\x00\x12f\n" + + "\x15DismissSessionWarning\x12$.daemon.DismissSessionWarningRequest\x1a%.daemon.DismissSessionWarningResponse\"\x00\x12T\n" + "\x0fStartCPUProfile\x12\x1e.daemon.StartCPUProfileRequest\x1a\x1f.daemon.StartCPUProfileResponse\"\x00\x12Q\n" + "\x0eStopCPUProfile\x12\x1d.daemon.StopCPUProfileRequest\x1a\x1e.daemon.StopCPUProfileResponse\"\x00\x12W\n" + "\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00\x12M\n" + - "\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01B\bZ\x06/protob\x06proto3" + "\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01\x12K\n" + + "\fWailsUIReady\x12\x1b.daemon.WailsUIReadyRequest\x1a\x1c.daemon.WailsUIReadyResponse\"\x00B\bZ\x06/protob\x06proto3" var ( file_daemon_proto_rawDescOnce sync.Once @@ -7099,7 +7697,7 @@ func file_daemon_proto_rawDescGZIP() []byte { } var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 100) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 110) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ExposeProtocol)(0), // 1: daemon.ExposeProtocol @@ -7142,195 +7740,219 @@ var file_daemon_proto_goTypes = []any{ (*GetLogLevelResponse)(nil), // 38: daemon.GetLogLevelResponse (*SetLogLevelRequest)(nil), // 39: daemon.SetLogLevelRequest (*SetLogLevelResponse)(nil), // 40: daemon.SetLogLevelResponse - (*State)(nil), // 41: daemon.State - (*ListStatesRequest)(nil), // 42: daemon.ListStatesRequest - (*ListStatesResponse)(nil), // 43: daemon.ListStatesResponse - (*CleanStateRequest)(nil), // 44: daemon.CleanStateRequest - (*CleanStateResponse)(nil), // 45: daemon.CleanStateResponse - (*DeleteStateRequest)(nil), // 46: daemon.DeleteStateRequest - (*DeleteStateResponse)(nil), // 47: daemon.DeleteStateResponse - (*SetSyncResponsePersistenceRequest)(nil), // 48: daemon.SetSyncResponsePersistenceRequest - (*SetSyncResponsePersistenceResponse)(nil), // 49: daemon.SetSyncResponsePersistenceResponse - (*TCPFlags)(nil), // 50: daemon.TCPFlags - (*TracePacketRequest)(nil), // 51: daemon.TracePacketRequest - (*TraceStage)(nil), // 52: daemon.TraceStage - (*TracePacketResponse)(nil), // 53: daemon.TracePacketResponse - (*SubscribeRequest)(nil), // 54: daemon.SubscribeRequest - (*SystemEvent)(nil), // 55: daemon.SystemEvent - (*GetEventsRequest)(nil), // 56: daemon.GetEventsRequest - (*GetEventsResponse)(nil), // 57: daemon.GetEventsResponse - (*SwitchProfileRequest)(nil), // 58: daemon.SwitchProfileRequest - (*SwitchProfileResponse)(nil), // 59: daemon.SwitchProfileResponse - (*SetConfigRequest)(nil), // 60: daemon.SetConfigRequest - (*SetConfigResponse)(nil), // 61: daemon.SetConfigResponse - (*AddProfileRequest)(nil), // 62: daemon.AddProfileRequest - (*AddProfileResponse)(nil), // 63: daemon.AddProfileResponse - (*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 + (*RegisterUILogRequest)(nil), // 41: daemon.RegisterUILogRequest + (*RegisterUILogResponse)(nil), // 42: daemon.RegisterUILogResponse + (*State)(nil), // 43: daemon.State + (*ListStatesRequest)(nil), // 44: daemon.ListStatesRequest + (*ListStatesResponse)(nil), // 45: daemon.ListStatesResponse + (*CleanStateRequest)(nil), // 46: daemon.CleanStateRequest + (*CleanStateResponse)(nil), // 47: daemon.CleanStateResponse + (*DeleteStateRequest)(nil), // 48: daemon.DeleteStateRequest + (*DeleteStateResponse)(nil), // 49: daemon.DeleteStateResponse + (*SetSyncResponsePersistenceRequest)(nil), // 50: daemon.SetSyncResponsePersistenceRequest + (*SetSyncResponsePersistenceResponse)(nil), // 51: daemon.SetSyncResponsePersistenceResponse + (*TCPFlags)(nil), // 52: daemon.TCPFlags + (*TracePacketRequest)(nil), // 53: daemon.TracePacketRequest + (*TraceStage)(nil), // 54: daemon.TraceStage + (*TracePacketResponse)(nil), // 55: daemon.TracePacketResponse + (*SubscribeRequest)(nil), // 56: daemon.SubscribeRequest + (*SystemEvent)(nil), // 57: daemon.SystemEvent + (*GetEventsRequest)(nil), // 58: daemon.GetEventsRequest + (*GetEventsResponse)(nil), // 59: daemon.GetEventsResponse + (*SwitchProfileRequest)(nil), // 60: daemon.SwitchProfileRequest + (*SwitchProfileResponse)(nil), // 61: daemon.SwitchProfileResponse + (*SetConfigRequest)(nil), // 62: daemon.SetConfigRequest + (*SetConfigResponse)(nil), // 63: daemon.SetConfigResponse + (*AddProfileRequest)(nil), // 64: daemon.AddProfileRequest + (*AddProfileResponse)(nil), // 65: daemon.AddProfileResponse + (*RenameProfileRequest)(nil), // 66: daemon.RenameProfileRequest + (*RenameProfileResponse)(nil), // 67: daemon.RenameProfileResponse + (*RemoveProfileRequest)(nil), // 68: daemon.RemoveProfileRequest + (*RemoveProfileResponse)(nil), // 69: daemon.RemoveProfileResponse + (*ListProfilesRequest)(nil), // 70: daemon.ListProfilesRequest + (*ListProfilesResponse)(nil), // 71: daemon.ListProfilesResponse + (*Profile)(nil), // 72: daemon.Profile + (*GetActiveProfileRequest)(nil), // 73: daemon.GetActiveProfileRequest + (*GetActiveProfileResponse)(nil), // 74: daemon.GetActiveProfileResponse + (*LogoutRequest)(nil), // 75: daemon.LogoutRequest + (*LogoutResponse)(nil), // 76: daemon.LogoutResponse + (*WailsUIReadyRequest)(nil), // 77: daemon.WailsUIReadyRequest + (*WailsUIReadyResponse)(nil), // 78: daemon.WailsUIReadyResponse + (*GetFeaturesRequest)(nil), // 79: daemon.GetFeaturesRequest + (*GetFeaturesResponse)(nil), // 80: daemon.GetFeaturesResponse + (*MDMManagedFieldsViolation)(nil), // 81: daemon.MDMManagedFieldsViolation + (*TriggerUpdateRequest)(nil), // 82: daemon.TriggerUpdateRequest + (*TriggerUpdateResponse)(nil), // 83: daemon.TriggerUpdateResponse + (*GetPeerSSHHostKeyRequest)(nil), // 84: daemon.GetPeerSSHHostKeyRequest + (*GetPeerSSHHostKeyResponse)(nil), // 85: daemon.GetPeerSSHHostKeyResponse + (*RequestJWTAuthRequest)(nil), // 86: daemon.RequestJWTAuthRequest + (*RequestJWTAuthResponse)(nil), // 87: daemon.RequestJWTAuthResponse + (*WaitJWTTokenRequest)(nil), // 88: daemon.WaitJWTTokenRequest + (*WaitJWTTokenResponse)(nil), // 89: daemon.WaitJWTTokenResponse + (*RequestExtendAuthSessionRequest)(nil), // 90: daemon.RequestExtendAuthSessionRequest + (*RequestExtendAuthSessionResponse)(nil), // 91: daemon.RequestExtendAuthSessionResponse + (*WaitExtendAuthSessionRequest)(nil), // 92: daemon.WaitExtendAuthSessionRequest + (*WaitExtendAuthSessionResponse)(nil), // 93: daemon.WaitExtendAuthSessionResponse + (*DismissSessionWarningRequest)(nil), // 94: daemon.DismissSessionWarningRequest + (*DismissSessionWarningResponse)(nil), // 95: daemon.DismissSessionWarningResponse + (*StartCPUProfileRequest)(nil), // 96: daemon.StartCPUProfileRequest + (*StartCPUProfileResponse)(nil), // 97: daemon.StartCPUProfileResponse + (*StopCPUProfileRequest)(nil), // 98: daemon.StopCPUProfileRequest + (*StopCPUProfileResponse)(nil), // 99: daemon.StopCPUProfileResponse + (*InstallerResultRequest)(nil), // 100: daemon.InstallerResultRequest + (*InstallerResultResponse)(nil), // 101: daemon.InstallerResultResponse + (*ExposeServiceRequest)(nil), // 102: daemon.ExposeServiceRequest + (*ExposeServiceEvent)(nil), // 103: daemon.ExposeServiceEvent + (*ExposeServiceReady)(nil), // 104: daemon.ExposeServiceReady + (*StartCaptureRequest)(nil), // 105: daemon.StartCaptureRequest + (*CapturePacket)(nil), // 106: daemon.CapturePacket + (*StartBundleCaptureRequest)(nil), // 107: daemon.StartBundleCaptureRequest + (*StartBundleCaptureResponse)(nil), // 108: daemon.StartBundleCaptureResponse + (*StopBundleCaptureRequest)(nil), // 109: daemon.StopBundleCaptureRequest + (*StopBundleCaptureResponse)(nil), // 110: daemon.StopBundleCaptureResponse + nil, // 111: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 112: daemon.PortInfo.Range + nil, // 113: daemon.SystemEvent.MetadataEntry + (*durationpb.Duration)(nil), // 114: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 115: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 104, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 114, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 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 - 18, // 8: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState - 17, // 9: daemon.FullStatus.peers:type_name -> daemon.PeerState - 21, // 10: daemon.FullStatus.relays:type_name -> daemon.RelayState - 22, // 11: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState - 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 - 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 - 0, // 20: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel - 0, // 21: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel - 41, // 22: daemon.ListStatesResponse.states:type_name -> daemon.State - 50, // 23: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags - 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 - 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 - 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 - 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 - 9, // 39: daemon.DaemonService.Up:input_type -> daemon.UpRequest - 11, // 40: daemon.DaemonService.Status:input_type -> daemon.StatusRequest - 13, // 41: daemon.DaemonService.Down:input_type -> daemon.DownRequest - 15, // 42: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest - 26, // 43: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest - 28, // 44: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest - 28, // 45: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest - 4, // 46: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest - 35, // 47: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest - 37, // 48: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest - 39, // 49: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest - 42, // 50: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest - 44, // 51: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest - 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 - 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.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 + 115, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 115, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 115, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 114, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 23, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo + 20, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState + 19, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState + 18, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState + 17, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState + 21, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState + 22, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState + 57, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent + 24, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState + 31, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network + 111, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 112, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 32, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo + 32, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo + 33, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule + 0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel + 0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel + 43, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State + 52, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags + 54, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage + 2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity + 3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category + 115, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 113, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 57, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent + 114, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 72, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile + 115, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 1, // 34: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol + 104, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 114, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration + 114, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration + 30, // 38: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList + 5, // 39: daemon.DaemonService.Login:input_type -> daemon.LoginRequest + 7, // 40: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest + 9, // 41: daemon.DaemonService.Up:input_type -> daemon.UpRequest + 11, // 42: daemon.DaemonService.Status:input_type -> daemon.StatusRequest + 11, // 43: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest + 13, // 44: daemon.DaemonService.Down:input_type -> daemon.DownRequest + 15, // 45: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest + 26, // 46: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest + 28, // 47: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest + 28, // 48: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest + 4, // 49: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest + 35, // 50: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest + 37, // 51: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest + 39, // 52: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest + 44, // 53: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest + 46, // 54: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest + 48, // 55: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest + 50, // 56: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest + 53, // 57: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest + 105, // 58: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest + 107, // 59: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest + 109, // 60: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest + 56, // 61: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest + 58, // 62: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest + 41, // 63: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest + 60, // 64: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest + 62, // 65: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest + 64, // 66: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest + 66, // 67: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest + 68, // 68: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest + 70, // 69: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest + 73, // 70: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest + 75, // 71: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest + 79, // 72: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest + 82, // 73: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 84, // 74: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 86, // 75: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 88, // 76: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 90, // 77: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest + 92, // 78: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest + 94, // 79: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest + 96, // 80: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 98, // 81: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 100, // 82: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 102, // 83: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 77, // 84: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest + 6, // 85: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 8, // 86: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 10, // 87: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 12, // 88: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 12, // 89: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse + 14, // 90: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 16, // 91: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 27, // 92: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 29, // 93: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 29, // 94: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 34, // 95: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 36, // 96: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 38, // 97: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 40, // 98: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 45, // 99: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 47, // 100: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 49, // 101: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 51, // 102: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 55, // 103: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 106, // 104: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket + 108, // 105: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse + 110, // 106: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse + 57, // 107: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 59, // 108: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 42, // 109: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse + 61, // 110: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 63, // 111: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 65, // 112: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 67, // 113: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse + 69, // 114: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 71, // 115: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 74, // 116: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 76, // 117: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 80, // 118: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 83, // 119: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 85, // 120: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 87, // 121: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 89, // 122: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 91, // 123: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse + 93, // 124: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse + 95, // 125: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse + 97, // 126: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 99, // 127: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 101, // 128: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 103, // 129: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 78, // 130: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse + 85, // [85:131] is the sub-list for method output_type + 39, // [39:85] is the sub-list for method input_type + 39, // [39:39] is the sub-list for extension type_name + 39, // [39:39] is the sub-list for extension extendee + 0, // [0:39] is the sub-list for field type_name } func init() { file_daemon_proto_init() } @@ -7345,13 +7967,15 @@ func file_daemon_proto_init() { (*PortInfo_Port)(nil), (*PortInfo_Range_)(nil), } - file_daemon_proto_msgTypes[47].OneofWrappers = []any{} - file_daemon_proto_msgTypes[48].OneofWrappers = []any{} - file_daemon_proto_msgTypes[54].OneofWrappers = []any{} + file_daemon_proto_msgTypes[49].OneofWrappers = []any{} + file_daemon_proto_msgTypes[50].OneofWrappers = []any{} file_daemon_proto_msgTypes[56].OneofWrappers = []any{} - file_daemon_proto_msgTypes[69].OneofWrappers = []any{} - file_daemon_proto_msgTypes[78].OneofWrappers = []any{} - file_daemon_proto_msgTypes[89].OneofWrappers = []any{ + file_daemon_proto_msgTypes[58].OneofWrappers = []any{} + file_daemon_proto_msgTypes[71].OneofWrappers = []any{} + file_daemon_proto_msgTypes[76].OneofWrappers = []any{} + file_daemon_proto_msgTypes[82].OneofWrappers = []any{} + file_daemon_proto_msgTypes[86].OneofWrappers = []any{} + file_daemon_proto_msgTypes[99].OneofWrappers = []any{ (*ExposeServiceEvent_Ready)(nil), } type x struct{} @@ -7360,7 +7984,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: 100, + NumMessages: 110, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.pb.gw.go b/client/proto/daemon.pb.gw.go new file mode 100644 index 000000000..b64dfeea1 --- /dev/null +++ b/client/proto/daemon.pb.gw.go @@ -0,0 +1,2921 @@ +// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT. +// source: daemon.proto + +/* +Package proto is a reverse proxy. + +It translates gRPC into RESTful JSON APIs. +*/ +package proto + +import ( + "context" + "errors" + "io" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/grpc-ecosystem/grpc-gateway/v2/utilities" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// Suppress "imported and not used" errors +var ( + _ codes.Code + _ io.Reader + _ status.Status + _ = errors.New + _ = runtime.String + _ = utilities.NewDoubleArray + _ = metadata.Join +) + +func request_DaemonService_Login_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq LoginRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Login(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Login_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq LoginRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Login(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_WaitSSOLogin_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitSSOLoginRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.WaitSSOLogin(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_WaitSSOLogin_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitSSOLoginRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.WaitSSOLogin(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_Up_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq UpRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Up(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Up_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq UpRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Up(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_Status_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StatusRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Status(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Status_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StatusRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Status(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SubscribeStatus_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_SubscribeStatusClient, runtime.ServerMetadata, error) { + var ( + protoReq StatusRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + stream, err := client.SubscribeStatus(ctx, &protoReq) + if err != nil { + return nil, metadata, err + } + header, err := stream.Header() + if err != nil { + return nil, metadata, err + } + metadata.HeaderMD = header + return stream, metadata, nil +} + +func request_DaemonService_Down_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DownRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Down(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Down_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DownRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Down(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetConfig_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetConfigRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetConfig(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetConfig_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetConfigRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetConfig(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ListNetworks_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListNetworks(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_ListNetworks_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListNetworks(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SelectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SelectNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SelectNetworks(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SelectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SelectNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SelectNetworks(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_DeselectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SelectNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.DeselectNetworks(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_DeselectNetworks_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SelectNetworksRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.DeselectNetworks(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ForwardingRules_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq EmptyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ForwardingRules(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_ForwardingRules_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq EmptyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ForwardingRules(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_DebugBundle_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DebugBundleRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.DebugBundle(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_DebugBundle_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DebugBundleRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.DebugBundle(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetLogLevelRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetLogLevel(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetLogLevelRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetLogLevel(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetLogLevelRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SetLogLevel(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SetLogLevel_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetLogLevelRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SetLogLevel(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ListStates_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListStatesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListStates(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_ListStates_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListStatesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListStates(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_CleanState_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CleanStateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.CleanState(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_CleanState_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CleanStateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.CleanState(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_DeleteState_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DeleteStateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.DeleteState(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_DeleteState_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DeleteStateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.DeleteState(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SetSyncResponsePersistence_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetSyncResponsePersistenceRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SetSyncResponsePersistence(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SetSyncResponsePersistence_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetSyncResponsePersistenceRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SetSyncResponsePersistence(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_TracePacket_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq TracePacketRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.TracePacket(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_TracePacket_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq TracePacketRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.TracePacket(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_StartCapture_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_StartCaptureClient, runtime.ServerMetadata, error) { + var ( + protoReq StartCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + stream, err := client.StartCapture(ctx, &protoReq) + if err != nil { + return nil, metadata, err + } + header, err := stream.Header() + if err != nil { + return nil, metadata, err + } + metadata.HeaderMD = header + return stream, metadata, nil +} + +func request_DaemonService_StartBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StartBundleCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.StartBundleCapture(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_StartBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StartBundleCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.StartBundleCapture(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_StopBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StopBundleCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.StopBundleCapture(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_StopBundleCapture_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StopBundleCaptureRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.StopBundleCapture(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SubscribeEvents_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_SubscribeEventsClient, runtime.ServerMetadata, error) { + var ( + protoReq SubscribeRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + stream, err := client.SubscribeEvents(ctx, &protoReq) + if err != nil { + return nil, metadata, err + } + header, err := stream.Header() + if err != nil { + return nil, metadata, err + } + metadata.HeaderMD = header + return stream, metadata, nil +} + +func request_DaemonService_GetEvents_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetEventsRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetEvents(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetEvents_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetEventsRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetEvents(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_RegisterUILog_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RegisterUILogRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.RegisterUILog(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_RegisterUILog_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RegisterUILogRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.RegisterUILog(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SwitchProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SwitchProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SwitchProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SwitchProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SwitchProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SwitchProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_SetConfig_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetConfigRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.SetConfig(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_SetConfig_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetConfigRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.SetConfig(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_AddProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq AddProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.AddProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_AddProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq AddProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.AddProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_RenameProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RenameProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.RenameProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_RenameProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RenameProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.RenameProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_RemoveProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RemoveProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.RemoveProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_RemoveProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RemoveProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.RemoveProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ListProfiles_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListProfilesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListProfiles(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_ListProfiles_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListProfilesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListProfiles(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetActiveProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetActiveProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetActiveProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetActiveProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetActiveProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetActiveProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_Logout_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq LogoutRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.Logout(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_Logout_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq LogoutRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.Logout(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetFeatures_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetFeaturesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetFeatures(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetFeatures_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetFeaturesRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetFeatures(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_TriggerUpdate_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq TriggerUpdateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.TriggerUpdate(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_TriggerUpdate_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq TriggerUpdateRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.TriggerUpdate(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetPeerSSHHostKey_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetPeerSSHHostKeyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetPeerSSHHostKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetPeerSSHHostKey_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetPeerSSHHostKeyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetPeerSSHHostKey(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_RequestJWTAuth_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RequestJWTAuthRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.RequestJWTAuth(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_RequestJWTAuth_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RequestJWTAuthRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.RequestJWTAuth(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_WaitJWTToken_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitJWTTokenRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.WaitJWTToken(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_WaitJWTToken_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitJWTTokenRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.WaitJWTToken(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_RequestExtendAuthSession_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RequestExtendAuthSessionRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.RequestExtendAuthSession(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_RequestExtendAuthSession_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RequestExtendAuthSessionRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.RequestExtendAuthSession(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_WaitExtendAuthSession_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitExtendAuthSessionRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.WaitExtendAuthSession(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_WaitExtendAuthSession_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WaitExtendAuthSessionRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.WaitExtendAuthSession(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_DismissSessionWarning_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DismissSessionWarningRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.DismissSessionWarning(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_DismissSessionWarning_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DismissSessionWarningRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.DismissSessionWarning(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_StartCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StartCPUProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.StartCPUProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_StartCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StartCPUProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.StartCPUProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_StopCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StopCPUProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.StopCPUProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_StopCPUProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq StopCPUProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.StopCPUProfile(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_GetInstallerResult_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq InstallerResultRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.GetInstallerResult(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_GetInstallerResult_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq InstallerResultRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.GetInstallerResult(ctx, &protoReq) + return msg, metadata, err +} + +func request_DaemonService_ExposeService_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (DaemonService_ExposeServiceClient, runtime.ServerMetadata, error) { + var ( + protoReq ExposeServiceRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + stream, err := client.ExposeService(ctx, &protoReq) + if err != nil { + return nil, metadata, err + } + header, err := stream.Header() + if err != nil { + return nil, metadata, err + } + metadata.HeaderMD = header + return stream, metadata, nil +} + +func request_DaemonService_WailsUIReady_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WailsUIReadyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.WailsUIReady(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_WailsUIReady_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq WailsUIReadyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.WailsUIReady(ctx, &protoReq) + return msg, metadata, err +} + +// RegisterDaemonServiceHandlerServer registers the http handlers for service DaemonService to "mux". +// UnaryRPC :call DaemonServiceServer directly. +// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. +// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterDaemonServiceHandlerFromEndpoint instead. +// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call. +func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server DaemonServiceServer) error { + mux.Handle(http.MethodPost, pattern_DaemonService_Login_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Login", runtime.WithHTTPPathPattern("/daemon.DaemonService/Login")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Login_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Login_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitSSOLogin_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/WaitSSOLogin", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitSSOLogin")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_WaitSSOLogin_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitSSOLogin_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Up_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Up", runtime.WithHTTPPathPattern("/daemon.DaemonService/Up")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Up_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Up_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Status_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Status", runtime.WithHTTPPathPattern("/daemon.DaemonService/Status")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Status_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Status_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + mux.Handle(http.MethodPost, pattern_DaemonService_SubscribeStatus_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport") + _, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Down_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Down", runtime.WithHTTPPathPattern("/daemon.DaemonService/Down")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Down_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Down_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetConfig")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetConfig_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ListNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_ListNetworks_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SelectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SelectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/SelectNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SelectNetworks_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SelectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DeselectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DeselectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeselectNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_DeselectNetworks_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DeselectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ForwardingRules_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ForwardingRules", runtime.WithHTTPPathPattern("/daemon.DaemonService/ForwardingRules")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_ForwardingRules_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ForwardingRules_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DebugBundle_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DebugBundle", runtime.WithHTTPPathPattern("/daemon.DaemonService/DebugBundle")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_DebugBundle_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DebugBundle_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetLogLevel")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetLogLevel_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetLogLevel")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SetLogLevel_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListStates_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ListStates", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListStates")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_ListStates_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListStates_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_CleanState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/CleanState", runtime.WithHTTPPathPattern("/daemon.DaemonService/CleanState")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_CleanState_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_CleanState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DeleteState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DeleteState", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeleteState")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_DeleteState_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DeleteState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetSyncResponsePersistence_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SetSyncResponsePersistence", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetSyncResponsePersistence")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SetSyncResponsePersistence_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetSyncResponsePersistence_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_TracePacket_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/TracePacket", runtime.WithHTTPPathPattern("/daemon.DaemonService/TracePacket")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_TracePacket_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_TracePacket_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + mux.Handle(http.MethodPost, pattern_DaemonService_StartCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport") + _, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StartBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartBundleCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_StartBundleCapture_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StopBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StopBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopBundleCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_StopBundleCapture_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StopBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + mux.Handle(http.MethodPost, pattern_DaemonService_SubscribeEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport") + _, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetEvents", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetEvents")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetEvents_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetEvents_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RegisterUILog_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RegisterUILog", runtime.WithHTTPPathPattern("/daemon.DaemonService/RegisterUILog")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_RegisterUILog_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RegisterUILog_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SwitchProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SwitchProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/SwitchProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SwitchProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SwitchProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/SetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_SetConfig_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_AddProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/AddProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_AddProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_AddProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RenameProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RenameProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RenameProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_RenameProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RenameProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RemoveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RemoveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RemoveProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_RemoveProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RemoveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListProfiles_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ListProfiles", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListProfiles")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_ListProfiles_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListProfiles_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetActiveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetActiveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetActiveProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetActiveProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/Logout", runtime.WithHTTPPathPattern("/daemon.DaemonService/Logout")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_Logout_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Logout_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetFeatures_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetFeatures", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetFeatures")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetFeatures_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetFeatures_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_TriggerUpdate_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/TriggerUpdate", runtime.WithHTTPPathPattern("/daemon.DaemonService/TriggerUpdate")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_TriggerUpdate_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_TriggerUpdate_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetPeerSSHHostKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetPeerSSHHostKey", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetPeerSSHHostKey")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetPeerSSHHostKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetPeerSSHHostKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RequestJWTAuth_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RequestJWTAuth", runtime.WithHTTPPathPattern("/daemon.DaemonService/RequestJWTAuth")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_RequestJWTAuth_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RequestJWTAuth_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitJWTToken_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/WaitJWTToken", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitJWTToken")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_WaitJWTToken_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitJWTToken_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RequestExtendAuthSession_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/RequestExtendAuthSession", runtime.WithHTTPPathPattern("/daemon.DaemonService/RequestExtendAuthSession")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_RequestExtendAuthSession_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RequestExtendAuthSession_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitExtendAuthSession_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/WaitExtendAuthSession", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitExtendAuthSession")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_WaitExtendAuthSession_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitExtendAuthSession_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DismissSessionWarning_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/DismissSessionWarning", runtime.WithHTTPPathPattern("/daemon.DaemonService/DismissSessionWarning")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_DismissSessionWarning_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DismissSessionWarning_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StartCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartCPUProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_StartCPUProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StopCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/StopCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopCPUProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_StopCPUProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StopCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetInstallerResult_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/GetInstallerResult", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetInstallerResult")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_GetInstallerResult_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetInstallerResult_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + mux.Handle(http.MethodPost, pattern_DaemonService_ExposeService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + err := status.Error(codes.Unimplemented, "streaming calls are not yet supported in the in-process transport") + _, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WailsUIReady_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/WailsUIReady", runtime.WithHTTPPathPattern("/daemon.DaemonService/WailsUIReady")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_WailsUIReady_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WailsUIReady_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + return nil +} + +// RegisterDaemonServiceHandlerFromEndpoint is same as RegisterDaemonServiceHandler but +// automatically dials to "endpoint" and closes the connection when "ctx" gets done. +func RegisterDaemonServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { + conn, err := grpc.NewClient(endpoint, opts...) + if err != nil { + return err + } + defer func() { + if err != nil { + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + return + } + go func() { + <-ctx.Done() + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + }() + }() + return RegisterDaemonServiceHandler(ctx, mux, conn) +} + +// RegisterDaemonServiceHandler registers the http handlers for service DaemonService to "mux". +// The handlers forward requests to the grpc endpoint over "conn". +func RegisterDaemonServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + return RegisterDaemonServiceHandlerClient(ctx, mux, NewDaemonServiceClient(conn)) +} + +// RegisterDaemonServiceHandlerClient registers the http handlers for service DaemonService +// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "DaemonServiceClient". +// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "DaemonServiceClient" +// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in +// "DaemonServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares. +func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client DaemonServiceClient) error { + mux.Handle(http.MethodPost, pattern_DaemonService_Login_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Login", runtime.WithHTTPPathPattern("/daemon.DaemonService/Login")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Login_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Login_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitSSOLogin_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/WaitSSOLogin", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitSSOLogin")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_WaitSSOLogin_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitSSOLogin_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Up_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Up", runtime.WithHTTPPathPattern("/daemon.DaemonService/Up")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Up_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Up_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Status_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Status", runtime.WithHTTPPathPattern("/daemon.DaemonService/Status")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Status_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Status_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SubscribeStatus_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SubscribeStatus", runtime.WithHTTPPathPattern("/daemon.DaemonService/SubscribeStatus")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SubscribeStatus_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SubscribeStatus_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Down_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Down", runtime.WithHTTPPathPattern("/daemon.DaemonService/Down")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Down_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Down_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetConfig")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetConfig_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ListNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ListNetworks_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SelectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SelectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/SelectNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SelectNetworks_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SelectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DeselectNetworks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DeselectNetworks", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeselectNetworks")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_DeselectNetworks_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DeselectNetworks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ForwardingRules_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ForwardingRules", runtime.WithHTTPPathPattern("/daemon.DaemonService/ForwardingRules")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ForwardingRules_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ForwardingRules_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DebugBundle_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DebugBundle", runtime.WithHTTPPathPattern("/daemon.DaemonService/DebugBundle")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_DebugBundle_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DebugBundle_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetLogLevel")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetLogLevel_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetLogLevel_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SetLogLevel", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetLogLevel")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SetLogLevel_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetLogLevel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListStates_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ListStates", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListStates")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ListStates_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListStates_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_CleanState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/CleanState", runtime.WithHTTPPathPattern("/daemon.DaemonService/CleanState")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_CleanState_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_CleanState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DeleteState_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DeleteState", runtime.WithHTTPPathPattern("/daemon.DaemonService/DeleteState")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_DeleteState_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DeleteState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetSyncResponsePersistence_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SetSyncResponsePersistence", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetSyncResponsePersistence")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SetSyncResponsePersistence_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetSyncResponsePersistence_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_TracePacket_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/TracePacket", runtime.WithHTTPPathPattern("/daemon.DaemonService/TracePacket")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_TracePacket_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_TracePacket_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StartCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StartCapture_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartCapture_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StartBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartBundleCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StartBundleCapture_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StopBundleCapture_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StopBundleCapture", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopBundleCapture")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StopBundleCapture_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StopBundleCapture_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SubscribeEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SubscribeEvents", runtime.WithHTTPPathPattern("/daemon.DaemonService/SubscribeEvents")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SubscribeEvents_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SubscribeEvents_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetEvents_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetEvents", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetEvents")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetEvents_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetEvents_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RegisterUILog_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RegisterUILog", runtime.WithHTTPPathPattern("/daemon.DaemonService/RegisterUILog")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_RegisterUILog_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RegisterUILog_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SwitchProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SwitchProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/SwitchProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SwitchProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SwitchProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_SetConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/SetConfig", runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_SetConfig_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_SetConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_AddProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/AddProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_AddProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_AddProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RenameProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RenameProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RenameProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_RenameProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RenameProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RemoveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RemoveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/RemoveProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_RemoveProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RemoveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ListProfiles_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ListProfiles", runtime.WithHTTPPathPattern("/daemon.DaemonService/ListProfiles")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ListProfiles_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ListProfiles_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetActiveProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetActiveProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetActiveProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetActiveProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/Logout", runtime.WithHTTPPathPattern("/daemon.DaemonService/Logout")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_Logout_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_Logout_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetFeatures_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetFeatures", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetFeatures")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetFeatures_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetFeatures_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_TriggerUpdate_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/TriggerUpdate", runtime.WithHTTPPathPattern("/daemon.DaemonService/TriggerUpdate")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_TriggerUpdate_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_TriggerUpdate_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetPeerSSHHostKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetPeerSSHHostKey", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetPeerSSHHostKey")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetPeerSSHHostKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetPeerSSHHostKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RequestJWTAuth_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RequestJWTAuth", runtime.WithHTTPPathPattern("/daemon.DaemonService/RequestJWTAuth")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_RequestJWTAuth_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RequestJWTAuth_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitJWTToken_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/WaitJWTToken", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitJWTToken")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_WaitJWTToken_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitJWTToken_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_RequestExtendAuthSession_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/RequestExtendAuthSession", runtime.WithHTTPPathPattern("/daemon.DaemonService/RequestExtendAuthSession")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_RequestExtendAuthSession_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_RequestExtendAuthSession_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WaitExtendAuthSession_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/WaitExtendAuthSession", runtime.WithHTTPPathPattern("/daemon.DaemonService/WaitExtendAuthSession")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_WaitExtendAuthSession_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WaitExtendAuthSession_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_DismissSessionWarning_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/DismissSessionWarning", runtime.WithHTTPPathPattern("/daemon.DaemonService/DismissSessionWarning")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_DismissSessionWarning_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_DismissSessionWarning_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StartCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StartCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StartCPUProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StartCPUProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StartCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_StopCPUProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/StopCPUProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/StopCPUProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_StopCPUProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_StopCPUProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_GetInstallerResult_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/GetInstallerResult", runtime.WithHTTPPathPattern("/daemon.DaemonService/GetInstallerResult")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_GetInstallerResult_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_GetInstallerResult_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_ExposeService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ExposeService", runtime.WithHTTPPathPattern("/daemon.DaemonService/ExposeService")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ExposeService_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ExposeService_0(annotatedContext, mux, outboundMarshaler, w, req, func() (proto.Message, error) { return resp.Recv() }, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_DaemonService_WailsUIReady_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/WailsUIReady", runtime.WithHTTPPathPattern("/daemon.DaemonService/WailsUIReady")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_WailsUIReady_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_WailsUIReady_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + return nil +} + +var ( + pattern_DaemonService_Login_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Login"}, "")) + pattern_DaemonService_WaitSSOLogin_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WaitSSOLogin"}, "")) + pattern_DaemonService_Up_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Up"}, "")) + pattern_DaemonService_Status_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Status"}, "")) + pattern_DaemonService_SubscribeStatus_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SubscribeStatus"}, "")) + pattern_DaemonService_Down_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Down"}, "")) + pattern_DaemonService_GetConfig_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetConfig"}, "")) + pattern_DaemonService_ListNetworks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListNetworks"}, "")) + pattern_DaemonService_SelectNetworks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SelectNetworks"}, "")) + pattern_DaemonService_DeselectNetworks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DeselectNetworks"}, "")) + pattern_DaemonService_ForwardingRules_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ForwardingRules"}, "")) + pattern_DaemonService_DebugBundle_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DebugBundle"}, "")) + pattern_DaemonService_GetLogLevel_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetLogLevel"}, "")) + pattern_DaemonService_SetLogLevel_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SetLogLevel"}, "")) + pattern_DaemonService_ListStates_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListStates"}, "")) + pattern_DaemonService_CleanState_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "CleanState"}, "")) + pattern_DaemonService_DeleteState_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DeleteState"}, "")) + pattern_DaemonService_SetSyncResponsePersistence_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SetSyncResponsePersistence"}, "")) + pattern_DaemonService_TracePacket_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "TracePacket"}, "")) + pattern_DaemonService_StartCapture_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StartCapture"}, "")) + pattern_DaemonService_StartBundleCapture_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StartBundleCapture"}, "")) + pattern_DaemonService_StopBundleCapture_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StopBundleCapture"}, "")) + pattern_DaemonService_SubscribeEvents_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SubscribeEvents"}, "")) + pattern_DaemonService_GetEvents_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetEvents"}, "")) + pattern_DaemonService_RegisterUILog_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RegisterUILog"}, "")) + pattern_DaemonService_SwitchProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SwitchProfile"}, "")) + pattern_DaemonService_SetConfig_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "SetConfig"}, "")) + pattern_DaemonService_AddProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "AddProfile"}, "")) + pattern_DaemonService_RenameProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RenameProfile"}, "")) + pattern_DaemonService_RemoveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RemoveProfile"}, "")) + pattern_DaemonService_ListProfiles_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListProfiles"}, "")) + pattern_DaemonService_GetActiveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetActiveProfile"}, "")) + pattern_DaemonService_Logout_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Logout"}, "")) + pattern_DaemonService_GetFeatures_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetFeatures"}, "")) + pattern_DaemonService_TriggerUpdate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "TriggerUpdate"}, "")) + pattern_DaemonService_GetPeerSSHHostKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetPeerSSHHostKey"}, "")) + pattern_DaemonService_RequestJWTAuth_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RequestJWTAuth"}, "")) + pattern_DaemonService_WaitJWTToken_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WaitJWTToken"}, "")) + pattern_DaemonService_RequestExtendAuthSession_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RequestExtendAuthSession"}, "")) + pattern_DaemonService_WaitExtendAuthSession_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WaitExtendAuthSession"}, "")) + pattern_DaemonService_DismissSessionWarning_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "DismissSessionWarning"}, "")) + pattern_DaemonService_StartCPUProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StartCPUProfile"}, "")) + pattern_DaemonService_StopCPUProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "StopCPUProfile"}, "")) + pattern_DaemonService_GetInstallerResult_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetInstallerResult"}, "")) + pattern_DaemonService_ExposeService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ExposeService"}, "")) + pattern_DaemonService_WailsUIReady_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "WailsUIReady"}, "")) +) + +var ( + forward_DaemonService_Login_0 = runtime.ForwardResponseMessage + forward_DaemonService_WaitSSOLogin_0 = runtime.ForwardResponseMessage + forward_DaemonService_Up_0 = runtime.ForwardResponseMessage + forward_DaemonService_Status_0 = runtime.ForwardResponseMessage + forward_DaemonService_SubscribeStatus_0 = runtime.ForwardResponseStream + forward_DaemonService_Down_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetConfig_0 = runtime.ForwardResponseMessage + forward_DaemonService_ListNetworks_0 = runtime.ForwardResponseMessage + forward_DaemonService_SelectNetworks_0 = runtime.ForwardResponseMessage + forward_DaemonService_DeselectNetworks_0 = runtime.ForwardResponseMessage + forward_DaemonService_ForwardingRules_0 = runtime.ForwardResponseMessage + forward_DaemonService_DebugBundle_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetLogLevel_0 = runtime.ForwardResponseMessage + forward_DaemonService_SetLogLevel_0 = runtime.ForwardResponseMessage + forward_DaemonService_ListStates_0 = runtime.ForwardResponseMessage + forward_DaemonService_CleanState_0 = runtime.ForwardResponseMessage + forward_DaemonService_DeleteState_0 = runtime.ForwardResponseMessage + forward_DaemonService_SetSyncResponsePersistence_0 = runtime.ForwardResponseMessage + forward_DaemonService_TracePacket_0 = runtime.ForwardResponseMessage + forward_DaemonService_StartCapture_0 = runtime.ForwardResponseStream + forward_DaemonService_StartBundleCapture_0 = runtime.ForwardResponseMessage + forward_DaemonService_StopBundleCapture_0 = runtime.ForwardResponseMessage + forward_DaemonService_SubscribeEvents_0 = runtime.ForwardResponseStream + forward_DaemonService_GetEvents_0 = runtime.ForwardResponseMessage + forward_DaemonService_RegisterUILog_0 = runtime.ForwardResponseMessage + forward_DaemonService_SwitchProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_SetConfig_0 = runtime.ForwardResponseMessage + forward_DaemonService_AddProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_RenameProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_RemoveProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_ListProfiles_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetActiveProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_Logout_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetFeatures_0 = runtime.ForwardResponseMessage + forward_DaemonService_TriggerUpdate_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetPeerSSHHostKey_0 = runtime.ForwardResponseMessage + forward_DaemonService_RequestJWTAuth_0 = runtime.ForwardResponseMessage + forward_DaemonService_WaitJWTToken_0 = runtime.ForwardResponseMessage + forward_DaemonService_RequestExtendAuthSession_0 = runtime.ForwardResponseMessage + forward_DaemonService_WaitExtendAuthSession_0 = runtime.ForwardResponseMessage + forward_DaemonService_DismissSessionWarning_0 = runtime.ForwardResponseMessage + forward_DaemonService_StartCPUProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_StopCPUProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_GetInstallerResult_0 = runtime.ForwardResponseMessage + forward_DaemonService_ExposeService_0 = runtime.ForwardResponseStream + forward_DaemonService_WailsUIReady_0 = runtime.ForwardResponseMessage +) diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index c1e3fe513..a3e3f4500 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -24,6 +24,12 @@ service DaemonService { // Status of the service. rpc Status(StatusRequest) returns (StatusResponse) {} + // SubscribeStatus pushes a fresh StatusResponse on connection state + // changes (Connected / Disconnected / Connecting / address change / + // peers list change). The first message on the stream is the current + // snapshot, so a freshly-subscribed UI doesn't need to also call Status. + rpc SubscribeStatus(StatusRequest) returns (stream StatusResponse) {} + // Down stops engine work in the daemon. rpc Down(DownRequest) returns (DownResponse) {} @@ -79,6 +85,11 @@ service DaemonService { rpc GetEvents(GetEventsRequest) returns (GetEventsResponse) {} + // RegisterUILog records the desktop UI's absolute log path so the daemon's + // debug bundle can collect it (the daemon runs as root and can't resolve the + // user's config dir). + rpc RegisterUILog(RegisterUILogRequest) returns (RegisterUILogResponse) {} + rpc SwitchProfile(SwitchProfileRequest) returns (SwitchProfileResponse) {} rpc SetConfig(SetConfigRequest) returns (SetConfigResponse) {} @@ -111,6 +122,25 @@ service DaemonService { // WaitJWTToken waits for JWT authentication completion rpc WaitJWTToken(WaitJWTTokenRequest) returns (WaitJWTTokenResponse) {} + // RequestExtendAuthSession initiates an SSO session-extension flow. + // The daemon prepares a PKCE/device-code request against the IdP and + // returns the verification URI; the UI is expected to open it. The flow + // state is kept in the daemon until WaitExtendAuthSession completes it. + rpc RequestExtendAuthSession(RequestExtendAuthSessionRequest) returns (RequestExtendAuthSessionResponse) {} + + // WaitExtendAuthSession blocks until the user finishes the SSO step + // started by RequestExtendAuthSession, then forwards the resulting JWT + // to the management server's ExtendAuthSession RPC. Returns the new + // session expiry deadline. The tunnel stays up the entire time. + rpc WaitExtendAuthSession(WaitExtendAuthSessionRequest) returns (WaitExtendAuthSessionResponse) {} + + // DismissSessionWarning records that the user clicked "Dismiss" on the + // T-WarningLead interactive notification, suppressing the auto-opened + // SessionAboutToExpire dialog that would otherwise fire at + // T-FinalWarningLead for the current deadline. Idempotent and best-effort: + // a missed call only means the fallback dialog will still appear. + rpc DismissSessionWarning(DismissSessionWarningRequest) returns (DismissSessionWarningResponse) {} + // StartCPUProfile starts CPU profiling in the daemon rpc StartCPUProfile(StartCPUProfileRequest) returns (StartCPUProfileResponse) {} @@ -121,6 +151,11 @@ service DaemonService { // ExposeService exposes a local port via the NetBird reverse proxy rpc ExposeService(ExposeServiceRequest) returns (stream ExposeServiceEvent) {} + + // WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI + // only cares whether the daemon implements it: an Unimplemented response + // means the daemon predates this UI and is too old to drive it. + rpc WailsUIReady(WailsUIReadyRequest) returns (WailsUIReadyResponse) {} } @@ -229,6 +264,12 @@ message UpRequest { optional string profileName = 1; optional string username = 2; reserved 3; + // async instructs the daemon to start the connection attempt and return + // immediately without waiting for the engine to become ready. Status updates + // are delivered via the SubscribeStatus stream. When false (the default) the + // RPC blocks until the engine is running or gives up, which is the behaviour + // needed by the CLI. + bool async = 4; } message UpResponse {} @@ -246,6 +287,10 @@ message StatusResponse{ FullStatus fullStatus = 2; // NetBird daemon version string daemonVersion = 3; + // Absolute UTC instant at which the peer's SSO session expires. + // Unset when the peer is not SSO-registered or login expiration is disabled. + // The UI derives "warning active" from this value and its own clock. + google.protobuf.Timestamp sessionExpiresAt = 4; } message DownRequest {} @@ -421,6 +466,12 @@ message FullStatus { bool lazyConnectionEnabled = 9; SSHServerState sshServerState = 10; + + // networksRevision bumps whenever the set of routed networks (route and + // exit-node candidates) or their selected state changes. The UI fingerprints + // on it to know when to re-fetch ListNetworks via the push stream, instead + // of polling on every status snapshot. + uint64 networksRevision = 11; } // Networks @@ -485,6 +536,15 @@ message DebugBundleRequest { string uploadURL = 4; uint32 logFileCount = 5; string cliVersion = 6; + // uploadInsecure allows uploading to an http endpoint or one with an + // untrusted TLS certificate. Restricted to privileged callers; for + // self-hosted upload servers. + bool uploadInsecure = 7; + // anonymizeLevel selects how much the anonymizer redacts: "default" + // (or empty) keeps internal IP ranges, "strict" also anonymizes them. + // Unknown values are treated as "strict". Only meaningful with anonymize; + // "strict" implies it. + string anonymizeLevel = 8; } message DebugBundleResponse { @@ -518,6 +578,13 @@ message SetLogLevelRequest { message SetLogLevelResponse { } +message RegisterUILogRequest { + string path = 1; +} + +message RegisterUILogResponse { +} + // State represents a daemon state entry message State { string name = 1; @@ -771,12 +838,22 @@ message LogoutRequest { message LogoutResponse {} +message WailsUIReadyRequest {} + +message WailsUIReadyResponse {} + message GetFeaturesRequest{} message GetFeaturesResponse{ bool disable_profiles = 1; bool disable_update_settings = 2; bool disable_networks = 3; + // disableAdvancedView gates the upcoming UI revision's advanced + // section. Tristate: unset = no MDM directive, the UI applies its + // own default; true = MDM enforces disable; false = MDM enforces + // enable. Sourced exclusively from the MDM policy — no CLI / + // config flag backs this value. + optional bool disable_advanced_view = 4; } // MDMManagedFieldsViolation is attached as a gRPC error detail on a @@ -817,6 +894,10 @@ message GetPeerSSHHostKeyResponse { message RequestJWTAuthRequest { // hint for OIDC login_hint parameter (typically email address) optional string hint = 1; + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + bool hasGraphicalSession = 2; } // RequestJWTAuthResponse contains authentication flow information @@ -855,6 +936,59 @@ message WaitJWTTokenResponse { int64 expiresIn = 3; } +// RequestExtendAuthSessionRequest kicks off the session-extension SSO flow. +message RequestExtendAuthSessionRequest { + // Optional OIDC login_hint (typically the user's email) to pre-fill the + // IdP login form. + optional string hint = 1; + // hasGraphicalSession tells the daemon that the caller has a graphical session, + // which decides whether PKCE or the device code flow is preferred. The daemon + // cannot detect this itself: it does not inherit the session environment. + bool hasGraphicalSession = 2; +} + +// RequestExtendAuthSessionResponse carries the verification URI the UI +// should open in a browser. The daemon retains the flow state and resolves +// it via WaitExtendAuthSession. +message RequestExtendAuthSessionResponse { + // verification URI for the user to open in the browser + string verificationURI = 1; + // complete verification URI (with embedded user code) + string verificationURIComplete = 2; + // user code to enter on verification URI (for device-code flows) + string userCode = 3; + // device code for matching the WaitExtendAuthSession call to this flow + string deviceCode = 4; + // expiration time in seconds for the device code / PKCE flow + int64 expiresIn = 5; +} + +// WaitExtendAuthSessionRequest is sent by the UI after it opens the +// verification URI. The daemon blocks on this call until the user +// completes (or aborts) the SSO step. +message WaitExtendAuthSessionRequest { + // device code returned by RequestExtendAuthSession + string deviceCode = 1; + // user code for verification + string userCode = 2; +} + +// WaitExtendAuthSessionResponse carries the refreshed deadline returned +// by the management server. Unset when the management server reports the +// peer is not eligible for session extension. +message WaitExtendAuthSessionResponse { + google.protobuf.Timestamp sessionExpiresAt = 1; +} + +// DismissSessionWarningRequest is sent by the UI when the user clicks +// "Dismiss" on the T-WarningLead notification. +message DismissSessionWarningRequest {} + +// DismissSessionWarningResponse acknowledges the dismissal. Carries no +// payload — the daemon's only obligation is to silence the upcoming +// T-FinalWarningLead fallback for the current deadline. +message DismissSessionWarningResponse {} + // StartCPUProfileRequest for starting CPU profiling message StartCPUProfileRequest {} diff --git a/client/proto/daemon_gateway_test.go b/client/proto/daemon_gateway_test.go new file mode 100644 index 000000000..20031e9d9 --- /dev/null +++ b/client/proto/daemon_gateway_test.go @@ -0,0 +1,80 @@ +package proto + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + gatewayruntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" +) + +func TestGatewayServerRoutesCoverDaemonRPCs(t *testing.T) { + mux := gatewayruntime.NewServeMux() + if err := RegisterDaemonServiceHandlerServer(context.Background(), mux, UnimplementedDaemonServiceServer{}); err != nil { + t.Fatalf("register daemon gateway server handlers: %v", err) + } + + assertAllDaemonGatewayRoutesRegistered(t, mux) +} + +func TestGatewayClientRoutesCoverDaemonRPCs(t *testing.T) { + listener := bufconn.Listen(1024 * 1024) + server := grpc.NewServer() + RegisterDaemonServiceServer(server, UnimplementedDaemonServiceServer{}) + go func() { + if err := server.Serve(listener); err != nil && err != grpc.ErrServerStopped { + t.Errorf("serve bufconn gRPC server: %v", err) + } + }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mux := gatewayruntime.NewServeMux() + opts := []grpc.DialOption{ + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return listener.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + } + if err := RegisterDaemonServiceHandlerFromEndpoint(ctx, mux, "passthrough:///bufnet", opts); err != nil { + t.Fatalf("register daemon gateway client handlers: %v", err) + } + + assertAllDaemonGatewayRoutesRegistered(t, mux) +} + +func assertAllDaemonGatewayRoutesRegistered(t *testing.T, mux http.Handler) { + t.Helper() + for _, method := range DaemonService_ServiceDesc.Methods { + assertGatewayRouteRegistered(t, mux, method.MethodName) + } + for _, stream := range DaemonService_ServiceDesc.Streams { + assertGatewayRouteRegistered(t, mux, stream.StreamName) + } +} + +func assertGatewayRouteRegistered(t *testing.T, mux http.Handler, methodName string) { + t.Helper() + + path := "/daemon.DaemonService/" + methodName + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("{}")) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + + mux.ServeHTTP(res, req) + + if res.Code == http.StatusNotFound { + t.Fatalf("gateway route for %s is not registered", methodName) + } +} diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 5f585aafc..2d01d474d 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -23,6 +23,7 @@ const ( DaemonService_WaitSSOLogin_FullMethodName = "/daemon.DaemonService/WaitSSOLogin" DaemonService_Up_FullMethodName = "/daemon.DaemonService/Up" DaemonService_Status_FullMethodName = "/daemon.DaemonService/Status" + DaemonService_SubscribeStatus_FullMethodName = "/daemon.DaemonService/SubscribeStatus" DaemonService_Down_FullMethodName = "/daemon.DaemonService/Down" DaemonService_GetConfig_FullMethodName = "/daemon.DaemonService/GetConfig" DaemonService_ListNetworks_FullMethodName = "/daemon.DaemonService/ListNetworks" @@ -42,6 +43,7 @@ const ( DaemonService_StopBundleCapture_FullMethodName = "/daemon.DaemonService/StopBundleCapture" DaemonService_SubscribeEvents_FullMethodName = "/daemon.DaemonService/SubscribeEvents" DaemonService_GetEvents_FullMethodName = "/daemon.DaemonService/GetEvents" + DaemonService_RegisterUILog_FullMethodName = "/daemon.DaemonService/RegisterUILog" DaemonService_SwitchProfile_FullMethodName = "/daemon.DaemonService/SwitchProfile" DaemonService_SetConfig_FullMethodName = "/daemon.DaemonService/SetConfig" DaemonService_AddProfile_FullMethodName = "/daemon.DaemonService/AddProfile" @@ -55,10 +57,14 @@ const ( DaemonService_GetPeerSSHHostKey_FullMethodName = "/daemon.DaemonService/GetPeerSSHHostKey" DaemonService_RequestJWTAuth_FullMethodName = "/daemon.DaemonService/RequestJWTAuth" DaemonService_WaitJWTToken_FullMethodName = "/daemon.DaemonService/WaitJWTToken" + DaemonService_RequestExtendAuthSession_FullMethodName = "/daemon.DaemonService/RequestExtendAuthSession" + DaemonService_WaitExtendAuthSession_FullMethodName = "/daemon.DaemonService/WaitExtendAuthSession" + DaemonService_DismissSessionWarning_FullMethodName = "/daemon.DaemonService/DismissSessionWarning" DaemonService_StartCPUProfile_FullMethodName = "/daemon.DaemonService/StartCPUProfile" DaemonService_StopCPUProfile_FullMethodName = "/daemon.DaemonService/StopCPUProfile" DaemonService_GetInstallerResult_FullMethodName = "/daemon.DaemonService/GetInstallerResult" DaemonService_ExposeService_FullMethodName = "/daemon.DaemonService/ExposeService" + DaemonService_WailsUIReady_FullMethodName = "/daemon.DaemonService/WailsUIReady" ) // DaemonServiceClient is the client API for DaemonService service. @@ -74,6 +80,11 @@ type DaemonServiceClient interface { Up(ctx context.Context, in *UpRequest, opts ...grpc.CallOption) (*UpResponse, error) // Status of the service. Status(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (*StatusResponse, error) + // SubscribeStatus pushes a fresh StatusResponse on connection state + // changes (Connected / Disconnected / Connecting / address change / + // peers list change). The first message on the stream is the current + // snapshot, so a freshly-subscribed UI doesn't need to also call Status. + SubscribeStatus(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StatusResponse], error) // Down stops engine work in the daemon. Down(ctx context.Context, in *DownRequest, opts ...grpc.CallOption) (*DownResponse, error) // GetConfig of the daemon. @@ -110,6 +121,10 @@ type DaemonServiceClient interface { StopBundleCapture(ctx context.Context, in *StopBundleCaptureRequest, opts ...grpc.CallOption) (*StopBundleCaptureResponse, error) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error) GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error) + // RegisterUILog records the desktop UI's absolute log path so the daemon's + // debug bundle can collect it (the daemon runs as root and can't resolve the + // user's config dir). + RegisterUILog(ctx context.Context, in *RegisterUILogRequest, opts ...grpc.CallOption) (*RegisterUILogResponse, error) 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) @@ -129,6 +144,22 @@ type DaemonServiceClient interface { RequestJWTAuth(ctx context.Context, in *RequestJWTAuthRequest, opts ...grpc.CallOption) (*RequestJWTAuthResponse, error) // WaitJWTToken waits for JWT authentication completion WaitJWTToken(ctx context.Context, in *WaitJWTTokenRequest, opts ...grpc.CallOption) (*WaitJWTTokenResponse, error) + // RequestExtendAuthSession initiates an SSO session-extension flow. + // The daemon prepares a PKCE/device-code request against the IdP and + // returns the verification URI; the UI is expected to open it. The flow + // state is kept in the daemon until WaitExtendAuthSession completes it. + RequestExtendAuthSession(ctx context.Context, in *RequestExtendAuthSessionRequest, opts ...grpc.CallOption) (*RequestExtendAuthSessionResponse, error) + // WaitExtendAuthSession blocks until the user finishes the SSO step + // started by RequestExtendAuthSession, then forwards the resulting JWT + // to the management server's ExtendAuthSession RPC. Returns the new + // session expiry deadline. The tunnel stays up the entire time. + WaitExtendAuthSession(ctx context.Context, in *WaitExtendAuthSessionRequest, opts ...grpc.CallOption) (*WaitExtendAuthSessionResponse, error) + // DismissSessionWarning records that the user clicked "Dismiss" on the + // T-WarningLead interactive notification, suppressing the auto-opened + // SessionAboutToExpire dialog that would otherwise fire at + // T-FinalWarningLead for the current deadline. Idempotent and best-effort: + // a missed call only means the fallback dialog will still appear. + DismissSessionWarning(ctx context.Context, in *DismissSessionWarningRequest, opts ...grpc.CallOption) (*DismissSessionWarningResponse, error) // StartCPUProfile starts CPU profiling in the daemon StartCPUProfile(ctx context.Context, in *StartCPUProfileRequest, opts ...grpc.CallOption) (*StartCPUProfileResponse, error) // StopCPUProfile stops CPU profiling in the daemon @@ -136,6 +167,10 @@ type DaemonServiceClient interface { GetInstallerResult(ctx context.Context, in *InstallerResultRequest, opts ...grpc.CallOption) (*InstallerResultResponse, error) // ExposeService exposes a local port via the NetBird reverse proxy ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error) + // WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI + // only cares whether the daemon implements it: an Unimplemented response + // means the daemon predates this UI and is too old to drive it. + WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error) } type daemonServiceClient struct { @@ -186,6 +221,25 @@ func (c *daemonServiceClient) Status(ctx context.Context, in *StatusRequest, opt return out, nil } +func (c *daemonServiceClient) SubscribeStatus(ctx context.Context, in *StatusRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StatusResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], DaemonService_SubscribeStatus_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[StatusRequest, StatusResponse]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_SubscribeStatusClient = grpc.ServerStreamingClient[StatusResponse] + func (c *daemonServiceClient) Down(ctx context.Context, in *DownRequest, opts ...grpc.CallOption) (*DownResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DownResponse) @@ -328,7 +382,7 @@ func (c *daemonServiceClient) TracePacket(ctx context.Context, in *TracePacketRe func (c *daemonServiceClient) StartCapture(ctx context.Context, in *StartCaptureRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[CapturePacket], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], DaemonService_StartCapture_FullMethodName, cOpts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], DaemonService_StartCapture_FullMethodName, cOpts...) if err != nil { return nil, err } @@ -367,7 +421,7 @@ func (c *daemonServiceClient) StopBundleCapture(ctx context.Context, in *StopBun func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SystemEvent], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], DaemonService_SubscribeEvents_FullMethodName, cOpts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[2], DaemonService_SubscribeEvents_FullMethodName, cOpts...) if err != nil { return nil, err } @@ -394,6 +448,16 @@ func (c *daemonServiceClient) GetEvents(ctx context.Context, in *GetEventsReques return out, nil } +func (c *daemonServiceClient) RegisterUILog(ctx context.Context, in *RegisterUILogRequest, opts ...grpc.CallOption) (*RegisterUILogResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RegisterUILogResponse) + err := c.cc.Invoke(ctx, DaemonService_RegisterUILog_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *daemonServiceClient) SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(SwitchProfileResponse) @@ -524,6 +588,36 @@ func (c *daemonServiceClient) WaitJWTToken(ctx context.Context, in *WaitJWTToken return out, nil } +func (c *daemonServiceClient) RequestExtendAuthSession(ctx context.Context, in *RequestExtendAuthSessionRequest, opts ...grpc.CallOption) (*RequestExtendAuthSessionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RequestExtendAuthSessionResponse) + err := c.cc.Invoke(ctx, DaemonService_RequestExtendAuthSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) WaitExtendAuthSession(ctx context.Context, in *WaitExtendAuthSessionRequest, opts ...grpc.CallOption) (*WaitExtendAuthSessionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(WaitExtendAuthSessionResponse) + err := c.cc.Invoke(ctx, DaemonService_WaitExtendAuthSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) DismissSessionWarning(ctx context.Context, in *DismissSessionWarningRequest, opts ...grpc.CallOption) (*DismissSessionWarningResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DismissSessionWarningResponse) + err := c.cc.Invoke(ctx, DaemonService_DismissSessionWarning_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *daemonServiceClient) StartCPUProfile(ctx context.Context, in *StartCPUProfileRequest, opts ...grpc.CallOption) (*StartCPUProfileResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(StartCPUProfileResponse) @@ -556,7 +650,7 @@ func (c *daemonServiceClient) GetInstallerResult(ctx context.Context, in *Instal func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExposeServiceEvent], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[2], DaemonService_ExposeService_FullMethodName, cOpts...) + stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[3], DaemonService_ExposeService_FullMethodName, cOpts...) if err != nil { return nil, err } @@ -573,6 +667,16 @@ func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServi // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type DaemonService_ExposeServiceClient = grpc.ServerStreamingClient[ExposeServiceEvent] +func (c *daemonServiceClient) WailsUIReady(ctx context.Context, in *WailsUIReadyRequest, opts ...grpc.CallOption) (*WailsUIReadyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(WailsUIReadyResponse) + err := c.cc.Invoke(ctx, DaemonService_WailsUIReady_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // DaemonServiceServer is the server API for DaemonService service. // All implementations must embed UnimplementedDaemonServiceServer // for forward compatibility. @@ -586,6 +690,11 @@ type DaemonServiceServer interface { Up(context.Context, *UpRequest) (*UpResponse, error) // Status of the service. Status(context.Context, *StatusRequest) (*StatusResponse, error) + // SubscribeStatus pushes a fresh StatusResponse on connection state + // changes (Connected / Disconnected / Connecting / address change / + // peers list change). The first message on the stream is the current + // snapshot, so a freshly-subscribed UI doesn't need to also call Status. + SubscribeStatus(*StatusRequest, grpc.ServerStreamingServer[StatusResponse]) error // Down stops engine work in the daemon. Down(context.Context, *DownRequest) (*DownResponse, error) // GetConfig of the daemon. @@ -622,6 +731,10 @@ type DaemonServiceServer interface { StopBundleCapture(context.Context, *StopBundleCaptureRequest) (*StopBundleCaptureResponse, error) SubscribeEvents(*SubscribeRequest, grpc.ServerStreamingServer[SystemEvent]) error GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) + // RegisterUILog records the desktop UI's absolute log path so the daemon's + // debug bundle can collect it (the daemon runs as root and can't resolve the + // user's config dir). + RegisterUILog(context.Context, *RegisterUILogRequest) (*RegisterUILogResponse, error) SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) SetConfig(context.Context, *SetConfigRequest) (*SetConfigResponse, error) AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) @@ -641,6 +754,22 @@ type DaemonServiceServer interface { RequestJWTAuth(context.Context, *RequestJWTAuthRequest) (*RequestJWTAuthResponse, error) // WaitJWTToken waits for JWT authentication completion WaitJWTToken(context.Context, *WaitJWTTokenRequest) (*WaitJWTTokenResponse, error) + // RequestExtendAuthSession initiates an SSO session-extension flow. + // The daemon prepares a PKCE/device-code request against the IdP and + // returns the verification URI; the UI is expected to open it. The flow + // state is kept in the daemon until WaitExtendAuthSession completes it. + RequestExtendAuthSession(context.Context, *RequestExtendAuthSessionRequest) (*RequestExtendAuthSessionResponse, error) + // WaitExtendAuthSession blocks until the user finishes the SSO step + // started by RequestExtendAuthSession, then forwards the resulting JWT + // to the management server's ExtendAuthSession RPC. Returns the new + // session expiry deadline. The tunnel stays up the entire time. + WaitExtendAuthSession(context.Context, *WaitExtendAuthSessionRequest) (*WaitExtendAuthSessionResponse, error) + // DismissSessionWarning records that the user clicked "Dismiss" on the + // T-WarningLead interactive notification, suppressing the auto-opened + // SessionAboutToExpire dialog that would otherwise fire at + // T-FinalWarningLead for the current deadline. Idempotent and best-effort: + // a missed call only means the fallback dialog will still appear. + DismissSessionWarning(context.Context, *DismissSessionWarningRequest) (*DismissSessionWarningResponse, error) // StartCPUProfile starts CPU profiling in the daemon StartCPUProfile(context.Context, *StartCPUProfileRequest) (*StartCPUProfileResponse, error) // StopCPUProfile stops CPU profiling in the daemon @@ -648,6 +777,10 @@ type DaemonServiceServer interface { GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error) // ExposeService exposes a local port via the NetBird reverse proxy ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error + // WailsUIReady is a no-op probe the Wails UI calls once at startup. The UI + // only cares whether the daemon implements it: an Unimplemented response + // means the daemon predates this UI and is too old to drive it. + WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error) mustEmbedUnimplementedDaemonServiceServer() } @@ -670,6 +803,9 @@ func (UnimplementedDaemonServiceServer) Up(context.Context, *UpRequest) (*UpResp func (UnimplementedDaemonServiceServer) Status(context.Context, *StatusRequest) (*StatusResponse, error) { return nil, status.Error(codes.Unimplemented, "method Status not implemented") } +func (UnimplementedDaemonServiceServer) SubscribeStatus(*StatusRequest, grpc.ServerStreamingServer[StatusResponse]) error { + return status.Error(codes.Unimplemented, "method SubscribeStatus not implemented") +} func (UnimplementedDaemonServiceServer) Down(context.Context, *DownRequest) (*DownResponse, error) { return nil, status.Error(codes.Unimplemented, "method Down not implemented") } @@ -727,6 +863,9 @@ func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, grpc. func (UnimplementedDaemonServiceServer) GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetEvents not implemented") } +func (UnimplementedDaemonServiceServer) RegisterUILog(context.Context, *RegisterUILogRequest) (*RegisterUILogResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RegisterUILog not implemented") +} func (UnimplementedDaemonServiceServer) SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) { return nil, status.Error(codes.Unimplemented, "method SwitchProfile not implemented") } @@ -766,6 +905,15 @@ func (UnimplementedDaemonServiceServer) RequestJWTAuth(context.Context, *Request func (UnimplementedDaemonServiceServer) WaitJWTToken(context.Context, *WaitJWTTokenRequest) (*WaitJWTTokenResponse, error) { return nil, status.Error(codes.Unimplemented, "method WaitJWTToken not implemented") } +func (UnimplementedDaemonServiceServer) RequestExtendAuthSession(context.Context, *RequestExtendAuthSessionRequest) (*RequestExtendAuthSessionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RequestExtendAuthSession not implemented") +} +func (UnimplementedDaemonServiceServer) WaitExtendAuthSession(context.Context, *WaitExtendAuthSessionRequest) (*WaitExtendAuthSessionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method WaitExtendAuthSession not implemented") +} +func (UnimplementedDaemonServiceServer) DismissSessionWarning(context.Context, *DismissSessionWarningRequest) (*DismissSessionWarningResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DismissSessionWarning not implemented") +} func (UnimplementedDaemonServiceServer) StartCPUProfile(context.Context, *StartCPUProfileRequest) (*StartCPUProfileResponse, error) { return nil, status.Error(codes.Unimplemented, "method StartCPUProfile not implemented") } @@ -778,6 +926,9 @@ func (UnimplementedDaemonServiceServer) GetInstallerResult(context.Context, *Ins func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, grpc.ServerStreamingServer[ExposeServiceEvent]) error { return status.Error(codes.Unimplemented, "method ExposeService not implemented") } +func (UnimplementedDaemonServiceServer) WailsUIReady(context.Context, *WailsUIReadyRequest) (*WailsUIReadyResponse, error) { + return nil, status.Error(codes.Unimplemented, "method WailsUIReady not implemented") +} func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {} func (UnimplementedDaemonServiceServer) testEmbeddedByValue() {} @@ -871,6 +1022,17 @@ func _DaemonService_Status_Handler(srv interface{}, ctx context.Context, dec fun return interceptor(ctx, in, info, handler) } +func _DaemonService_SubscribeStatus_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(StatusRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(DaemonServiceServer).SubscribeStatus(m, &grpc.GenericServerStream[StatusRequest, StatusResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type DaemonService_SubscribeStatusServer = grpc.ServerStreamingServer[StatusResponse] + func _DaemonService_Down_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(DownRequest) if err := dec(in); err != nil { @@ -1199,6 +1361,24 @@ func _DaemonService_GetEvents_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _DaemonService_RegisterUILog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegisterUILogRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).RegisterUILog(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_RegisterUILog_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).RegisterUILog(ctx, req.(*RegisterUILogRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_SwitchProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(SwitchProfileRequest) if err := dec(in); err != nil { @@ -1433,6 +1613,60 @@ func _DaemonService_WaitJWTToken_Handler(srv interface{}, ctx context.Context, d return interceptor(ctx, in, info, handler) } +func _DaemonService_RequestExtendAuthSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RequestExtendAuthSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).RequestExtendAuthSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_RequestExtendAuthSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).RequestExtendAuthSession(ctx, req.(*RequestExtendAuthSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_WaitExtendAuthSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(WaitExtendAuthSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).WaitExtendAuthSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_WaitExtendAuthSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).WaitExtendAuthSession(ctx, req.(*WaitExtendAuthSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_DismissSessionWarning_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DismissSessionWarningRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).DismissSessionWarning(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_DismissSessionWarning_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).DismissSessionWarning(ctx, req.(*DismissSessionWarningRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_StartCPUProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(StartCPUProfileRequest) if err := dec(in); err != nil { @@ -1498,6 +1732,24 @@ func _DaemonService_ExposeService_Handler(srv interface{}, stream grpc.ServerStr // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type DaemonService_ExposeServiceServer = grpc.ServerStreamingServer[ExposeServiceEvent] +func _DaemonService_WailsUIReady_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(WailsUIReadyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).WailsUIReady(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_WailsUIReady_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).WailsUIReady(ctx, req.(*WailsUIReadyRequest)) + } + return interceptor(ctx, in, info, handler) +} + // DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -1589,6 +1841,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetEvents", Handler: _DaemonService_GetEvents_Handler, }, + { + MethodName: "RegisterUILog", + Handler: _DaemonService_RegisterUILog_Handler, + }, { MethodName: "SwitchProfile", Handler: _DaemonService_SwitchProfile_Handler, @@ -1641,6 +1897,18 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "WaitJWTToken", Handler: _DaemonService_WaitJWTToken_Handler, }, + { + MethodName: "RequestExtendAuthSession", + Handler: _DaemonService_RequestExtendAuthSession_Handler, + }, + { + MethodName: "WaitExtendAuthSession", + Handler: _DaemonService_WaitExtendAuthSession_Handler, + }, + { + MethodName: "DismissSessionWarning", + Handler: _DaemonService_DismissSessionWarning_Handler, + }, { MethodName: "StartCPUProfile", Handler: _DaemonService_StartCPUProfile_Handler, @@ -1653,8 +1921,17 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetInstallerResult", Handler: _DaemonService_GetInstallerResult_Handler, }, + { + MethodName: "WailsUIReady", + Handler: _DaemonService_WailsUIReady_Handler, + }, }, Streams: []grpc.StreamDesc{ + { + StreamName: "SubscribeStatus", + Handler: _DaemonService_SubscribeStatus_Handler, + ServerStreams: true, + }, { StreamName: "StartCapture", Handler: _DaemonService_StartCapture_Handler, diff --git a/client/proto/generate.sh b/client/proto/generate.sh index 1ae55e380..cea8ae912 100755 --- a/client/proto/generate.sh +++ b/client/proto/generate.sh @@ -12,5 +12,11 @@ script_path=$(dirname "$(realpath "$0")") cd "$script_path" go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6 go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1 -protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional +go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@v2.26.3 +protoc -I ./ ./daemon.proto \ + --go_out=../ \ + --go-grpc_out=../ \ + --grpc-gateway_out=../ \ + --grpc-gateway_opt=generate_unbound_methods=true \ + --experimental_allow_proto3_optional cd "$old_pwd" diff --git a/client/proto/metadata.go b/client/proto/metadata.go new file mode 100644 index 000000000..9b1dbd16e --- /dev/null +++ b/client/proto/metadata.go @@ -0,0 +1,61 @@ +package proto + +// SystemEvent metadata markers. The daemon stamps these on internal control +// events it publishes over SubscribeEvents (profile-list refresh, log-level +// change); the desktop UI recognises them and acts on them instead of +// surfacing them as user-facing notifications. +// +// These live in the proto package — the shared contract both the daemon +// (client/server) and the UI (client/ui/services) already import — so producer +// and consumer reference the same constant rather than duplicating literals. +// This file is hand-written and not touched by protoc. +const ( + // MetadataKindKey is the SystemEvent.metadata key carrying the event-kind + // marker (one of the MetadataKind* values below). + MetadataKindKey = "kind" + + // MetadataKindProfileListChanged marks a CLI-driven profile add/remove that + // should nudge the UI's profile views to refresh. + MetadataKindProfileListChanged = "profile-list-changed" + // MetadataKindLogLevelChanged marks a daemon log-level change (or the + // per-subscription snapshot) that drives the GUI's file logging on/off. + MetadataKindLogLevelChanged = "log-level-changed" + + // MetadataProfileKey carries the profile name for + // MetadataKindProfileListChanged. + MetadataProfileKey = "profile" + // MetadataLevelKey carries the lowercase logrus level name for + // MetadataKindLogLevelChanged. + MetadataLevelKey = "level" +) + +// SystemEvent metadata markers for daemon config-change events. The daemon +// publishes a SYSTEM-category event whenever its effective Config is +// replaced (engine spawn, Up RPC, MDM policy diff); the UI re-fetches its +// cached config/features in response and, for the MDM source, shows a +// localised toast. Producer (client/server) and consumer (client/ui) share +// these so neither duplicates the wire literals. +const ( + // MetadataTypeKey is the SystemEvent.metadata key carrying the + // config-change event type (one of the MetadataType* values below). + MetadataTypeKey = "type" + // MetadataTypeConfigChanged marks a config replacement that should nudge + // UIs to re-fetch their cached config + features. UserMessage is empty so + // the change is silent; the source is carried in MetadataSourceKey. + MetadataTypeConfigChanged = "config_changed" + // MetadataTypePolicyApplied marks an MDM-policy-driven config change. The + // daemon stamps it with a (non-localised) UserMessage; the UI suppresses + // that and builds its own localised toast off the paired config_changed + // event instead. + MetadataTypePolicyApplied = "policy_applied" + + // MetadataSourceKey is the SystemEvent.metadata key carrying what + // triggered a config_changed event (one of the MetadataSource* values). + MetadataSourceKey = "source" + // MetadataSourceStartup marks a config_changed from the daemon Start path. + MetadataSourceStartup = "startup" + // MetadataSourceUpRPC marks a config_changed from the Up RPC. + MetadataSourceUpRPC = "up_rpc" + // MetadataSourceMDM marks a config_changed driven by an MDM policy diff. + MetadataSourceMDM = "mdm" +) diff --git a/client/server/debug.go b/client/server/debug.go index 14dcaba33..8f4a506b4 100644 --- a/client/server/debug.go +++ b/client/server/debug.go @@ -7,18 +7,63 @@ import ( "context" "errors" "fmt" + "path/filepath" "runtime/pprof" + "strings" + "time" log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/internal/debug" + "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/proto" mgmProto "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/version" ) // DebugBundle creates a debug bundle and returns the location. -func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) { +func (s *Server) DebugBundle(callerCtx context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) { + if err := requirePrivilegeForUploadURL(callerCtx, req.GetUploadURL(), req.GetUploadInsecure()); err != nil { + return nil, err + } + + // The UI log is opened as whoever asked for this bundle, so a caller only + // collects a log it owns (privileged callers excepted). ok is false on a + // socket that carries no identity, which skips the UI log. + callerID, callerIdentified := ipcauth.CallerIdentity(callerCtx) + + path, managementURL, err := s.generateDebugBundle(req, uiLogOpener(callerID, callerIdentified)) + if err != nil { + return nil, err + } + + if req.GetUploadURL() == "" { + return &proto.DebugBundleResponse{Path: path}, nil + } + + // The upload runs without s.mutex held: it does network I/O to a possibly + // slow destination and must not block the other RPCs that take the lock. The + // bounded context is a backstop against a hung connection. + uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + key, err := debug.UploadDebugBundle(uploadCtx, req.GetUploadURL(), managementURL, path, req.GetUploadInsecure()) + if err != nil { + log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err) + return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil + } + + log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key) + + return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil +} + +// generateDebugBundle builds the bundle under s.mutex and returns its path plus +// the management URL captured under the lock, so the caller can run the upload +// without holding the lock. +func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener debug.LogOpener) (path string, managementURL string, err error) { s.mutex.Lock() defer s.mutex.Unlock() @@ -53,7 +98,10 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( if engine != nil { refreshStatus = func() { log.Debug("refreshing system health status for debug bundle") - engine.RunHealthProbes(true) + // Background ctx: the bundle wants a full, fresh probe regardless + // of the DebugBundle RPC client's lifetime. The engine's own ctx + // still aborts it on shutdown. + engine.RunHealthProbes(context.Background(), true) } } } @@ -64,6 +112,8 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( StatusRecorder: s.statusRecorder, SyncResponse: syncResponse, LogPath: s.logFile, + UILogPath: s.uiLogPath, + UILogOpener: uiOpener, CPUProfile: cpuProfileData, CapturePath: capturePath, RefreshStatus: refreshStatus, @@ -73,28 +123,22 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( }, debug.BundleConfig{ Anonymize: req.GetAnonymize(), + AnonymizeLevel: anonymize.ParseLevel(req.GetAnonymizeLevel()), IncludeSystemInfo: req.GetSystemInfo(), LogFileCount: req.GetLogFileCount(), }, ) - path, err := bundleGenerator.Generate() + path, err = bundleGenerator.Generate() if err != nil { - return nil, fmt.Errorf("generate debug bundle: %w", err) + return "", "", fmt.Errorf("generate debug bundle: %w", err) } - if req.GetUploadURL() == "" { - return &proto.DebugBundleResponse{Path: path}, nil - } - key, err := debug.UploadDebugBundle(context.Background(), req.GetUploadURL(), s.config.ManagementURL.String(), path) - if err != nil { - log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err) - return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil + if s.config != nil && s.config.ManagementURL != nil { + managementURL = s.config.ManagementURL.String() } - log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key) - - return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil + return path, managementURL, nil } // GetLogLevel gets the current logging level for the server. @@ -124,9 +168,48 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) ( log.Infof("Log level set to %s", level.String()) + // Signal the desktop UI so it can attach/detach its gui-client.log. Rides + // the SubscribeEvents stream as a marked event (see publishLogLevelChanged). + s.publishLogLevelChanged(level.String()) + return &proto.SetLogLevelResponse{}, nil } +// RegisterUILog records the desktop UI's absolute log path so DebugBundle can +// collect the GUI log. The daemon runs as root and can't resolve the user's +// config dir, so the UI reports it. Last-writer-wins (one UI per socket). +// +// The path arrives over an IPC any local user can reach and is later opened by +// a root daemon, so it is constrained to the file name the UI writes and to a +// local absolute path. Authorization happens when DebugBundle opens it: the +// bundle refuses a file its requester does not own. A caller the daemon cannot +// identify cannot register a path at all. +func (s *Server) RegisterUILog(callerCtx context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) { + if _, ok := ipcauth.CallerIdentity(callerCtx); !ok { + return nil, gstatus.Error(codes.PermissionDenied, + "registering a UI log path requires a control channel that carries the caller's identity") + } + + path := filepath.Clean(req.GetPath()) + if !filepath.IsAbs(path) || filepath.Base(path) != uiLogFileName { + return nil, gstatus.Errorf(codes.InvalidArgument, "UI log path must be an absolute path ending in %s", uiLogFileName) + } + // filepath.IsAbs accepts a Windows UNC path (\\host\share\...) and a device + // path (\\.\, \\?\); opening one would make the root daemon reach a remote + // or device namespace. Require a plain local path. + if strings.HasPrefix(path, `\\`) { + return nil, gstatus.Error(codes.InvalidArgument, "UI log path must be a local path, not a UNC or device path") + } + + s.mutex.Lock() + defer s.mutex.Unlock() + + s.uiLogPath = path + log.Infof("registered UI log path %s", s.uiLogPath) + + return &proto.RegisterUILogResponse{}, nil +} + // SetSyncResponsePersistence sets the sync response persistence for the server. func (s *Server) SetSyncResponsePersistence(_ context.Context, req *proto.SetSyncResponsePersistenceRequest) (*proto.SetSyncResponsePersistenceResponse, error) { s.mutex.Lock() diff --git a/client/server/debug_gate.go b/client/server/debug_gate.go new file mode 100644 index 000000000..983a13aaf --- /dev/null +++ b/client/server/debug_gate.go @@ -0,0 +1,99 @@ +//go:build !android && !ios + +package server + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/configs" + "github.com/netbirdio/netbird/client/internal/debug" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/upload-server/types" +) + +// uiLogFileName is the only file name the daemon accepts as a UI log path. The +// UI (writer), this validation, and the bundle collector all read it from +// configs so they cannot drift. +const uiLogFileName = configs.UILogFile + +// uiLogOpener opens the registered UI log, and its rotated siblings, on behalf +// of the caller requesting the bundle: OpenOwnedFile then collects the log only +// when that caller owns it (or is privileged). identified is false on a socket +// that carries no caller identity, in which case nothing is opened. +func uiLogOpener(id ipcauth.Identity, identified bool) debug.LogOpener { + return func(path string) (*os.File, error) { + if !identified { + return nil, fmt.Errorf("bundle requester has no verified identity") + } + return ipcauth.OpenOwnedFile(id, path) + } +} + +// requirePrivilegeForUploadURL restricts where the daemon may send a debug +// bundle. The bundle holds the daemon's own logs and state, and the daemon +// fetches the upload URL itself, so an unrestricted endpoint turns the daemon +// into both an exfiltration channel and a request forwarder that reaches +// services only it can talk to. +// +// The upload service NetBird publishes is open to any caller, since that is what +// the CLI and the desktop UI use. Any other endpoint, self-hosted upload servers +// included, requires a privileged caller. Plaintext is refused for everyone: the +// daemon fetches the URL and then PUTs the bundle to whatever that fetch returns, +// so an http hop is a place to intercept the bundle or the redirect. +// +// insecure relaxes transport security (http, or an untrusted TLS certificate) +// for a self-hosted server. It weakens a root-privileged upload, so it is +// refused for an unprivileged caller regardless of the host. +func requirePrivilegeForUploadURL(ctx context.Context, rawURL string, insecure bool) error { + if rawURL == "" { + return nil + } + + parsed, err := url.Parse(rawURL) + if err != nil { + return gstatus.Errorf(codes.InvalidArgument, "parse upload URL: %v", err) + } + + // --insecure relaxes https to http or an untrusted certificate; it does not + // widen the URL to arbitrary schemes, so a host and http/https are required + // before the insecure branch takes over. + if parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") { + return gstatus.Errorf(codes.InvalidArgument, "upload URL must be http or https with a host") + } + + if insecure { + return denyPrivileged(ctx, + "uploading a debug bundle without transport security (--upload-bundle-insecure)", + ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-insecure --upload-bundle-url ")) + } + + if parsed.Scheme != "https" { + return gstatus.Errorf(codes.InvalidArgument, "upload URL must use https, got scheme %q", parsed.Scheme) + } + + if isDefaultUploadService(parsed) { + return nil + } + + return denyPrivileged(ctx, + "uploading a debug bundle to an upload service other than the default one", + ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-url ")) +} + +// isDefaultUploadService reports whether the URL points at the upload service +// NetBird runs. Only the host is compared: the service's path may differ between +// releases, and the host is what decides who receives the bundle. +func isDefaultUploadService(parsed *url.URL) bool { + defaultURL, err := url.Parse(types.DefaultBundleURL) + if err != nil { + return false + } + return parsed.Scheme == defaultURL.Scheme && strings.EqualFold(parsed.Host, defaultURL.Host) +} diff --git a/client/server/debug_gate_test.go b/client/server/debug_gate_test.go new file mode 100644 index 000000000..e958fc581 --- /dev/null +++ b/client/server/debug_gate_test.go @@ -0,0 +1,157 @@ +//go:build !android && !ios + +package server + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/upload-server/types" +) + +func TestRegisterUILogRefusesUnidentifiedCaller(t *testing.T) { + s := &Server{} + + _, err := s.RegisterUILog(noIdentityCtx(), &proto.RegisterUILogRequest{ + Path: filepath.Join(t.TempDir(), uiLogFileName), + }) + + if gstatus.Code(err) != codes.PermissionDenied { + t.Fatalf("code = %v, want PermissionDenied", gstatus.Code(err)) + } +} + +func TestRegisterUILogRefusesForeignPath(t *testing.T) { + secret := "/etc/shadow" + if runtime.GOOS == "windows" { + secret = `C:\Windows\System32\config\SAM` + } + + tests := []struct { + name string + path string + }{ + {"empty", ""}, + {"relative", filepath.Join("netbird", uiLogFileName)}, + {"another file", secret}, + {"directory of the log", t.TempDir()}, + {"unc path", `\\attacker\share\` + uiLogFileName}, + {"device path", `\\.\C:\` + uiLogFileName}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := &Server{} + + _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: tc.path}) + + if gstatus.Code(err) != codes.InvalidArgument { + t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err)) + } + if s.uiLogPath != "" { + t.Fatalf("path %q was recorded despite the refusal", s.uiLogPath) + } + }) + } +} + +func TestRegisterUILogRecordsPath(t *testing.T) { + s := &Server{} + path := filepath.Join(t.TempDir(), uiLogFileName) + + if _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: path}); err != nil { + t.Fatalf("register: %v", err) + } + + if s.uiLogPath != path { + t.Fatalf("path = %q, want %q", s.uiLogPath, path) + } +} + +// The UI log is opened as the bundle requester, so a second local user cannot +// collect a log they do not own, and an unidentified requester collects nothing. +func TestUILogOpenerBindsToRequester(t *testing.T) { + path := filepath.Join(t.TempDir(), uiLogFileName) + if err := os.WriteFile(path, []byte("log line"), 0600); err != nil { + t.Fatalf("write log: %v", err) + } + + // A different unprivileged user than the file's owner: refused. + if _, err := uiLogOpener(unprivilegedIdentity(), true)(path); err == nil { + t.Fatal("expected a file the requester does not own to be refused") + } + + // No verified identity: refused. + if _, err := uiLogOpener(ipcauth.Identity{}, false)(path); err == nil { + t.Fatal("expected an unidentified requester to be refused") + } + + // The requester that owns the file: allowed. The test process created it, so + // its own identity is the owner (and a privileged runner is exempt anyway). + owner, err := ipcauth.CurrentProcessIdentity() + if err != nil { + t.Fatalf("current identity: %v", err) + } + f, err := uiLogOpener(owner, true)(path) + if err != nil { + t.Fatalf("expected the owning requester to be allowed, got %v", err) + } + _ = f.Close() +} + +func TestRequirePrivilegeForUploadURL(t *testing.T) { + tests := []struct { + name string + url string + insecure bool + unprivOK bool + invalid bool + rootAlso bool + }{ + {name: "no upload", url: "", unprivOK: true}, + {name: "default service", url: types.DefaultBundleURL, unprivOK: true}, + {name: "default service, other path", url: "https://upload.debug.netbird.io/other", unprivOK: true}, + {name: "loopback exfiltration endpoint", url: "https://127.0.0.1:8080/upload-url", rootAlso: true}, + {name: "custom upload service", url: "https://attacker.example/upload-url", rootAlso: true}, + {name: "plaintext default host", url: "http://upload.debug.netbird.io/upload-url", invalid: true}, + {name: "plaintext custom host", url: "http://attacker.example/upload-url", invalid: true}, + {name: "unsupported scheme", url: "file:///etc/shadow", invalid: true}, + // insecure relaxes transport security; privileged only, whatever the host. + {name: "insecure http custom", url: "http://selfhosted.local/upload-url", insecure: true, rootAlso: true}, + {name: "insecure https custom", url: "https://selfhosted.local/upload-url", insecure: true, rootAlso: true}, + {name: "insecure default host", url: types.DefaultBundleURL, insecure: true, rootAlso: true}, + // --insecure must not widen the URL to non-http(s) schemes or a hostless URL. + {name: "insecure file scheme", url: "file:///etc/shadow", insecure: true, invalid: true}, + {name: "insecure hostless", url: "https:///upload-url", insecure: true, invalid: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := requirePrivilegeForUploadURL(userCtx(), tc.url, tc.insecure) + + switch { + case tc.invalid: + if gstatus.Code(err) != codes.InvalidArgument { + t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err)) + } + return + case tc.unprivOK: + assertAllowed(t, err) + return + default: + assertDenied(t, err) + } + + if tc.rootAlso { + assertAllowed(t, requirePrivilegeForUploadURL(rootCtx(), tc.url, tc.insecure)) + } + }) + } +} diff --git a/client/server/event.go b/client/server/event.go index d93151c96..753a051e7 100644 --- a/client/server/event.go +++ b/client/server/event.go @@ -1,7 +1,9 @@ package server import ( + "github.com/google/uuid" log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/netbirdio/netbird/client/proto" ) @@ -16,6 +18,15 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo log.Debug("client subscribed to events") s.startUpdateManagerForGUI() + // Replay the current log level to this subscriber so a freshly-connected UI + // learns it even when the daemon was already started with --log-level debug + // (the change-driven publishLogLevelChanged only fires on SetLogLevel). Sent + // directly on this stream rather than via PublishEvent so it reaches only + // the new subscriber, not every connected client. + if err := s.sendCurrentLogLevel(stream); err != nil { + return err + } + for { select { case event := <-subscription.Events(): @@ -28,3 +39,24 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo } } } + +// sendCurrentLogLevel sends a marked log-level-changed SystemEvent carrying the +// daemon's current level directly to one subscriber. Mirrors the shape +// publishLogLevelChanged emits so the UI's dispatchSystemEvent handles both the +// same way. +func (s *Server) sendCurrentLogLevel(stream proto.DaemonService_SubscribeEventsServer) error { + level := log.GetLevel().String() + event := &proto.SystemEvent{ + Id: uuid.New().String(), + Severity: proto.SystemEvent_INFO, + Category: proto.SystemEvent_SYSTEM, + Message: "Log level changed", + Metadata: map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level}, + Timestamp: timestamppb.Now(), + } + if err := stream.Send(event); err != nil { + log.Warnf("error sending initial log level event: %v", err) + return err + } + return nil +} diff --git a/client/server/extend_authsession_test.go b/client/server/extend_authsession_test.go new file mode 100644 index 000000000..a1a048a7c --- /dev/null +++ b/client/server/extend_authsession_test.go @@ -0,0 +1,42 @@ +package server + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" +) + +func TestInnermostStatus(t *testing.T) { + t.Run("wrapped gRPC status", func(t *testing.T) { + inner := gstatus.Error(codes.PermissionDenied, "peer is already registered by a different User or a Setup Key") + // Mirror the daemon wrap chain: engine wraps with %w, mgm error is the inner status. + wrapped := fmt.Errorf("extend auth session on management: %w", inner) + + st := innermostStatus(wrapped) + require.NotNil(t, st) + require.Equal(t, codes.PermissionDenied, st.Code()) + require.Equal(t, "peer is already registered by a different User or a Setup Key", st.Message()) + }) + + t.Run("deepest status wins over an outer one", func(t *testing.T) { + inner := gstatus.Error(codes.PermissionDenied, "deepest") + chain := fmt.Errorf("outer: %w", fmt.Errorf("mid: %w", inner)) + + st := innermostStatus(chain) + require.NotNil(t, st) + require.Equal(t, codes.PermissionDenied, st.Code()) + require.Equal(t, "deepest", st.Message()) + }) + + t.Run("no status in chain", func(t *testing.T) { + require.Nil(t, innermostStatus(errors.New("plain error"))) + }) + + t.Run("nil error", func(t *testing.T) { + require.Nil(t, innermostStatus(nil)) + }) +} diff --git a/client/server/lock_order_test.go b/client/server/lock_order_test.go new file mode 100644 index 000000000..457e3db34 --- /dev/null +++ b/client/server/lock_order_test.go @@ -0,0 +1,51 @@ +package server + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/proto" +) + +// The daemon takes guardedConfigMu before s.mutex. authorizeAndPrepareLogin +// takes s.mutex while holding guardedConfigMu, so a SetConfig that grabbed +// s.mutex first and then waited for guardedConfigMu would deadlock the daemon +// against a concurrent login: two unprivileged IPC calls are enough. +// +// The held guardedConfigMu below stands in for that login. While SetConfig waits +// for it, s.mutex must stay free, otherwise the login waiting for s.mutex could +// never release guardedConfigMu. +func TestSetConfig_TakesGuardedConfigMuBeforeServerMutex(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + + s.guardedConfigMu.Lock() + + done := make(chan error, 1) + go func() { + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + }) + done <- err + }() + + require.Never(t, func() bool { + if !s.mutex.TryLock() { + return true + } + s.mutex.Unlock() + return false + }, 500*time.Millisecond, 10*time.Millisecond, + "SetConfig held s.mutex while waiting for guardedConfigMu, which deadlocks against a concurrent login") + + s.guardedConfigMu.Unlock() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("SetConfig did not finish after guardedConfigMu was released") + } +} diff --git a/client/server/login_gate_test.go b/client/server/login_gate_test.go new file mode 100644 index 000000000..de62a8180 --- /dev/null +++ b/client/server/login_gate_test.go @@ -0,0 +1,127 @@ +package server + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// A refused login must not leave the profile switched. Login can both switch +// profiles and carry the guarded config fields, so the gate has to run before the +// switch: otherwise a caller whose change is refused still gets the side effect of +// activating whichever profile the request named. +func TestLogin_RefusedChangeLeavesTheProfileAlone(t *testing.T) { + s, _, activeProfile, username, _ := setupServerWithProfile(t) + + // Login reads process state off the daemon's root context. + s.rootCtx = internal.CtxInitState(context.Background()) + + // A second profile that runs the SSH server, which is what makes repointing + // its management binding a privileged change. + target := "ssh-enabled" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"), + ManagementURL: "https://api.netbird.io:443", + ServerSSHAllowed: boolPtr(true), + }) + require.NoError(t, err) + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: "https://mgmt.attacker.example:443", + }) + require.Error(t, err, "an unprivileged caller must not move the management URL of an SSH-enabled profile") + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err) + + active, err := s.profileManager.GetActiveProfileState() + require.NoError(t, err) + require.Equal(t, profilemanager.ID(activeProfile), active.ID, + "the refused login switched the active profile anyway") +} + +// A caller whose change becomes privileged only after its first check must be +// refused without having cancelled a login or switched profiles: the first check is +// unsynchronized, so the SSH server can be enabled by a concurrent privileged +// request in between, and the authoritative check happens before any side effect. +func TestLogin_ChangeThatBecomesPrivilegedMidRequestHasNoSideEffects(t *testing.T) { + s, _, activeProfile, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + // The target profile has SSH off, so the first check lets the request through. + target := "ssh-later" + targetPath := filepath.Join(profilemanager.DefaultConfigPathDir, target+".json") + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: targetPath, + ManagementURL: "https://api.netbird.io:443", + ServerSSHAllowed: boolPtr(false), + }) + require.NoError(t, err) + + cancelled := false + s.actCancel = func() { cancelled = true } + + // Stand in for a privileged SetConfig that enables the SSH server between the + // two checks, which is the interleaving the lock has to make safe. + afterLoginPreCheck = func() { + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: targetPath, + ServerSSHAllowed: boolPtr(true), + }) + require.NoError(t, err) + } + t.Cleanup(func() { afterLoginPreCheck = nil }) + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: "https://mgmt.attacker.example:443", + }) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err) + require.False(t, cancelled, "the refused login cancelled the login already in progress") + + active, err := s.profileManager.GetActiveProfileState() + require.NoError(t, err) + require.Equal(t, profilemanager.ID(activeProfile), active.ID, "the refused login switched the active profile anyway") + + stored, err := profilemanager.ReadConfig(targetPath) + require.NoError(t, err) + require.Equal(t, "https://api.netbird.io:443", stored.ManagementURL.String(), "the refused login moved the management URL") +} + +// Login cancels whatever login is already in progress before starting its own. A +// refused caller must not get that far, otherwise anyone able to reach the socket +// can abort someone else's login by sending a request that is denied. +func TestLogin_RefusedChangeLeavesAnInProgressLoginAlone(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + target := "ssh-enabled" + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"), + ManagementURL: "https://api.netbird.io:443", + ServerSSHAllowed: boolPtr(true), + }) + require.NoError(t, err) + + cancelled := false + s.actCancel = func() { cancelled = true } + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: "https://mgmt.attacker.example:443", + }) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err) + require.False(t, cancelled, "the refused login cancelled the login already in progress") +} diff --git a/client/server/login_outcome_test.go b/client/server/login_outcome_test.go new file mode 100644 index 000000000..7ebf04f92 --- /dev/null +++ b/client/server/login_outcome_test.go @@ -0,0 +1,110 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/proto" +) + +// A login that never reached Management is not a decision about the peer's +// credentials, so it must come back as a retryable error rather than an SSO +// prompt: the user cannot finish a browser login while Management is down, and +// the CLI's own backoff resolves the outage on its own once the daemon reports +// the failure. Reproduces `netbird down; netbird up` printing a device-code URL +// because Management happened to be restarting when the daemon dialed it. +func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + unreachable := errors.New("create connection: dial context: context deadline exceeded") + attempts := 0 + s.isLoginRequiredFn = func(context.Context) (bool, error) { + attempts++ + return false, unreachable + } + + resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username}) + require.Error(t, err) + require.ErrorIs(t, err, unreachable, "the transport failure was replaced by something else") + require.Nil(t, resp, "a failed login must not answer with a login response") + require.Equal(t, 1, attempts) + require.Nil(t, s.oauthAuthFlow.flow, "the daemon started an SSO flow for a peer whose login was never decided") + + status, err := internal.CtxGetState(s.rootCtx).Status() + require.NoError(t, err) + require.Equal(t, internal.StatusLoginFailed, status, + "a peer that could not reach Management is not waiting on a login") +} + +// The counterpart: Management refusing the peer's credentials is a decision, and +// the SSO flow still has to start for it. The profile carries an unusable +// private key so the flow setup fails immediately instead of dialing, which is +// enough to show the branch was entered — the refusal itself is never what comes +// back out. +func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) { + s, _, _, username, cfgPath := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + breakProfilePrivateKey(t, cfgPath) + + s.isLoginRequiredFn = func(context.Context) (bool, error) { + return true, nil + } + + _, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username}) + require.Error(t, err) + + status, stateErr := internal.CtxGetState(s.rootCtx).Status() + require.NoError(t, stateErr) + require.Equal(t, internal.StatusLoginFailed, status, + "the SSO flow setup was never reached with the broken key") +} + +func TestLogin_SetupKeyStillRunsWhenPeerNeedsLogin(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.rootCtx = internal.CtxInitState(context.Background()) + + s.isLoginRequiredFn = func(context.Context) (bool, error) { + return true, nil + } + + var keysTried []string + s.loginAttemptFn = func(_ context.Context, setupKey, _ string) (internal.StatusType, error) { + keysTried = append(keysTried, setupKey) + return "", nil + } + + setupKey := "A2C8E32F-AEB2-4B45-8FD3-8A0C1B2D3E4F" + resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username, SetupKey: setupKey}) + require.NoError(t, err, "the probe's outcome leaked out as the login result") + require.NotNil(t, resp) + require.Equal(t, []string{setupKey}, keysTried, "the setup key never reached the login attempt") + require.Nil(t, s.oauthAuthFlow.flow, "a setup-key login started an SSO flow") + + status, err := internal.CtxGetState(s.rootCtx).Status() + require.NoError(t, err) + require.Equal(t, internal.StatusIdle, status) +} + +// breakProfilePrivateKey replaces the profile's private key with an unparseable +// one, which makes any attempt to build a Management client fail on the spot. +func breakProfilePrivateKey(t *testing.T, cfgPath string) { + t.Helper() + + raw, err := os.ReadFile(cfgPath) + require.NoError(t, err) + + var cfg map[string]any + require.NoError(t, json.Unmarshal(raw, &cfg)) + cfg["PrivateKey"] = "not-a-key" + + patched, err := json.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, os.WriteFile(cfgPath, patched, 0o600)) +} diff --git a/client/server/mdm.go b/client/server/mdm.go index 0da0ec5d1..9836c6bea 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -3,6 +3,7 @@ package server import ( "context" "fmt" + "net/url" "time" log "github.com/sirupsen/logrus" @@ -99,7 +100,10 @@ func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error { proto.SystemEvent_SYSTEM, "MDM policy applied", "NetBird configuration was updated by your IT policy.", - map[string]string{"source": "mdm", "type": "policy_applied"}, + map[string]string{ + proto.MetadataSourceKey: proto.MetadataSourceMDM, + proto.MetadataTypeKey: proto.MetadataTypePolicyApplied, + }, ) return nil } @@ -124,8 +128,8 @@ func (s *Server) publishConfigChangedEvent(source string) { fmt.Sprintf("daemon config changed (source=%s)", source), "", map[string]string{ - "source": source, - "type": "config_changed", + proto.MetadataSourceKey: source, + proto.MetadataTypeKey: proto.MetadataTypeConfigChanged, }, ) } @@ -152,7 +156,6 @@ func (s *Server) restartEngineForMDMLocked() error { s.config = config s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive) - s.statusRecorder.UpdateLazyConnection(config.LazyConnectionEnabled) ctx, cancel := context.WithCancel(s.rootCtx) s.actCancel = cancel @@ -161,7 +164,7 @@ func (s *Server) restartEngineForMDMLocked() error { s.clientGiveUpChan = make(chan struct{}) log.Info("MDM restart: spawning connectWithRetryRuns with re-resolved config") go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) - s.publishConfigChangedEvent("mdm") + s.publishConfigChangedEvent(proto.MetadataSourceMDM) return nil } @@ -182,6 +185,37 @@ func conflictBool(key string, p *bool) conflictCheck { } } +func canonicalURL(s string) string { + u, err := url.ParseRequestURI(s) + if err != nil { + return s + } + if u.Port() == "" { + switch u.Scheme { + case "https": + u.Host += ":443" + case "http": + u.Host += ":80" + } + } + return u.String() +} + +// conflictURL is conflictString for URL-typed keys: both sides are +// normalized via canonicalURL before comparison. +func conflictURL(key, got string) conflictCheck { + return conflictCheck{ + key: key, + check: func(pol *mdm.Policy) bool { + if got == "" { + return true + } + want, ok := pol.GetString(key) + return ok && canonicalURL(want) == canonicalURL(got) + }, + } +} + // conflictString builds a conflictCheck for a string MDM key. An empty // `got` is treated as "field not set" (no override requested); otherwise // the check returns true only when the policy contains the key and its @@ -257,7 +291,7 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ } return resolveConflicts(policy, []conflictCheck{ - conflictString(mdm.KeyManagementURL, msg.ManagementUrl), + conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), conflictString(mdm.KeyPreSharedKey, pskGot), conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), @@ -305,7 +339,6 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { msg.DisableFirewall != nil || msg.BlockLanAccess != nil || msg.DisableNotifications != nil || - msg.LazyConnectionEnabled != nil || msg.BlockInbound != nil || msg.DisableIpv6 != nil || msg.EnableSSHRoot != nil || @@ -348,7 +381,6 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { msg.BlockLanAccess != nil || msg.DisableNotifications != nil || len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || - msg.LazyConnectionEnabled != nil || msg.BlockInbound != nil } @@ -380,7 +412,7 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str } return resolveConflicts(policy, []conflictCheck{ - conflictString(mdm.KeyManagementURL, msg.ManagementUrl), + conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), conflictString(mdm.KeyPreSharedKey, pskGot), conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), diff --git a/client/server/network.go b/client/server/network.go index 7a3c08f2e..c390b8180 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -8,7 +8,6 @@ import ( "sort" "strings" - "golang.org/x/exp/maps" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" @@ -161,19 +160,11 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ return nil, fmt.Errorf("no route manager") } - routeSelector := routeManager.GetRouteSelector() if req.GetAll() { - routeSelector.SelectAllRoutes() - } else { - routes := toNetIDs(req.GetNetworkIDs()) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - netIdRoutes := maps.Keys(routesMap) - if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil { - return nil, fmt.Errorf("select routes: %w", err) - } + routeManager.SelectAllRoutes() + } else if err := routeManager.SelectRoutes(toNetIDs(req.GetNetworkIDs()), req.GetAppend()); err != nil { + return nil, err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) s.statusRecorder.PublishEvent( proto.SystemEvent_INFO, @@ -213,19 +204,11 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe return nil, fmt.Errorf("no route manager") } - routeSelector := routeManager.GetRouteSelector() if req.GetAll() { - routeSelector.DeselectAllRoutes() - } else { - routes := toNetIDs(req.GetNetworkIDs()) - routesMap := routeManager.GetClientRoutesWithNetID() - routes = route.ExpandV6ExitPairs(routes, routesMap) - netIdRoutes := maps.Keys(routesMap) - if err := routeSelector.DeselectRoutes(routes, netIdRoutes); err != nil { - return nil, fmt.Errorf("deselect routes: %w", err) - } + routeManager.DeselectAllRoutes() + } else if err := routeManager.DeselectRoutes(toNetIDs(req.GetNetworkIDs())); err != nil { + return nil, err } - routeManager.TriggerSelection(routeManager.GetClientRoutes()) s.statusRecorder.PublishEvent( proto.SystemEvent_INFO, @@ -249,3 +232,4 @@ func toNetIDs(routes []string) []route.NetID { } return netIDs } + diff --git a/client/server/panic_windows.go b/client/server/panic_windows.go index 8592f12ad..4bed6662f 100644 --- a/client/server/panic_windows.go +++ b/client/server/panic_windows.go @@ -3,6 +3,7 @@ package server import ( + "errors" "fmt" "os" "path" @@ -69,7 +70,7 @@ func setStdHandle(f *os.File) error { handle := f.Fd() r0, _, e1 := setStdHandleFn.Call(stdErrorHandle, handle) if r0 == 0 { - if e1 != nil { + if !errors.Is(e1, syscall.Errno(0)) { return e1 } return syscall.EINVAL diff --git a/client/server/probe_throttle.go b/client/server/probe_throttle.go new file mode 100644 index 000000000..ec6137e15 --- /dev/null +++ b/client/server/probe_throttle.go @@ -0,0 +1,88 @@ +package server + +import ( + "context" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +// healthProbeRunner runs the full, expensive probe (network round-trips to +// management, signal and the relays) and reports whether every component was +// healthy. ctx cancels the probe when the caller gives up. Satisfied by +// *internal.Engine. +type healthProbeRunner interface { + RunHealthProbes(ctx context.Context, waitForResult bool) bool +} + +// statsRefresher does the cheap WireGuard-stats refresh callers fall back to +// when a fresh probe isn't warranted. Satisfied by *peer.Status. +type statsRefresher interface { + RefreshWireGuardStats() error +} + +// probeThrottle rate-limits and single-flights the daemon's health probes. +// +// Health probes are expensive (network round-trips to management, signal and +// the relays), while Status(GetFullPeerStatus=true) RPCs can arrive frequently +// and concurrently — the desktop UI alone issues one per connect/disconnect. +// probeThrottle keeps that load bounded with two rules: +// +// - Single-flight: only one probe runs at a time. Callers that pile up while +// a probe is in flight share its result instead of each launching another, +// even when that probe failed. A failed probe therefore does not make every +// waiter re-probe in turn; the next, non-overlapping caller can try again. +// - Throttle: after a fully successful probe the result is cached for +// interval. While any component is unhealthy the cache is not advanced, so +// later callers keep probing frequently and notice recovery quickly — the +// intentional "probe often while unhealthy" behaviour from the original +// design. +type probeThrottle struct { + interval time.Duration + + mu sync.Mutex + lastOK time.Time // last fully-successful probe; drives the throttle window + completedAt time.Time // when the most recent probe finished; drives single-flight sharing +} + +func newProbeThrottle(interval time.Duration) *probeThrottle { + return &probeThrottle{interval: interval} +} + +// Run decides whether to run a fresh health probe or serve the most recent +// result. It serialises concurrent callers: at most one runner.RunHealthProbes +// executes at a time and the rest call refresher.RefreshWireGuardStats and read +// the snapshot it produced. +// +// Both calls run while the throttle's lock is held, so a slow probe blocks +// other callers until it completes — that blocking is the single-flight +// guarantee. ctx is forwarded to RunHealthProbes so a caller that gives up +// cancels the in-flight probe (and any caller still queued on the lock falls +// through quickly once it acquires it, since the probe ctx is already done). +func (t *probeThrottle) Run(ctx context.Context, runner healthProbeRunner, refresher statsRefresher, waitForResult bool) { + entered := time.Now() + + t.mu.Lock() + defer t.mu.Unlock() + + // A probe that finished after we entered ran while we were waiting on the + // lock — i.e. a peer in the same burst already probed for us, so share its + // result rather than launch another. This holds even when that probe + // failed, so a failed probe doesn't make every waiter re-probe in turn. + sharedRecentProbe := t.completedAt.After(entered) + throttled := time.Since(t.lastOK) <= t.interval + + if sharedRecentProbe || throttled { + if err := refresher.RefreshWireGuardStats(); err != nil { + log.Debugf("failed to refresh WireGuard stats: %v", err) + } + return + } + + healthy := runner.RunHealthProbes(ctx, waitForResult) + t.completedAt = time.Now() + if healthy { + t.lastOK = t.completedAt + } +} diff --git a/client/server/probe_throttle_test.go b/client/server/probe_throttle_test.go new file mode 100644 index 000000000..cae776fa4 --- /dev/null +++ b/client/server/probe_throttle_test.go @@ -0,0 +1,109 @@ +package server + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +// fakeProber implements both healthProbeRunner and statsRefresher with +// caller-supplied behaviour. +type fakeProber struct { + onProbe func() bool + onRefresh func() +} + +func (f fakeProber) RunHealthProbes(context.Context, bool) bool { + return f.onProbe() +} + +func (f fakeProber) RefreshWireGuardStats() error { + if f.onRefresh != nil { + f.onRefresh() + } + return nil +} + +func TestProbeThrottle_CachesAfterSuccess(t *testing.T) { + pt := newProbeThrottle(time.Minute) + + var probes, refreshes int + prober := fakeProber{ + onProbe: func() bool { probes++; return true }, + onRefresh: func() { refreshes++ }, + } + + pt.Run(context.Background(), prober, prober, false) + pt.Run(context.Background(), prober, prober, false) + + if probes != 1 { + t.Fatalf("expected 1 probe within the throttle window, got %d", probes) + } + if refreshes != 1 { + t.Fatalf("expected the throttled caller to refresh stats once, got %d", refreshes) + } +} + +func TestProbeThrottle_StaysOpenWhileUnhealthy(t *testing.T) { + pt := newProbeThrottle(time.Minute) + + var probes int + prober := fakeProber{onProbe: func() bool { probes++; return false }} // never healthy + + // Sequential, non-overlapping callers must each re-probe while unhealthy: + // a failed probe does not advance the throttle window. + pt.Run(context.Background(), prober, prober, false) + pt.Run(context.Background(), prober, prober, false) + pt.Run(context.Background(), prober, prober, false) + + if probes != 3 { + t.Fatalf("expected every non-overlapping caller to probe while unhealthy, got %d", probes) + } +} + +func TestProbeThrottle_SingleFlightSharesResult(t *testing.T) { + pt := newProbeThrottle(time.Minute) + + var probes int32 + release := make(chan struct{}) + started := make(chan struct{}) + + // First caller blocks inside the probe until released, holding the lock so + // the others pile up behind it. + prober := fakeProber{onProbe: func() bool { + if atomic.AddInt32(&probes, 1) == 1 { + close(started) + <-release + } + return false // unhealthy — the share must happen regardless of result + }} + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + pt.Run(context.Background(), prober, prober, false) + }() + + <-started // ensure the first probe is in flight before the burst arrives + + const waiters = 9 + wg.Add(waiters) + for i := 0; i < waiters; i++ { + go func() { + defer wg.Done() + pt.Run(context.Background(), prober, prober, false) + }() + } + + // Give the waiters time to block on the lock, then let the first finish. + time.Sleep(50 * time.Millisecond) + close(release) + wg.Wait() + + if got := atomic.LoadInt32(&probes); got != 1 { + t.Fatalf("expected a concurrent burst to run exactly 1 probe, got %d", got) + } +} diff --git a/client/server/server.go b/client/server/server.go index a4d53a823..f33e19075 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -19,6 +19,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" gstatus "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/expose" @@ -67,7 +68,28 @@ type Server struct { logFile string + // uiLogPath is the desktop UI's absolute log path, reported via + // RegisterUILog. Guarded by mutex. Consumed by DebugBundle so the bundle + // can collect the GUI log even though the daemon runs as root and can't + // resolve the user's config dir. Last-writer-wins (one UI per socket). + // DebugBundle opens it on behalf of the bundle requester and refuses a file + // that caller does not own, so a local user cannot read another user's log + // or a root-only file through it. + uiLogPath string + oauthAuthFlow oauthAuthFlow + // extendAuthSessionFlow holds the pending PKCE flow created by + // RequestExtendAuthSession until WaitExtendAuthSession resolves it. + // Kept separate from oauthAuthFlow (which is reserved for the SSH + // JWT path) so a concurrent SSH auth doesn't clobber the session + // extend flow or vice versa. + extendAuthSessionFlow *auth.PendingFlow + + // guardedConfigMu serializes a privilege check against the write it + // authorizes. Without it the two are separate steps over the same file, and a + // change that was allowed because the profile had the SSH server disabled + // could land after a concurrent privileged request enabled it. + guardedConfigMu sync.Mutex mutex sync.Mutex config *profilemanager.Config @@ -87,7 +109,7 @@ type Server struct { statusRecorder *peer.Status sessionWatcher *internal.SessionWatcher - lastProbe time.Time + probeThrottle *probeThrottle persistSyncResponse bool isSessionActive atomic.Bool @@ -113,6 +135,13 @@ type Server struct { updateManager *updater.Manager jwtCache *jwtCache + + // loginAttemptFn stands in for the Management login round trip. Tests set + // it to drive the login outcomes that need a server on the other end; + // production leaves it nil, and every login goes through loginAttempt. + loginAttemptFn func(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) + + isLoginRequiredFn func(ctx context.Context) (bool, error) } type oauthAuthFlow struct { @@ -135,6 +164,8 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable captureEnabled: captureEnabled, networksDisabled: networksDisabled, jwtCache: newJWTCache(), + extendAuthSessionFlow: auth.NewPendingFlow(), + probeThrottle: newProbeThrottle(probeThreshold), } agent := &serverAgent{s} s.sleepHandler = sleephandler.New(agent) @@ -152,12 +183,21 @@ func (s *Server) Start() error { } state := internal.CtxGetState(s.rootCtx) + // Every contextState.Set in the connect/login/server paths must push a + // SubscribeStatus snapshot, otherwise transitions that don't happen to + // be accompanied by a Mark{Management,Signal,...} call (e.g. plain + // StatusNeedsLogin after a PermissionDenied login, StatusLoginFailed + // after OAuth init failure, StatusIdle in the Login defer) leave the + // UI stuck on the previous status until the next unrelated peer event. + // Binding the recorder here means new state.Set callsites don't have + // to opt in individually. + state.SetOnChange(s.statusRecorder.NotifyStateChange) if err := handlePanicLog(); err != nil { log.Warnf("failed to redirect stderr: %v", err) } - if err := restoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { + if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { log.Warnf(errRestoreResidualState, err) } @@ -214,7 +254,6 @@ func (s *Server) Start() error { s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive) - s.statusRecorder.UpdateLazyConnection(config.LazyConnectionEnabled) if s.sessionWatcher == nil { s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder) @@ -236,7 +275,7 @@ func (s *Server) Start() error { s.clientRunningChan = make(chan struct{}) s.clientGiveUpChan = make(chan struct{}) go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) - s.publishConfigChangedEvent("startup") + s.publishConfigChangedEvent(proto.MetadataSourceStartup) return nil } @@ -253,6 +292,10 @@ func (s *Server) Start() error { // "intent" (clientRunning) is maintained by the RPC handlers, not by this // goroutine. func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}, giveUpChan chan struct{}) { + // close(giveUpChan) MUST run on every exit path (DisableAutoConnect + // return, backoff.Retry return, panic) — Down() blocks for up to 5s + // waiting on this signal before flipping the state to Idle, and a + // missed close leaves Down() always hitting the timeout. defer func() { if giveUpChan != nil { close(giveUpChan) @@ -291,6 +334,15 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil runOperation := func() error { err := s.connect(ctx, profileConfig, statusRecorder, runningChan) if err != nil { + // PermissionDenied means the daemon transitioned to NeedsLogin + // inside connect(). Without backoff.Permanent the outer retry + // re-enters connect(), which resets the state to Connecting and + // makes the tray flicker between NeedsLogin and Connecting until + // the user logs in. Stop retrying and let the state stick. + if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied { + log.Debugf("run client connection exited with PermissionDenied, waiting for login") + return backoff.Permanent(err) + } log.Debugf("run client connection exited with error: %v. Will retry in the background", err) return err } @@ -325,7 +377,34 @@ func (s *Server) connectionGoroutineRunning() bool { } } -// loginAttempt attempts to login using the provided information. it returns a status in case something fails +// attemptLogin runs a login round trip against Management, or the stand-in a +// test installed in place of it. +func (s *Server) attemptLogin(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) { + if s.loginAttemptFn != nil { + return s.loginAttemptFn(ctx, setupKey, jwtToken) + } + return s.loginAttempt(ctx, setupKey, jwtToken) +} + +func (s *Server) isLoginRequired(ctx context.Context) (bool, error) { + if s.isLoginRequiredFn != nil { + return s.isLoginRequiredFn(ctx) + } + + authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config) + if err != nil { + log.Errorf("failed to create auth client: %v", err) + return false, err + } + defer authClient.Close() + + return authClient.IsLoginRequired(ctx) +} + +// loginAttempt attempts to login using the provided information. It returns +// StatusNeedsLogin when Management refused the peer's credentials and +// StatusLoginFailed for every other failure, so callers can tell an +// authentication decision apart from a login that never got made. func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) { authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config) if err != nil { @@ -351,6 +430,16 @@ func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (i // Login uses setup key to prepare configuration for the daemon. func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigRequest) (*proto.SetConfigResponse, error) { + // Privilege gate: refuse the parts of the request that would let a local + // user turn the root daemon into a root shell. Held across the write so the + // config cannot gain the SSH server between the decision and the update. + // + // Taken before s.mutex: authorizeAndPrepareLogin takes s.mutex while holding + // guardedConfigMu, so acquiring the two in the other order here would let a + // concurrent login deadlock the daemon. + s.guardedConfigMu.Lock() + defer s.guardedConfigMu.Unlock() + s.mutex.Lock() defer s.mutex.Unlock() @@ -375,6 +464,14 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques return nil, err } + stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username) + if err != nil { + return nil, err + } + if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromSetConfig(msg)); err != nil { + return nil, err + } + config, err := s.setConfigInputFromRequest(msg) if err != nil { return nil, err @@ -425,7 +522,7 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile wgPort := int(*msg.WireguardPort) config.WireguardPort = &wgPort } - if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != "" { + if msg.OptionalPreSharedKey != nil { config.PreSharedKey = msg.OptionalPreSharedKey } @@ -463,7 +560,6 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile config.DisableFirewall = msg.DisableFirewall config.BlockLANAccess = msg.BlockLanAccess config.DisableNotifications = msg.DisableNotifications - config.LazyConnectionEnabled = msg.LazyConnectionEnabled config.BlockInbound = msg.BlockInbound config.DisableIPv6 = msg.DisableIpv6 config.EnableSSHRoot = msg.EnableSSHRoot @@ -502,22 +598,23 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro } } - s.mutex.Lock() - if s.actCancel != nil { - s.actCancel() - } - ctx, cancel := context.WithCancel(callerCtx) - - md, ok := metadata.FromIncomingContext(callerCtx) - if ok { - ctx = metadata.NewOutgoingContext(ctx, md) + activeProf, err := s.profileManager.GetActiveProfileState() + if err != nil { + log.Errorf("failed to get active profile state: %v", err) + return nil, fmt.Errorf("failed to get active profile state: %w", err) } - s.actCancel = cancel - s.mutex.Unlock() - - if err := restoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { - log.Warnf(errRestoreResidualState, err) + // Privilege gate: same restrictions as SetConfig, since LoginRequest can carry + // the same fields. It runs before anything here changes daemon state, so a + // refused login neither switches the profile nor cancels a login already in + // progress, and it reads the profile the request targets, which is the one the + // switch below would activate. + stored, err := s.storedLoginConfig(activeProf, msg) + if err != nil { + return nil, err + } + if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil { + return nil, err } state := internal.CtxGetState(s.rootCtx) @@ -528,23 +625,16 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro } }() - activeProf, err := s.profileManager.GetActiveProfileState() + ctx, activeProf, err := s.authorizeAndPrepareLogin(callerCtx, msg, activeProf) if err != nil { - log.Errorf("failed to get active profile state: %v", err) - return nil, fmt.Errorf("failed to get active profile state: %w", err) - } - - if msg.ProfileName != nil { - if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { - log.Errorf("failed to switch profile: %v", err) - return nil, err + // The RPC boundary is where this gets recorded: nothing logs handler + // errors for us, and a caller that retries would otherwise leave no + // trace in the daemon log. A refusal is skipped because the gate has + // already logged the decision, with the caller's identity. + if gstatus.Code(err) != codes.PermissionDenied { + log.Errorf("failed to prepare login: %v", err) } - } - - activeProf, err = s.profileManager.GetActiveProfileState() - if err != nil { - log.Errorf("failed to get active profile state: %v", err) - return nil, fmt.Errorf("failed to get active profile state: %w", err) + return nil, err } log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username) @@ -558,11 +648,6 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro s.mutex.Unlock() - if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil { - log.Errorf("failed to persist login overrides: %v", err) - return nil, fmt.Errorf("persist login overrides: %w", err) - } - config, _, err := s.getConfig(activeProf) if err != nil { log.Errorf("failed to get active profile config: %v", err) @@ -572,13 +657,23 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro s.config = config s.mutex.Unlock() - if _, err := s.loginAttempt(ctx, "", ""); err == nil { + // A probe that errors leaves the login undecided: Management unreachable, a + // restart mid-request, an internal error. Those are returned for the caller + // to retry, because turning them into an SSO prompt asks the user to solve + // something that is not theirs to solve, and a browser login cannot succeed + // while Management is unreachable anyway. Only Management refusing the + // peer's key is a decision, and IsLoginRequired reports that as + // needsLogin=true rather than an error. + needsLogin, err := s.isLoginRequired(ctx) + if err != nil { + state.Set(internal.StatusLoginFailed) + return nil, err + } + if !needsLogin { state.Set(internal.StatusIdle) return &proto.LoginResponse{}, nil } - state.Set(internal.StatusConnecting) - if msg.SetupKey == "" { hint := "" if msg.Hint != nil { @@ -593,6 +688,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro if s.oauthAuthFlow.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) { if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) { log.Debugf("using previous oauth flow info") + state.Set(internal.StatusNeedsLogin) return &proto.LoginResponse{ NeedsSSOLogin: true, VerificationURI: s.oauthAuthFlow.info.VerificationURI, @@ -629,7 +725,12 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro }, nil } - if loginStatus, err := s.loginAttempt(ctx, msg.SetupKey, ""); err != nil { + // Setup-key path: we are about to dial Management with the key, so the + // Connecting paint is meaningful here — unlike the SSO branch above, + // which returns NeedsLogin and parks on the browser leg. + state.Set(internal.StatusConnecting) + + if loginStatus, err := s.attemptLogin(ctx, msg.SetupKey, ""); err != nil { state.Set(loginStatus) return nil, err } @@ -637,8 +738,43 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro return &proto.LoginResponse{}, nil } -// WaitSSOLogin uses the userCode to validate the TokenInfo and -// waits for the user to continue with the login on a browser +// WaitSSOLogin validates the supplied userCode against the in-flight OAuth +// device/PKCE flow and blocks until the user finishes the browser leg. +// +// The daemon holds StatusNeedsLogin for the whole browser wait (set on +// entry): the login is not done until the token returns, so a client that +// (re)attaches mid-wait — a restarted UI, a second `netbird up` — reads +// "login required" and offers the affordance, instead of a Connecting that +// never resolves. The wait is also tied to the caller's context (see the +// goroutine below), so a client that goes away cancels the wait instead of +// orphaning it on rootCtx until the device-code window expires. +// +// State transitions on exit: +// +// ┌──────────────────────────────────────────┬──────────────────────────────────┐ +// │ Outcome │ contextState │ +// ├──────────────────────────────────────────┼──────────────────────────────────┤ +// │ Success → loginAttempt ok │ NeedsLogin held; the caller's Up │ +// │ │ drives Connecting → Connected │ +// │ Success → loginAttempt → still-NeedsLogin│ StatusNeedsLogin (loginAttempt) │ +// │ Success → loginAttempt error │ StatusLoginFailed (loginAttempt) │ +// │ UserCode mismatch │ StatusLoginFailed │ +// │ WaitToken: context.Canceled │ NeedsLogin held. Caller gone │ +// │ (caller went away — UI restart / │ (UI/CLI) → a fresh client │ +// │ Ctrl+C — or internal abort: profile │ shows the login affordance; │ +// │ switch / app quit / another │ internal aborts are │ +// │ WaitSSOLogin via actCancel/waitCancel) │ overwritten by the next Up. │ +// │ WaitToken: context.DeadlineExceeded │ StatusNeedsLogin │ +// │ (OAuth device-code window expired │ (retryable; the UI's "Connect" │ +// │ while waiting on the browser leg) │ re-enters the Login flow) │ +// │ WaitToken: any other error │ StatusLoginFailed │ +// │ (access_denied, expired_token, HTTP │ (genuine auth/IO failure; │ +// │ failure, token validation rejection) │ surfaced verbatim to caller) │ +// └──────────────────────────────────────────┴──────────────────────────────────┘ +// +// The defer still applies a StatusIdle fallback for the early +// oauth-flow-not-initialized return (before the entry Set), so a half state +// doesn't leak when there is nothing to wait on. func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLoginRequest) (*proto.WaitSSOLoginResponse, error) { s.mutex.Lock() if s.actCancel != nil { @@ -646,6 +782,21 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin } ctx, cancel := context.WithCancel(s.rootCtx) + // Tie the in-flight browser wait to the caller. ctx stays rooted in + // rootCtx so CtxGetState resolves the daemon's contextState, but if the + // UI window or CLI that drove the login goes away mid-flow (restart, + // Ctrl+C) the gRPC callerCtx cancels and we cancel the wait instead of + // orphaning it on rootCtx until the OAuth device-code window expires. + // The goroutine exits as soon as either context completes, so it can't + // outlive the RPC. + go func() { + select { + case <-callerCtx.Done(): + cancel() + case <-ctx.Done(): + } + }() + md, ok := metadata.FromIncomingContext(callerCtx) if ok { ctx = metadata.NewOutgoingContext(ctx, md) @@ -671,7 +822,11 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin } }() - state.Set(internal.StatusConnecting) + // Hold NeedsLogin for the whole browser wait — the login is not done + // until the token returns, so a client that (re)attaches mid-wait + // (restarted UI, second `netbird up`) reads "login required" and offers + // the affordance instead of a Connecting that never resolves. + state.Set(internal.StatusNeedsLogin) s.mutex.Lock() flowInfo := s.oauthAuthFlow.info @@ -698,7 +853,30 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin s.mutex.Lock() s.oauthAuthFlow.expiresAt = time.Now() s.mutex.Unlock() - state.Set(internal.StatusLoginFailed) + switch { + case errors.Is(err, context.Canceled): + // External abort. If our caller cancelled (the client closed + // the browser-login popup, or the UI went away — callerCtx is + // done), clear the abandoned OAuth flow so a fresh Login starts + // a new device code instead of reusing this one. The entry + // NeedsLogin stays in place, so a reattaching client shows the + // login affordance. An internal abort (actCancel from a new + // Login/WaitSSOLogin, callerCtx still live) leaves the flow for + // the new owner — don't clobber it. + if callerCtx.Err() != nil { + s.mutex.Lock() + s.oauthAuthFlow = oauthAuthFlow{} + s.mutex.Unlock() + } + case errors.Is(err, context.DeadlineExceeded): + // OAuth device-code window expired with no user action. + // Retryable — leave the daemon in NeedsLogin so the UI + // keeps the Login affordance instead of reading as a + // hard failure. + state.Set(internal.StatusNeedsLogin) + default: + state.Set(internal.StatusLoginFailed) + } log.Errorf("waiting for browser login failed: %v", err) return nil, err } @@ -707,11 +885,12 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin s.oauthAuthFlow.expiresAt = time.Now() s.mutex.Unlock() - if loginStatus, err := s.loginAttempt(ctx, "", tokenInfo.GetTokenToUse()); err != nil { + if loginStatus, err := s.attemptLogin(ctx, "", tokenInfo.GetTokenToUse()); err != nil { state.Set(loginStatus) return nil, err } + log.Infof("SSO login flow finished, returning success to caller") return &proto.WaitSSOLoginResponse{ Email: tokenInfo.Email, }, nil @@ -719,6 +898,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin // Up starts engine work in the daemon. func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) { + log.Infof("up request received") s.mutex.Lock() // clientRunning is the daemon-intent flag (set by previous Up/Start, cleared // by Down). connectionGoroutineRunning() reports whether the previous retry-loop @@ -740,7 +920,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR return s.waitForUp(callerCtx) } - if err := restoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil { + if err := RestoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil { log.Warnf(errRestoreResidualState, err) } @@ -755,6 +935,22 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR return nil, err } + // StatusNeedsLogin is a legitimate fresh-start entry state: a successful + // WaitSSOLogin deliberately leaves the daemon in NeedsLogin (the login is + // done, the token is in hand, but the engine hasn't been brought up yet — + // see WaitSSOLogin's state-transition table). The same holds after a + // mid-session expiry tore the engine down (clientRunning == false) and the + // user re-authenticated. In both cases the caller's Up is expected to drive + // the connection; treat NeedsLogin like Idle and reset to Idle so the + // engine's own StatusConnecting → StatusConnected progression starts from a + // clean slate. Without this, the first Up after an SSO login fails with + // "up already in progress" and the user has to trigger Up a second time + // (CLI: re-run `netbird up`; GUI: click Connect again). + if status == internal.StatusNeedsLogin { + status = internal.StatusIdle + state.Set(internal.StatusIdle) + } + if status != internal.StatusIdle { s.mutex.Unlock() return nil, fmt.Errorf("up already in progress: current status %s", status) @@ -817,9 +1013,12 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR s.clientGiveUpChan = make(chan struct{}) go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) - s.publishConfigChangedEvent("up_rpc") + s.publishConfigChangedEvent(proto.MetadataSourceUpRPC) s.mutex.Unlock() + if msg.GetAsync() { + return &proto.UpResponse{}, nil + } return s.waitForUp(callerCtx) } @@ -843,6 +1042,63 @@ func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error) } } +// storedProfileConfig loads the on-disk config of the profile a request +// targets, so a privileged-change decision can be made against the values the +// profile currently holds. A profile that has no config file yet yields nil, +// which every caller must read as "nothing enabled yet". +func (s *Server) storedProfileConfig(handle, username string) (*profilemanager.Config, error) { + resolved, err := s.resolveProfileHandle(handle, username) + if err != nil { + return nil, err + } + + path := resolved.Path + if path == "" { + path = profilemanager.DefaultConfigPath + } + + return s.storedConfigAtPath(path) +} + +// storedLoginConfig loads the on-disk config of the profile a login request +// targets: the one it names, or the active one when it names none. Used to decide +// a privileged change before the request is allowed to switch profiles. +func (s *Server) storedLoginConfig(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest) (*profilemanager.Config, error) { + if msg.ProfileName == nil { + cfgPath, err := activeProf.FilePath() + if err != nil { + return nil, fmt.Errorf("active profile file path: %w", err) + } + return s.storedConfigAtPath(cfgPath) + } + + // Mirrors switchProfileIfNeeded: the default profile resolves without a + // username, so this reads the same profile the switch would activate. + handle := *msg.ProfileName + username := "" + if handle != profilemanager.DefaultProfileName { + username = msg.GetUsername() + } + return s.storedProfileConfig(handle, username) +} + +// storedConfigAtPath reads a profile config file, yielding nil when it does not +// exist yet. +func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, nil //nolint:nilnil + } + return nil, fmt.Errorf("stat profile config: %w", err) + } + + cfg, err := profilemanager.GetConfig(path) + if err != nil { + return nil, fmt.Errorf("read profile config: %w", err) + } + return cfg, nil +} + // 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. @@ -929,6 +1185,10 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi s.config = config + if msg != nil && msg.ProfileName != nil { + s.publishProfileListChanged(*msg.ProfileName) + } + return &proto.SwitchProfileResponse{Id: activeProf.ID.String()}, nil } @@ -940,28 +1200,45 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes if err := s.cleanupConnection(); err != nil { s.mutex.Unlock() - // todo review to update the status in case any type of error + if errors.Is(err, ErrServiceNotUp) { + log.Debugf("Down called while service not up: %v", err) + return nil, err + } log.Errorf("failed to shut down properly: %v", err) return nil, err } - state := internal.CtxGetState(s.rootCtx) - state.Set(internal.StatusIdle) - s.mutex.Unlock() // Wait for the connectWithRetryRuns goroutine to finish with a short timeout. // This prevents the goroutine from setting ErrResetConnection after Down() returns. - // The giveUpChan is closed at the end of connectWithRetryRuns. + // The giveUpChan is closed by the goroutine's deferred cleanup (see + // connectWithRetryRuns) on every exit path. A timeout here typically + // means the goroutine is still wedged inside a slow teardown step. if giveUpChan != nil { select { case <-giveUpChan: - log.Debugf("client goroutine finished successfully") + log.Debugf("client goroutine finished, giveUpChan closed") case <-time.After(5 * time.Second): log.Warnf("timeout waiting for client goroutine to finish, proceeding anyway") } } + // Set Idle only after the retry goroutine has exited (or timed out). + // Setting it earlier races with the goroutine's own Set(StatusConnecting) + // at the top of each retry attempt, which would leave the snapshot + // stuck at Connecting long after the user asked to disconnect. + internal.CtxGetState(s.rootCtx).Set(internal.StatusIdle) + + // Clear stale management/signal errors so the next Up() (typically for a + // different profile) starts with a clean status snapshot. Without this, + // a managementError left over from a LoginFailed cycle persists in the + // statusRecorder and appears in the new profile's initial + // SubscribeStatus snapshot, making the new profile look like it also + // failed to log in. + s.statusRecorder.MarkManagementDisconnected(nil) + s.statusRecorder.MarkSignalDisconnected(nil) + return &proto.DownResponse{}, nil } @@ -993,9 +1270,13 @@ 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 + log.Errorf("failed to stop engine during cleanup: %v", err) } } @@ -1035,6 +1316,12 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque if err := s.logoutFromProfile(ctx, resolved); err != nil { log.Errorf("failed to logout from profile %s: %v", resolved.ID, err) + // A refused deregistration is already a status error carrying the reason + // and the command to run; rewrapping it as Internal would flatten both + // into a gRPC dump for the user. + if _, isStatus := gstatus.FromError(err); isStatus { + return nil, err + } return nil, gstatus.Errorf(codes.Internal, "logout: %v", err) } @@ -1156,6 +1443,13 @@ func (s *Server) sendLogoutRequest(ctx context.Context) error { } func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profilemanager.Config) error { + // Privilege gate: deregistering frees this machine's key to be registered + // against another management server, which is only restricted while the SSH + // server makes that a privilege handover. + if err := requirePrivilegeForDeregistration(ctx, config); err != nil { + return err + } + key, err := wgtypes.ParseKey(config.PrivateKey) if err != nil { return fmt.Errorf("parse private key: %w", err) @@ -1172,7 +1466,19 @@ func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profil } }() - return mgmClient.Logout() + if err := mgmClient.Logout(); err != nil { + // The peer is already gone from the management server (e.g. deleted + // from the dashboard). The logout's goal — deregistering this peer — + // is therefore already satisfied, so treat NotFound as success rather + // than blocking the logout/profile-removal flow. + if logoutPeerGone(err) { + log.Infof("peer already removed from management server, treating logout as successful") + return nil + } + return err + } + + return nil } // Status returns the daemon status @@ -1225,9 +1531,24 @@ func (s *Server) Status( } } - status, err := internal.CtxGetState(s.rootCtx).Status() + return s.buildStatusResponse(ctx, msg) +} + +// buildStatusResponse composes a StatusResponse from the current daemon +// state. Shared between the unary Status RPC and the SubscribeStatus +// stream so both paths return identical snapshots. ctx scopes the health +// probe runProbes may trigger — a caller that disconnects cancels it. +func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusRequest) (*proto.StatusResponse, error) { + state := internal.CtxGetState(s.rootCtx) + status, err := state.Status() if err != nil { - return nil, err + // state.Status() blanks the status when err is set (e.g. management + // retry loop wrapped a connection error). The underlying status is + // still meaningful and the failure is already surfaced via + // FullStatus.ManagementState.Error, so don't propagate err — that + // would tear down the SubscribeStatus stream and cause the UI to + // mark the daemon as unreachable on every retry. + status = state.CurrentStatus() } if status == internal.StatusNeedsLogin && s.isSessionActive.Load() { @@ -1238,15 +1559,20 @@ func (s *Server) Status( statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()} + if deadline := s.statusRecorder.GetSessionExpiresAt(); !deadline.IsZero() { + statusResponse.SessionExpiresAt = timestamppb.New(deadline) + } + s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive) if msg.GetFullPeerStatus { - s.runProbes(msg.ShouldRunProbes) + s.runProbes(ctx, msg.ShouldRunProbes) fullStatus := s.statusRecorder.GetFullStatus() pbFullStatus := fullStatus.ToProto() pbFullStatus.Events = s.statusRecorder.GetEventHistory() pbFullStatus.SshServerState = s.getSSHServerState() + pbFullStatus.NetworksRevision = s.statusRecorder.GetNetworksRevision() statusResponse.FullStatus = pbFullStatus } @@ -1397,8 +1723,8 @@ func (s *Server) RequestJWTAuth( hint = profilemanager.GetLoginHint() } - isDesktop := isUnixRunningDesktop() - oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, false, hint) + // the daemon has no graphical session of its own, only the caller can answer this + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint) if err != nil { return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err) } @@ -1467,6 +1793,154 @@ func (s *Server) WaitJWTToken( }, nil } +// RequestExtendAuthSession initiates the SSO session-extension flow and +// returns the verification URI the UI should open. The flow state is held +// in s.extendAuthSessionFlow until WaitExtendAuthSession resolves it. +func (s *Server) RequestExtendAuthSession( + ctx context.Context, + msg *proto.RequestExtendAuthSessionRequest, +) (*proto.RequestExtendAuthSessionResponse, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + s.mutex.Lock() + config := s.config + connectClient := s.connectClient + s.mutex.Unlock() + + if config == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not configured") + } + if connectClient == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running") + } + if connectClient.Engine() == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "session can no longer be extended, log in again to reconnect") + } + + hint := "" + if msg.Hint != nil { + hint = *msg.Hint + } + if hint == "" { + hint = profilemanager.GetLoginHint() + } + + // the daemon has no graphical session of its own, only the caller can answer this + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint) + if err != nil { + return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err) + } + + authInfo, err := oAuthFlow.RequestAuthInfo(ctx) + if err != nil { + return nil, gstatus.Errorf(codes.Internal, "failed to request auth info: %v", err) + } + + s.extendAuthSessionFlow.Set(oAuthFlow, authInfo) + + return &proto.RequestExtendAuthSessionResponse{ + VerificationURI: authInfo.VerificationURI, + VerificationURIComplete: authInfo.VerificationURIComplete, + UserCode: authInfo.UserCode, + DeviceCode: authInfo.DeviceCode, + ExpiresIn: int64(authInfo.ExpiresIn), + }, nil +} + +// WaitExtendAuthSession blocks until the user completes the SSO step +// initiated by RequestExtendAuthSession, then forwards the resulting JWT +// to the management server's ExtendAuthSession RPC. The returned deadline +// is also applied locally via the engine so SubscribeStatus consumers see +// the refreshed state. +func (s *Server) WaitExtendAuthSession( + ctx context.Context, + req *proto.WaitExtendAuthSessionRequest, +) (*proto.WaitExtendAuthSessionResponse, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + oAuthFlow, authInfo, ok := s.extendAuthSessionFlow.Get() + + s.mutex.Lock() + connectClient := s.connectClient + s.mutex.Unlock() + + if !ok || authInfo.DeviceCode != req.DeviceCode { + return nil, gstatus.Errorf(codes.InvalidArgument, "invalid device code or no active extend-session flow") + } + + // Preempt a previous WaitExtendAuthSession (e.g. when the tray + // notification and the about-to-expire dialog both start a flow on + // the same deadline). The older waiter exits via context.Canceled; + // the new one takes over the IdP poll. + s.extendAuthSessionFlow.CancelWait() + + waitCtx, cancel := context.WithCancel(ctx) + defer cancel() + s.extendAuthSessionFlow.SetWaitCancel(cancel) + + tokenInfo, err := oAuthFlow.WaitToken(waitCtx, authInfo) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, gstatus.Errorf(codes.Canceled, "extend-session flow preempted") + } + return nil, gstatus.Errorf(codes.Internal, "failed to obtain JWT token: %v", err) + } + + // Clear pending flow before talking to mgm so a retry can re-initiate. + s.extendAuthSessionFlow.Clear() + + if connectClient == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not running") + } + engine := connectClient.Engine() + if engine == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "engine is not initialised") + } + + deadline, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()) + if err != nil { + // Log the full wrapped chain, but return only the innermost gRPC + // status (code + clean desc) so the UI shows the root cause, not + // the daemon's wrapping layers. + log.Errorf("management ExtendAuthSession failed: %v", err) + if st := innermostStatus(err); st != nil { + return nil, gstatus.Error(st.Code(), st.Message()) + } + return nil, gstatus.Errorf(codes.Internal, "%v", err) + } + + resp := &proto.WaitExtendAuthSessionResponse{} + if !deadline.IsZero() { + resp.SessionExpiresAt = timestamppb.New(deadline) + } + return resp, nil +} + +// DismissSessionWarning forwards the user's "Dismiss" click on the +// T-WarningLead notification down to the engine's sessionWatcher so the +// T-FinalWarningLead fallback is suppressed for the current deadline. +// Best-effort: when the client/engine is not yet running the call is a +// successful no-op (the watcher has no deadline to dismiss anyway). +func (s *Server) DismissSessionWarning( + _ context.Context, + _ *proto.DismissSessionWarningRequest, +) (*proto.DismissSessionWarningResponse, error) { + s.mutex.Lock() + connectClient := s.connectClient + s.mutex.Unlock() + if connectClient == nil { + return &proto.DismissSessionWarningResponse{}, nil + } + if engine := connectClient.Engine(); engine != nil { + engine.DismissSessionWarning() + } + return &proto.DismissSessionWarningResponse{}, nil +} + // ExposeService exposes a local port via the NetBird reverse proxy. func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.DaemonService_ExposeServiceServer) error { s.mutex.Lock() @@ -1526,14 +2000,7 @@ func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.Daemon return nil } -func isUnixRunningDesktop() bool { - if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { - return false - } - return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != "" -} - -func (s *Server) runProbes(waitForProbeResult bool) { +func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) { if s.connectClient == nil { return } @@ -1543,15 +2010,7 @@ func (s *Server) runProbes(waitForProbeResult bool) { return } - if time.Since(s.lastProbe) > probeThreshold { - if engine.RunHealthProbes(waitForProbeResult) { - s.lastProbe = time.Now() - } - } else { - if err := s.statusRecorder.RefreshWireGuardStats(); err != nil { - log.Debugf("failed to refresh WireGuard stats: %v", err) - } - } + s.probeThrottle.Run(ctx, engine, s.statusRecorder, waitForProbeResult) } // GetConfig of the daemon. @@ -1643,7 +2102,6 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p ServerSSHAllowed: *cfg.ServerSSHAllowed, RosenpassEnabled: cfg.RosenpassEnabled, RosenpassPermissive: cfg.RosenpassPermissive, - LazyConnectionEnabled: cfg.LazyConnectionEnabled, BlockInbound: cfg.BlockInbound, DisableNotifications: disableNotifications, NetworkMonitor: networkMonitor, @@ -1681,6 +2139,8 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) ( return nil, fmt.Errorf("failed to create profile: %w", err) } + s.publishProfileListChanged(msg.ProfileName) + return &proto.AddProfileResponse{Id: created.ID.String()}, nil } @@ -1707,6 +2167,8 @@ func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequ return nil, fmt.Errorf("failed to rename profile: %w", err) } + s.publishProfileListChanged(msg.NewProfileName) + return &proto.RenameProfileResponse{OldProfileName: resolved.Name}, nil } @@ -1729,7 +2191,10 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ } if err := s.logoutFromProfile(ctx, resolved); err != nil { - log.Warnf("failed to logout from profile %s before removal: %v", resolved.ID, err) + // Deregistration is best-effort here: the local profile is removed + // either way, so an unprivileged caller leaves the peer registered on + // the management server rather than being blocked from removing it. + log.Warnf("removing profile %s locally without deregistering it: %v", resolved.ID, err) } if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil { @@ -1737,9 +2202,51 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ return nil, fmt.Errorf("failed to remove profile: %w", err) } + s.publishProfileListChanged(msg.ProfileName) + return &proto.RemoveProfileResponse{Id: resolved.ID.String()}, nil } +// publishProfileListChanged nudges the desktop UI to refresh its profile list +// after a CLI-driven add/remove. The daemon exposes no dedicated +// profile-changed RPC event, and a profile add/remove doesn't move the +// connection status, so the UI's SubscribeStatus path never fires for it (and +// the tray's status-string guard would swallow it anyway). Instead we publish +// a marked INFO/SYSTEM event over SubscribeEvents: the UI's dispatchSystemEvent +// recognises the metadata "kind" marker and translates it into its internal +// profile-changed signal that both the tray menu and the React profile views +// already subscribe to (see proto.MetadataKindProfileListChanged, recognised in +// client/ui/services/daemon_feed.go). userMessage is intentionally empty so this +// stays a silent refresh signal rather than a user-facing notification. +func (s *Server) publishProfileListChanged(profileName string) { + s.statusRecorder.PublishEvent( + proto.SystemEvent_INFO, + proto.SystemEvent_SYSTEM, + "Profile list changed", + "", + map[string]string{proto.MetadataKindKey: proto.MetadataKindProfileListChanged, proto.MetadataProfileKey: profileName}, + ) +} + +// publishLogLevelChanged signals the desktop UI that the daemon log level +// changed, so it can attach/detach its rotated gui-client.log. Like +// publishProfileListChanged, this rides the SubscribeEvents stream as a marked +// INFO/SYSTEM event (kind "log-level-changed", level the lowercase logrus +// name); the UI's dispatchSystemEvent recognises the marker and routes it to +// the logging toggle instead of an OS toast (userMessage is empty so it stays +// a silent control signal). The "level" value matches log.Level.String() +// (e.g. "debug", "info") so the UI can parse it directly. See +// proto.MetadataKindLogLevelChanged, recognised in client/ui/services/daemon_feed.go. +func (s *Server) publishLogLevelChanged(level string) { + s.statusRecorder.PublishEvent( + proto.SystemEvent_INFO, + proto.SystemEvent_SYSTEM, + "Log level changed", + "", + map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level}, + ) +} + // ListProfiles lists all profiles in the daemon. func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesRequest) (*proto.ListProfilesResponse, error) { s.mutex.Lock() @@ -1811,11 +2318,33 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest) DisableProfiles: s.checkProfilesDisabled(), DisableUpdateSettings: s.checkUpdateSettingsDisabled(), DisableNetworks: s.checkNetworksDisabled(), + DisableAdvancedView: s.checkDisableAdvancedView(), } return features, nil } +// WailsUIReady is a no-op the Wails UI probes at startup; merely answering it +// (rather than returning Unimplemented) tells the UI this daemon is new enough. +func (s *Server) WailsUIReady(context.Context, *proto.WailsUIReadyRequest) (*proto.WailsUIReadyResponse, error) { + return &proto.WailsUIReadyResponse{}, nil +} + +// checkDisableAdvancedView reports the MDM-policy directive for the +// upcoming UI's advanced-view section. Tristate: returns nil when no +// MDM directive is set so the UI applies its own default; returns +// &true / &false when MDM explicitly enforces. No CLI flag backs +// this feature — MDM is the sole source. +func (s *Server) checkDisableAdvancedView() *bool { + if s.config == nil { + return nil + } + if v, ok := s.config.Policy().GetBool(mdm.KeyDisableAdvancedView); ok { + return &v + } + return nil +} + func (s *Server) connect(ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}) error { log.Tracef("running client connection") client := internal.NewConnectClient(ctx, config, statusRecorder) @@ -1962,6 +2491,69 @@ func sendTerminalNotification() error { // persistLoginOverrides writes management URL and pre-shared key from a LoginRequest to the // active profile config so that subsequent reads pick them up. Empty/nil values are ignored. +// afterLoginPreCheck is a seam for tests to run a concurrent config change +// between Login's first privilege check and the authoritative one. +var afterLoginPreCheck func() + +// authorizeAndPrepareLogin makes the authoritative privilege decision for a login +// and, when it passes, carries out every state change that decision authorizes: +// cancelling an login already in progress, switching to the requested profile, and +// persisting the config overrides the request carries. +// +// All of it happens under guardedConfigMu, which SetConfig also holds across its +// own check and write. Login's earlier check refuses the ordinary case before any +// of this is reached; this one exists because that check is not synchronized +// against a concurrent privileged request that enables the SSH server, and a +// caller refused here must not have cancelled or switched anything either. +func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto.LoginRequest, activeProf *profilemanager.ActiveProfileState) (context.Context, *profilemanager.ActiveProfileState, error) { + if afterLoginPreCheck != nil { + afterLoginPreCheck() + } + + s.guardedConfigMu.Lock() + defer s.guardedConfigMu.Unlock() + + stored, err := s.storedLoginConfig(activeProf, msg) + if err != nil { + return nil, nil, err + } + if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil { + return nil, nil, err + } + + s.mutex.Lock() + if s.actCancel != nil { + s.actCancel() + } + ctx, cancel := context.WithCancel(callerCtx) + if md, ok := metadata.FromIncomingContext(callerCtx); ok { + ctx = metadata.NewOutgoingContext(ctx, md) + } + s.actCancel = cancel + s.mutex.Unlock() + + if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { + log.Warnf(errRestoreResidualState, err) + } + + if msg.ProfileName != nil { + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + return nil, nil, fmt.Errorf("switch profile: %w", err) + } + } + + activeProf, err = s.profileManager.GetActiveProfileState() + if err != nil { + return nil, nil, fmt.Errorf("active profile state: %w", err) + } + + if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil { + return nil, nil, fmt.Errorf("persist login overrides: %w", err) + } + + return ctx, activeProf, nil +} + func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, managementURL string, preSharedKey *string) error { if preSharedKey != nil && *preSharedKey == "" { preSharedKey = nil @@ -1985,3 +2577,28 @@ func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, manage } return nil } + +// logoutPeerGone reports whether a management Logout failed because the peer +// no longer exists server-side (gRPC NotFound), walking the wrap chain since +// the client wraps the gRPC status with fmt.Errorf. +func logoutPeerGone(err error) bool { + for e := err; e != nil; e = errors.Unwrap(e) { + if s, ok := gstatus.FromError(e); ok && s.Code() == codes.NotFound { + return true + } + } + return false +} + +// innermostStatus walks the wrap chain and returns the deepest gRPC status, +// or nil when none is present. gstatus.FromError does not unwrap, so a status +// wrapped with fmt.Errorf %w would otherwise be missed. +func innermostStatus(err error) *gstatus.Status { + var found *gstatus.Status + for e := err; e != nil; e = errors.Unwrap(e) { + if s, ok := gstatus.FromError(e); ok { + found = s + } + } + return found +} diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go new file mode 100644 index 000000000..0366ccb31 --- /dev/null +++ b/client/server/server_privileged_test.go @@ -0,0 +1,252 @@ +//go:build privileged + +package server + +import ( + "context" + "net" + "os/user" + "path/filepath" + "testing" + "time" + + "go.uber.org/mock/gomock" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/job" + + "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/groups" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + "google.golang.org/grpc/keepalive" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/activity" + nbcache "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/telemetry" + mgmtProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/signal/proto" + signalServer "github.com/netbirdio/netbird/signal/server" +) + +var ( + kaep = keepalive.EnforcementPolicy{ + MinTime: 15 * time.Second, + PermitWithoutStream: true, + } + + kasp = keepalive.ServerParameters{ + MaxConnectionIdle: 15 * time.Second, + MaxConnectionAgeGrace: 5 * time.Second, + Time: 5 * time.Second, + Timeout: 2 * time.Second, + } +) + +// TestConnectStopsRetryOnPermissionDenied verifies connectWithRetryRuns stops after a single login +// attempt on PermissionDenied, despite the fast retry config that would otherwise drive several. +func TestConnectStopsRetryOnPermissionDenied(t *testing.T) { + // Redirect profile paths to a temp dir so the test does not need root. + tempDir := t.TempDir() + origDefaultProfileDir := profilemanager.DefaultConfigPathDir + origActiveProfileStatePath := profilemanager.ActiveProfileStatePath + origDefaultConfigPath := profilemanager.DefaultConfigPath + profilemanager.ConfigDirOverride = tempDir + profilemanager.DefaultConfigPathDir = tempDir + profilemanager.ActiveProfileStatePath = filepath.Join(tempDir, "active_profile.json") + profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json") + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDefaultProfileDir + profilemanager.ActiveProfileStatePath = origActiveProfileStatePath + profilemanager.DefaultConfigPath = origDefaultConfigPath + profilemanager.ConfigDirOverride = "" + }) + + // start the signal server + _, signalAddr, err := startSignal(t) + if err != nil { + t.Fatalf("failed to start signal server: %v", err) + } + + counter := 0 + // start the management server + _, mgmtAddr, err := startManagement(t, signalAddr, &counter) + if err != nil { + t.Fatalf("failed to start management server: %v", err) + } + + ctx := internal.CtxInitState(context.Background()) + + ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second)) + defer cancel() + // create new server + ic := profilemanager.ConfigInput{ + ManagementURL: "http://" + mgmtAddr, + ConfigPath: t.TempDir() + "/test-profile.json", + } + + config, err := profilemanager.UpdateOrCreateConfig(ic) + if err != nil { + t.Fatalf("failed to create config: %v", err) + } + + currUser, err := user.Current() + require.NoError(t, err) + + pm := profilemanager.ServiceManager{} + err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ + ID: "test-profile", + Username: currUser.Username, + }) + if err != nil { + t.Fatalf("failed to set active profile state: %v", err) + } + + s := New(ctx, "debug", "", false, false, false, false) + + s.config = config + + s.statusRecorder = peer.NewRecorder(config.ManagementURL.String()) + t.Setenv(retryInitialIntervalVar, "1s") + t.Setenv(maxRetryIntervalVar, "2s") + t.Setenv(maxRetryTimeVar, "5s") + t.Setenv(retryMultiplierVar, "1") + + s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil) + if counter != 1 { + t.Fatalf("expected exactly 1 login attempt (PermissionDenied must stop the retry loop), got %d", counter) + } +} + +type mockServer struct { + mgmtProto.ManagementServiceServer + counter *int +} + +func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) { + *m.counter++ + return m.ManagementServiceServer.Login(ctx, req) +} + +func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) { + t.Helper() + dataDir := t.TempDir() + + config := &config.Config{ + Stuns: []*config.Host{}, + TURNConfig: &config.TURNConfig{}, + Signal: &config.Host{ + Proto: "http", + URI: signalAddr, + }, + Datadir: dataDir, + HttpConfig: nil, + } + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + return nil, "", err + } + s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) + store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir) + if err != nil { + return nil, "", err + } + t.Cleanup(cleanUp) + + eventStore := &activity.InMemoryEventStore{} + if err != nil { + return nil, "", err + } + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + permissionsManagerMock := permissions.NewMockManager(ctrl) + peersManager := peers.NewManager(store, permissionsManagerMock) + settingsManagerMock := settings.NewMockManager(ctrl) + + jobManager := job.NewJobManager(nil, store, peersManager) + + cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + return nil, "", err + } + + ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore) + + metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + require.NoError(t, err) + + settingsMockManager := settings.NewMockManager(ctrl) + groupsManager := groups.NewManagerMock() + + requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) + peersUpdateManager := update_channel.NewPeersUpdateManager(metrics) + networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) + accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) + if err != nil { + return nil, "", err + } + + secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) + if err != nil { + return nil, "", err + } + mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil) + if err != nil { + return nil, "", err + } + mock := &mockServer{ + ManagementServiceServer: mgmtServer, + counter: counter, + } + mgmtProto.RegisterManagementServiceServer(s, mock) + go func() { + if err = s.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } + }() + + return s, lis.Addr().String(), nil +} + +func startSignal(t *testing.T) (*grpc.Server, string, error) { + t.Helper() + + s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + return nil, "", err + } + + srv, err := signalServer.NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + proto.RegisterSignalExchangeServer(s, srv) + + go func() { + if err = s.Serve(lis); err != nil { + log.Fatalf("failed to serve: %v", err) + } + }() + + return s, lis.Addr().String(), nil +} diff --git a/client/server/server_test.go b/client/server/server_test.go index fa9599818..7717cfcf8 100644 --- a/client/server/server_test.go +++ b/client/server/server_test.go @@ -2,124 +2,22 @@ package server import ( "context" - "net" "net/url" "os/user" "path/filepath" "testing" "time" - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel" - - "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" - - "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" - "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" - "github.com/netbirdio/netbird/management/internals/modules/peers" - "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" - nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" - "github.com/netbirdio/netbird/management/server/job" - - "github.com/netbirdio/netbird/management/internals/server/config" - "github.com/netbirdio/netbird/management/server/groups" - log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/grpc" - "google.golang.org/grpc/keepalive" "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" daemonProto "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/management/server" - "github.com/netbirdio/netbird/management/server/activity" - nbcache "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" - "github.com/netbirdio/netbird/management/server/permissions" - "github.com/netbirdio/netbird/management/server/settings" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/telemetry" - mgmtProto "github.com/netbirdio/netbird/shared/management/proto" - "github.com/netbirdio/netbird/shared/signal/proto" - signalServer "github.com/netbirdio/netbird/signal/server" ) -var ( - kaep = keepalive.EnforcementPolicy{ - MinTime: 15 * time.Second, - PermitWithoutStream: true, - } - - kasp = keepalive.ServerParameters{ - MaxConnectionIdle: 15 * time.Second, - MaxConnectionAgeGrace: 5 * time.Second, - Time: 5 * time.Second, - Timeout: 2 * time.Second, - } -) - -// TestConnectWithRetryRuns checks that the connectWithRetry function runs and runs the retries according to the times specified via environment variables -// we will use a management server started via to simulate the server and capture the number of retries -func TestConnectWithRetryRuns(t *testing.T) { - // start the signal server - _, signalAddr, err := startSignal(t) - if err != nil { - t.Fatalf("failed to start signal server: %v", err) - } - - counter := 0 - // start the management server - _, mgmtAddr, err := startManagement(t, signalAddr, &counter) - if err != nil { - t.Fatalf("failed to start management server: %v", err) - } - - ctx := internal.CtxInitState(context.Background()) - - ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second)) - defer cancel() - // create new server - ic := profilemanager.ConfigInput{ - ManagementURL: "http://" + mgmtAddr, - ConfigPath: t.TempDir() + "/test-profile.json", - } - - config, err := profilemanager.UpdateOrCreateConfig(ic) - if err != nil { - t.Fatalf("failed to create config: %v", err) - } - - currUser, err := user.Current() - require.NoError(t, err) - - pm := profilemanager.ServiceManager{} - err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ - ID: "test-profile", - Username: currUser.Username, - }) - if err != nil { - t.Fatalf("failed to set active profile state: %v", err) - } - - s := New(ctx, "debug", "", false, false, false, false) - - s.config = config - - s.statusRecorder = peer.NewRecorder(config.ManagementURL.String()) - t.Setenv(retryInitialIntervalVar, "1s") - t.Setenv(maxRetryIntervalVar, "2s") - t.Setenv(maxRetryTimeVar, "5s") - t.Setenv(retryMultiplierVar, "1") - - s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil) - if counter < 3 { - t.Fatalf("expected counter > 2, got %d", counter) - } -} - func TestServer_Up(t *testing.T) { tempDir := t.TempDir() origDefaultProfileDir := profilemanager.DefaultConfigPathDir @@ -259,119 +157,3 @@ func TestServer_SubcribeEvents(t *testing.T) { assert.NoError(t, err) } - -type mockServer struct { - mgmtProto.ManagementServiceServer - counter *int -} - -func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) { - *m.counter++ - return m.ManagementServiceServer.Login(ctx, req) -} - -func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) { - t.Helper() - dataDir := t.TempDir() - - config := &config.Config{ - Stuns: []*config.Host{}, - TURNConfig: &config.TURNConfig{}, - Signal: &config.Host{ - Proto: "http", - URI: signalAddr, - }, - Datadir: dataDir, - HttpConfig: nil, - } - - lis, err := net.Listen("tcp", "localhost:0") - if err != nil { - return nil, "", err - } - s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) - store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir) - if err != nil { - return nil, "", err - } - t.Cleanup(cleanUp) - - eventStore := &activity.InMemoryEventStore{} - if err != nil { - return nil, "", err - } - - ctrl := gomock.NewController(t) - t.Cleanup(ctrl.Finish) - - permissionsManagerMock := permissions.NewMockManager(ctrl) - peersManager := peers.NewManager(store, permissionsManagerMock) - settingsManagerMock := settings.NewMockManager(ctrl) - - jobManager := job.NewJobManager(nil, store, peersManager) - - cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) - if err != nil { - return nil, "", err - } - - ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore) - - metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) - require.NoError(t, err) - - settingsMockManager := settings.NewMockManager(ctrl) - groupsManager := groups.NewManagerMock() - - requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) - peersUpdateManager := update_channel.NewPeersUpdateManager(metrics) - networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config) - accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) - if err != nil { - return nil, "", err - } - - secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) - if err != nil { - return nil, "", err - } - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil) - if err != nil { - return nil, "", err - } - mock := &mockServer{ - ManagementServiceServer: mgmtServer, - counter: counter, - } - mgmtProto.RegisterManagementServiceServer(s, mock) - go func() { - if err = s.Serve(lis); err != nil { - log.Fatalf("failed to serve: %v", err) - } - }() - - return s, lis.Addr().String(), nil -} - -func startSignal(t *testing.T) (*grpc.Server, string, error) { - t.Helper() - - s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp)) - - lis, err := net.Listen("tcp", "localhost:0") - if err != nil { - log.Fatalf("failed to listen: %v", err) - } - - srv, err := signalServer.NewServer(context.Background(), otel.Meter("")) - require.NoError(t, err) - proto.RegisterSignalExchangeServer(s, srv) - - go func() { - if err = s.Serve(lis); err != nil { - log.Fatalf("failed to serve: %v", err) - } - }() - - return s, lis.Addr().String(), nil -} diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index 9818f9fdf..ae323ea8c 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -66,7 +66,11 @@ func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profN Username: currUser.Username, })) - ctx = context.Background() + // The privileged-change gate reads the caller's kernel identity from the + // context, which a real caller gets from the daemon's transport credentials. + // This test drives the handler directly, so it stands in for a root caller; + // without an identity the gate would (correctly) refuse the SSH fields. + ctx = privilegedTestCtx() s = New(ctx, "console", "", false, false, false, false) return s, ctx, profName, currUser.Username, cfgPath } @@ -181,6 +185,43 @@ func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) { require.NotNil(t, resp) } +// TestSetConfig_MDMAllow_ManagementURLPortNormalized covers the +// regression from discussion #6483: MDM URL without explicit port vs +// UI echo with the parseURL-appended default port must be treated as +// a no-op echo, not a conflict. +func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) { + tests := []struct { + name string + mdmURL string + submitURL string + }{ + {"policy_no_port_submit_with_443", "https://netbird.corp.example", "https://netbird.corp.example:443"}, + {"policy_with_443_submit_no_port", "https://netbird.corp.example:443", "https://netbird.corp.example"}, + {"http_policy_no_port_submit_with_80", "http://netbird.corp.example", "http://netbird.corp.example:80"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: tc.mdmURL, + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + rosenpassEnabled := true + resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: tc.submitURL, + RosenpassEnabled: &rosenpassEnabled, + }) + + require.NoError(t, err, "port-normalized URL echo must not trip MDM conflict gate") + require.NotNil(t, resp) + }) + } +} + func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) { // No MDM policy active: any field can be written. withMDMPolicy(t, mdm.NewPolicy(nil)) diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index 7c85d16ce..db7a26f03 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -1,7 +1,6 @@ package server import ( - "context" "os/user" "path/filepath" "reflect" @@ -52,7 +51,11 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { }) require.NoError(t, err) - ctx := context.Background() + // The privileged-change gate reads the caller's kernel identity from the + // context, which a real caller gets from the daemon's transport credentials. + // This test drives the handler directly, so it stands in for a root caller; + // without an identity the gate would (correctly) refuse the SSH fields. + ctx := privilegedTestCtx() s := New(ctx, "console", "", false, false, false, false) rosenpassEnabled := true @@ -69,43 +72,41 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { disableFirewall := true blockLANAccess := true disableNotifications := true - lazyConnectionEnabled := true blockInbound := true disableIPv6 := true mtu := int64(1280) sshJWTCacheTTL := int32(300) req := &proto.SetConfigRequest{ - ProfileName: profName, - Username: currUser.Username, - ManagementUrl: "https://new-api.netbird.io:443", - AdminURL: "https://new-admin.netbird.io", - RosenpassEnabled: &rosenpassEnabled, - RosenpassPermissive: &rosenpassPermissive, - ServerSSHAllowed: &serverSSHAllowed, - InterfaceName: &interfaceName, - WireguardPort: &wireguardPort, - OptionalPreSharedKey: &preSharedKey, - DisableAutoConnect: &disableAutoConnect, - NetworkMonitor: &networkMonitor, - DisableClientRoutes: &disableClientRoutes, - DisableServerRoutes: &disableServerRoutes, - DisableDns: &disableDNS, - DisableFirewall: &disableFirewall, - BlockLanAccess: &blockLANAccess, - DisableNotifications: &disableNotifications, - LazyConnectionEnabled: &lazyConnectionEnabled, - BlockInbound: &blockInbound, - DisableIpv6: &disableIPv6, - NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"}, - CleanNATExternalIPs: false, - CustomDNSAddress: []byte("1.1.1.1:53"), - ExtraIFaceBlacklist: []string{"eth1", "eth2"}, - DnsLabels: []string{"label1", "label2"}, - CleanDNSLabels: false, - DnsRouteInterval: durationpb.New(2 * time.Minute), - Mtu: &mtu, - SshJWTCacheTTL: &sshJWTCacheTTL, + ProfileName: profName, + Username: currUser.Username, + ManagementUrl: "https://new-api.netbird.io:443", + AdminURL: "https://new-admin.netbird.io", + RosenpassEnabled: &rosenpassEnabled, + RosenpassPermissive: &rosenpassPermissive, + ServerSSHAllowed: &serverSSHAllowed, + InterfaceName: &interfaceName, + WireguardPort: &wireguardPort, + OptionalPreSharedKey: &preSharedKey, + DisableAutoConnect: &disableAutoConnect, + NetworkMonitor: &networkMonitor, + DisableClientRoutes: &disableClientRoutes, + DisableServerRoutes: &disableServerRoutes, + DisableDns: &disableDNS, + DisableFirewall: &disableFirewall, + BlockLanAccess: &blockLANAccess, + DisableNotifications: &disableNotifications, + BlockInbound: &blockInbound, + DisableIpv6: &disableIPv6, + NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"}, + CleanNATExternalIPs: false, + CustomDNSAddress: []byte("1.1.1.1:53"), + ExtraIFaceBlacklist: []string{"eth1", "eth2"}, + DnsLabels: []string{"label1", "label2"}, + CleanDNSLabels: false, + DnsRouteInterval: durationpb.New(2 * time.Minute), + Mtu: &mtu, + SshJWTCacheTTL: &sshJWTCacheTTL, } _, err = s.SetConfig(ctx, req) @@ -140,7 +141,6 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, blockLANAccess, cfg.BlockLANAccess) require.NotNil(t, cfg.DisableNotifications) require.Equal(t, disableNotifications, *cfg.DisableNotifications) - require.Equal(t, lazyConnectionEnabled, cfg.LazyConnectionEnabled) require.Equal(t, blockInbound, cfg.BlockInbound) require.Equal(t, disableIPv6, cfg.DisableIPv6) require.Equal(t, []string{"1.2.3.4", "5.6.7.8"}, cfg.NATExternalIPs) @@ -164,13 +164,14 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { t.Helper() metadataFields := map[string]bool{ - "state": true, // protobuf internal - "sizeCache": true, // protobuf internal - "unknownFields": true, // protobuf internal - "Username": true, // metadata - "ProfileName": true, // metadata - "CleanNATExternalIPs": true, // control flag for clearing - "CleanDNSLabels": true, // control flag for clearing + "state": true, // protobuf internal + "sizeCache": true, // protobuf internal + "unknownFields": true, // protobuf internal + "Username": true, // metadata + "ProfileName": true, // metadata + "CleanNATExternalIPs": true, // control flag for clearing + "CleanDNSLabels": true, // control flag for clearing + "LazyConnectionEnabled": true, // deprecated: proto field retained for compat, no longer applied } expectedFields := map[string]bool{ @@ -190,7 +191,6 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "DisableFirewall": true, "BlockLanAccess": true, "DisableNotifications": true, - "LazyConnectionEnabled": true, "BlockInbound": true, "DisableIpv6": true, "NatExternalIPs": true, @@ -252,7 +252,6 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "block-lan-access": "BlockLanAccess", "block-inbound": "BlockInbound", "disable-ipv6": "DisableIpv6", - "enable-lazy-connection": "LazyConnectionEnabled", "external-ip-map": "NatExternalIPs", "dns-resolver-address": "CustomDNSAddress", "extra-iface-blacklist": "ExtraIFaceBlacklist", @@ -269,7 +268,8 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { // SetConfigRequest fields that don't have CLI flags (settable only via UI or other means). fieldsWithoutCLIFlags := map[string]bool{ - "DisableNotifications": true, // Only settable via UI + "DisableNotifications": true, // Only settable via UI + "LazyConnectionEnabled": true, // deprecated: no longer settable (managed by server + NB_LAZY_CONN) } // Get all SetConfigRequest fields to verify our map is complete. diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go new file mode 100644 index 000000000..ca1b4c4ee --- /dev/null +++ b/client/server/ssh_gate.go @@ -0,0 +1,282 @@ +package server + +import ( + "context" + "fmt" + "net/url" + "runtime" + "strings" + + log "github.com/sirupsen/logrus" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" +) + +// The daemon runs as root/LocalSystem, so a handful of config changes cross the +// user-to-root boundary and are restricted to privileged callers: +// +// - Enabling SSH root login, or disabling SSH authentication, turns the +// daemon's SSH server into a root (or unauthenticated) shell. +// - Enabling the SSH server at all is what makes the above reachable, and a +// profile the caller owns is not a privilege they hold. +// - While the SSH server is enabled, repointing the profile at another +// management identity hands SSH authorization decisions, including which +// keys and users are accepted, to whoever controls that identity. Changing +// the management URL and deregistering the peer are both ways to do that. +// +// Everything else stays unauthenticated, so this is not an authorization model: +// it only refuses the changes that would let a local user become root. A caller +// whose identity cannot be established is refused as well. + +// privilegedConfigChange is the subset of a config request that crosses the +// user-to-root boundary. Fields are nil or empty when the request leaves them +// untouched. +type privilegedConfigChange struct { + managementURL string + serverSSHAllowed *bool + enableSSHRoot *bool + disableSSHAuth *bool +} + +func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange { + return privilegedConfigChange{ + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, + } +} + +func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange { + return privilegedConfigChange{ + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, + } +} + +// requirePrivilegeForConfigChange refuses the privileged parts of a config +// change when the caller is not root/administrator. stored is the profile's +// current config, or nil when it has none yet. +// +// Each check compares against the stored value so that a request restating a +// value it does not change is never refused: a UI that submits the whole +// settings form must not start failing once an administrator has enabled SSH. +func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager.Config, change privilegedConfigChange) error { + if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.EnableSSHRoot }), change.enableSSHRoot) { + return denyPrivileged(ctx, "enabling SSH root login", ipcauth.UpCommand("--enable-ssh-root")) + } + + if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.DisableSSHAuth }), change.disableSSHAuth) { + return denyPrivileged(ctx, "disabling SSH authentication", ipcauth.UpCommand("--disable-ssh-auth")) + } + + if enables(sshServerCurrentlyAllowed(stored), change.serverSSHAllowed) { + return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh")) + } + + // Only guard the management binding while the SSH server is enabled: that is + // when the management identity decides who may open a shell here. + if !sshServerEnabled(stored) { + return nil + } + + if change.managementURL != "" && !sameManagementURL(stored.ManagementURL, change.managementURL) { + return denyPrivileged(ctx, + "changing the management URL while the NetBird SSH server is enabled", + ipcauth.UpCommand("-m "+change.managementURL)) + } + + return nil +} + +// requirePrivilegeForDeregistration refuses to deregister the peer from the +// management server when the caller is not privileged and the profile has the +// SSH server enabled. Deregistering frees the peer's key to be registered +// against another management identity, which is the same handover the +// management URL check refuses. +// +// Callers that treat deregistration as best-effort (profile removal) continue +// without it; callers that were asked to deregister surface the error. +func requirePrivilegeForDeregistration(ctx context.Context, cfg *profilemanager.Config) error { + if !sshServerEnabled(cfg) { + return nil + } + + return denyPrivileged(ctx, + "deregistering this peer while the NetBird SSH server is enabled", + ipcauth.ElevatedCommand("netbird logout")) +} + +// denyPrivileged returns nil when the caller is privileged, and otherwise a +// PermissionDenied whose message names the action and the command that performs +// it with the privileges it needs. The same summary and command ride along as an +// ErrorInfo detail so the CLI and the UI can present them without parsing text. +// +// action reads as the subject of a sentence ("enabling SSH root login"), and +// command is the equivalent command, already elevated for the platform. +func denyPrivileged(ctx context.Context, action, command string) error { + id, ok := ipcauth.CallerIdentity(ctx) + if !ok { + log.Warnf("denying %s: the caller's identity cannot be verified on this control channel", action) + return privilegeError(unidentifiedSummary(action), reinstallCommand()) + } + + if ipcauth.IsPrivilegedCaller(id) { + log.Infof("allowing %s for privileged caller %s", action, id) + return nil + } + + log.Warnf("denying %s for unprivileged caller %s", action, id) + actor, command := requiredActor(command) + return privilegeError(privilegeSummary(action, actor), command) +} + +// requiredActor names who may perform the operation and adjusts the command to +// match. A daemon that is not itself privileged delegates to its own identity, so +// telling that host's user to become root is wrong twice over: root is not what the +// daemon checks for, and a rootless container has neither root nor sudo. +func requiredActor(command string) (string, string) { + self, delegates := ipcauth.SelfDelegatesTo() + if !delegates { + return ipcauth.PrivilegedActor(), command + } + return fmt.Sprintf("the user the daemon runs as (%s)", self), strings.ReplaceAll(command, "sudo ", "") +} + +// privilegeError builds the PermissionDenied carrying summary and command. +func privilegeError(summary, command string) error { + st := gstatus.New(codes.PermissionDenied, fmt.Sprintf("%s\n\n%s", summary, command)) + + detailed, err := st.WithDetails(&errdetails.ErrorInfo{ + Reason: ipcauth.ErrorReasonPrivilegeRequired, + Domain: ipcauth.ErrorDomain, + Metadata: map[string]string{ + ipcauth.ErrorMetaSummary: summary, + ipcauth.ErrorMetaCommand: command, + }, + }) + if err != nil { + log.Debugf("attach privilege error detail: %v", err) + return st.Err() + } + return detailed.Err() +} + +// privilegeSummary states what is refused and what it needs, in one sentence +// that reads the same in a dialog and in a terminal. +func privilegeSummary(action, actor string) string { + return fmt.Sprintf("%s requires %s.", capitalize(action), actor) +} + +// unidentifiedSummary covers a control channel that carries no caller identity. +// Elevating does not help there, so it points at the daemon's socket instead. +func unidentifiedSummary(action string) string { + return fmt.Sprintf("%s requires %s, and the daemon cannot verify who is calling over its current socket. "+ + "Reinstall the service on a socket that carries the caller's identity.", capitalize(action), ipcauth.PrivilegedActor()) +} + +// reinstallCommand is the command that moves the daemon onto a socket whose +// callers can be identified. +func reinstallCommand() string { + if runtime.GOOS == "windows" { + return fmt.Sprintf("netbird service install --daemon-addr %s", daemonaddr.WindowsPipeAddr) + } + return "sudo netbird service install --daemon-addr unix:///var/run/netbird.sock" +} + +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// enables reports whether requested turns a flag on that is currently off. A +// request that restates the stored value, or turns the flag off, is not a +// privileged change. +func enables(stored, requested *bool) bool { + if requested == nil || !*requested { + return false + } + return stored == nil || !*stored +} + +// storedFlag reads a flag from the stored config, tolerating a config that does +// not exist yet. +func storedFlag(cfg *profilemanager.Config, get func(*profilemanager.Config) *bool) *bool { + if cfg == nil { + return nil + } + return get(cfg) +} + +// sshServerEnabled reports whether the profile currently runs the SSH server. +// +// A nil flag means ON, matching what the engine does with the same config +// (util.ReturnBoolWithDefaultTrue in internal/connect.go, kept for configs written +// before the flag existed). Reading it as OFF here would open the management-URL +// and deregistration guards on exactly those legacy hosts, whose SSH server is +// running. Configs loaded through profilemanager have already been materialised by +// apply(), so this is the same answer by a route that does not depend on that. +func sshServerEnabled(cfg *profilemanager.Config) bool { + if cfg == nil { + return false + } + return util.ReturnBoolWithDefaultTrue(cfg.ServerSSHAllowed) +} + +// sshServerCurrentlyAllowed is the value an enable request is compared against. It +// shares sshServerEnabled's nil-means-on default, so restating "on" for a legacy +// config is correctly seen as no change. +func sshServerCurrentlyAllowed(cfg *profilemanager.Config) *bool { + enabled := sshServerEnabled(cfg) + if cfg == nil { + return nil + } + return &enabled +} + +// sameManagementURL reports whether requested addresses the same management +// server as stored, comparing scheme, host and effective port so that an +// equivalent spelling ("https://api.netbird.io" for a stored +// "https://api.netbird.io:443") is not treated as a change. It fails closed: +// anything unparseable counts as a change and therefore needs privilege. +func sameManagementURL(stored *url.URL, requested string) bool { + if stored == nil { + return false + } + + // Normalise the requested URL through the config layer's own parser, so the + // comparison cannot drift from how the value would actually be stored. + parsed, err := profilemanager.ParseServiceURL("Management URL", requested) + if err != nil { + return false + } + + return stored.Scheme == parsed.Scheme && + stored.Hostname() == parsed.Hostname() && + effectivePort(stored) == effectivePort(parsed) +} + +func effectivePort(u *url.URL) string { + if port := u.Port(); port != "" { + return port + } + switch u.Scheme { + case "https": + return "443" + case "http": + return "80" + default: + return "" + } +} diff --git a/client/server/ssh_gate_test.go b/client/server/ssh_gate_test.go new file mode 100644 index 000000000..cbd345f16 --- /dev/null +++ b/client/server/ssh_gate_test.go @@ -0,0 +1,348 @@ +package server + +import ( + "context" + "net/url" + "os" + "runtime" + "strings" + "testing" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// ctxWithIdentity builds a request context carrying the identity the transport +// credentials would have attached. +func ctxWithIdentity(id ipcauth.Identity) context.Context { + return peer.NewContext(context.Background(), &peer.Peer{ + AuthInfo: ipcauth.AuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity}, + Identity: id, + }, + }) +} + +// unprivUID is deliberately not this process's own uid. An unprivileged daemon +// treats a caller sharing its identity as privileged (rootless containers), and +// the test binary would otherwise stand in for both the daemon and the caller. +// os.Geteuid returns -1 on Windows, where identities are SIDs instead and this is +// unused. +var unprivUID = uint32(os.Geteuid() + 1) + +// The fabricated identities have to be shaped like the platform's: a uid says +// nothing on Windows, and a zero uid there would read as root and be privileged. +func rootCtx() context.Context { return ctxWithIdentity(privilegedIdentity()) } +func userCtx() context.Context { return ctxWithIdentity(unprivilegedIdentity()) } + +func privilegedIdentity() ipcauth.Identity { + if runtime.GOOS == "windows" { + // LocalSystem, which is what the Windows service account is. + return ipcauth.Identity{SID: "S-1-5-18"} + } + return ipcauth.Identity{UID: 0} +} + +func unprivilegedIdentity() ipcauth.Identity { + if runtime.GOOS == "windows" { + // A plain user SID: no groups, so no BUILTIN\Administrators, and not + // elevated. + return ipcauth.Identity{SID: "S-1-5-21-1-2-3-1001"} + } + return ipcauth.Identity{UID: unprivUID, GID: unprivUID} +} +func noIdentityCtx() context.Context { return context.Background() } + +func boolPtr(v bool) *bool { return &v } + +func mustURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse %q: %v", raw, err) + } + return u +} + +func assertDenied(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected the change to be refused, got nil") + } + st := gstatus.Convert(err) + if st.Code() != codes.PermissionDenied { + t.Fatalf("code = %v, want PermissionDenied", st.Code()) + } + // The refusal must be machine-readable: the CLI and the UI render the + // summary and command from the detail rather than parsing the message. + var info *errdetails.ErrorInfo + for _, d := range st.Details() { + if got, ok := d.(*errdetails.ErrorInfo); ok { + info = got + } + } + if info == nil { + t.Fatal("refusal carries no ErrorInfo detail") + } + if info.GetReason() != ipcauth.ErrorReasonPrivilegeRequired || info.GetDomain() != ipcauth.ErrorDomain { + t.Fatalf("detail = %s/%s, want %s/%s", info.GetDomain(), info.GetReason(), ipcauth.ErrorDomain, ipcauth.ErrorReasonPrivilegeRequired) + } + if info.GetMetadata()[ipcauth.ErrorMetaSummary] == "" { + t.Error("detail carries no summary") + } + if info.GetMetadata()[ipcauth.ErrorMetaCommand] == "" { + t.Error("detail carries no command") + } +} + +func assertAllowed(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("expected the change to be allowed, got %v", err) + } +} + +func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) { + tests := []struct { + name string + stored *profilemanager.Config + change privilegedConfigChange + privileged bool + wantDeny bool + }{ + { + name: "enabling the ssh server unprivileged is refused", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "enabling the ssh server as root is allowed", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + privileged: true, + }, + { + name: "restating an already enabled ssh server is not a change", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + }, + { + name: "turning the ssh server off is not guarded", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(false)}, + }, + { + name: "a profile with no config yet counts as off, so enabling is refused", + stored: nil, + change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "enabling ssh root login unprivileged is refused", + stored: &profilemanager.Config{EnableSSHRoot: boolPtr(false)}, + change: privilegedConfigChange{enableSSHRoot: boolPtr(true)}, + wantDeny: true, + }, + { + name: "restating ssh root login is not a change", + stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)}, + change: privilegedConfigChange{enableSSHRoot: boolPtr(true)}, + }, + { + name: "turning ssh root login off is not guarded", + stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)}, + change: privilegedConfigChange{enableSSHRoot: boolPtr(false)}, + }, + { + name: "disabling ssh authentication unprivileged is refused", + stored: &profilemanager.Config{DisableSSHAuth: boolPtr(false)}, + change: privilegedConfigChange{disableSSHAuth: boolPtr(true)}, + wantDeny: true, + }, + { + name: "re-enabling ssh authentication is not guarded", + stored: &profilemanager.Config{DisableSSHAuth: boolPtr(true)}, + change: privilegedConfigChange{disableSSHAuth: boolPtr(false)}, + }, + { + name: "a request that touches none of the guarded fields is allowed", + stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + change: privilegedConfigChange{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + +func TestRequirePrivilegeForConfigChange_ManagementURL(t *testing.T) { + sshOn := func(raw string) *profilemanager.Config { + return &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ManagementURL: mustURL(t, raw)} + } + sshOff := func(raw string) *profilemanager.Config { + return &profilemanager.Config{ServerSSHAllowed: boolPtr(false), ManagementURL: mustURL(t, raw)} + } + + tests := []struct { + name string + stored *profilemanager.Config + requested string + privileged bool + wantDeny bool + }{ + { + name: "moving the binding while ssh is enabled is refused", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://attacker.example.com:443", + wantDeny: true, + }, + { + name: "moving the binding as root is allowed", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://selfhosted.example.com:443", + privileged: true, + }, + { + name: "the same url restated is not a change", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://api.netbird.io:443", + }, + { + name: "an equivalent spelling of the same url is not a change", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://api.netbird.io", + }, + { + name: "an equivalent spelling with an explicit http port is not a change", + stored: sshOn("http://mgmt.internal:80"), + requested: "http://mgmt.internal", + }, + { + name: "a different port on the same host is a change", + stored: sshOn("https://api.netbird.io:443"), + requested: "https://api.netbird.io:8443", + wantDeny: true, + }, + { + name: "a different scheme on the same host is a change", + stored: sshOn("https://mgmt.internal:443"), + requested: "http://mgmt.internal:443", + wantDeny: true, + }, + { + name: "with ssh disabled the binding is not guarded at all", + stored: sshOff("https://api.netbird.io:443"), + requested: "https://attacker.example.com:443", + }, + { + name: "an unparseable url fails closed", + stored: sshOn("https://api.netbird.io:443"), + requested: "ht tp://%zz", + wantDeny: true, + }, + { + name: "an empty url leaves the binding alone", + stored: sshOn("https://api.netbird.io:443"), + requested: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForConfigChange(ctx, tt.stored, privilegedConfigChange{managementURL: tt.requested}) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + +// A caller the daemon cannot identify must be refused, not trusted: that is the +// state on a TCP daemon socket, where no peer credentials exist. +func TestRequirePrivilegeForConfigChange_UnidentifiedCallerIsRefused(t *testing.T) { + err := requirePrivilegeForConfigChange(noIdentityCtx(), + &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + privilegedConfigChange{serverSSHAllowed: boolPtr(true)}) + assertDenied(t, err) + + // The guidance must point at the socket rather than at sudo, since elevating + // would not help. + st := gstatus.Convert(err) + if !strings.Contains(st.Message(), "service install") { + t.Errorf("message %q does not tell the operator how to fix the socket", st.Message()) + } +} + +func TestRequirePrivilegeForDeregistration(t *testing.T) { + tests := []struct { + name string + cfg *profilemanager.Config + privileged bool + wantDeny bool + }{ + { + name: "deregistering while ssh is enabled is refused", + cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "deregistering while ssh is enabled is allowed for root", + cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)}, + privileged: true, + }, + { + name: "deregistering with ssh disabled is not guarded", + cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, + }, + { + name: "deregistering a profile with no config is not guarded", + cfg: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := userCtx() + if tt.privileged { + ctx = rootCtx() + } + err := requirePrivilegeForDeregistration(ctx, tt.cfg) + if tt.wantDeny { + assertDenied(t, err) + return + } + assertAllowed(t, err) + }) + } +} + +// privilegedTestCtx is the context a handler-level test should use when it is +// standing in for a root/administrator caller. Tests that drive the handlers +// directly have no transport credentials, and the privileged-change gate refuses +// a caller it cannot identify. +func privilegedTestCtx() context.Context { return rootCtx() } diff --git a/client/server/state.go b/client/server/state.go index f2d823465..a4e91468e 100644 --- a/client/server/state.go +++ b/client/server/state.go @@ -46,7 +46,7 @@ func (s *Server) CleanState(ctx context.Context, req *proto.CleanStateRequest) ( if req.All { // Reuse existing cleanup logic for all states - if err := restoreResidualState(ctx, statePath); err != nil { + if err := RestoreResidualState(ctx, statePath); err != nil { return nil, status.Errorf(codes.Internal, "failed to clean all states: %v", err) } @@ -113,9 +113,9 @@ func (s *Server) DeleteState(ctx context.Context, req *proto.DeleteStateRequest) }, nil } -// restoreResidualState checks if the client was not shut down in a clean way and restores residual if required. +// RestoreResidualState checks if the client was not shut down in a clean way and restores residual if required. // Otherwise, we might not be able to connect to the management server to retrieve new config. -func restoreResidualState(ctx context.Context, statePath string) error { +func RestoreResidualState(ctx context.Context, statePath string) error { if statePath == "" { return nil } diff --git a/client/server/status_stream.go b/client/server/status_stream.go new file mode 100644 index 000000000..c6ba547eb --- /dev/null +++ b/client/server/status_stream.go @@ -0,0 +1,57 @@ +package server + +import ( + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/proto" +) + +// SubscribeStatus pushes a fresh StatusResponse on every connection state +// change. The first message is the current snapshot, so a re-subscribing +// client doesn't need to also call Status. Subsequent messages fire when +// the peer recorder reports any of: connected/disconnected/connecting, +// management or signal flip, address change, or peers list change. +// +// The change channel coalesces bursts to a single tick. If the consumer +// is slow the daemon drops extras (not blocks), and the next snapshot +// the consumer pulls already reflects everything. +func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error { + subID, ch := s.statusRecorder.SubscribeToStateChanges() + defer func() { + s.statusRecorder.UnsubscribeFromStateChanges(subID) + log.Debug("client unsubscribed from status updates") + }() + + log.Debug("client subscribed to status updates") + + if err := s.sendStatusSnapshot(req, stream); err != nil { + return err + } + + for { + select { + case _, ok := <-ch: + if !ok { + return nil + } + if err := s.sendStatusSnapshot(req, stream); err != nil { + return err + } + case <-stream.Context().Done(): + return nil + } + } +} + +func (s *Server) sendStatusSnapshot(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error { + resp, err := s.buildStatusResponse(stream.Context(), req) + if err != nil { + log.Warnf("build status snapshot for stream: %v", err) + return err + } + if err := stream.Send(resp); err != nil { + log.Warnf("send status snapshot to stream: %v", err) + return err + } + return nil +} diff --git a/client/ssh/client/client.go b/client/ssh/client/client.go index ebf8eb794..31143a4f4 100644 --- a/client/ssh/client/client.go +++ b/client/ssh/client/client.go @@ -9,7 +9,6 @@ import ( "path/filepath" "runtime" "strconv" - "strings" "time" log "github.com/sirupsen/logrus" @@ -17,7 +16,6 @@ import ( "golang.org/x/crypto/ssh/knownhosts" "golang.org/x/term" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/profilemanager" @@ -32,7 +30,7 @@ const ( // DefaultDaemonAddr is the default address for the NetBird daemon DefaultDaemonAddr = "unix:///var/run/netbird.sock" // DefaultDaemonAddrWindows is the default address for the NetBird daemon on Windows - DefaultDaemonAddrWindows = "tcp://127.0.0.1:41731" + DefaultDaemonAddrWindows = daemonaddr.WindowsPipeAddr ) // Client wraps crypto/ssh Client for simplified SSH operations @@ -268,7 +266,7 @@ func getDefaultDaemonAddr() string { return addr } if runtime.GOOS == "windows" { - return DefaultDaemonAddrWindows + return daemonaddr.ResolveDaemonAddr(DefaultDaemonAddrWindows) } return daemonaddr.ResolveUnixDaemonAddr(DefaultDaemonAddr) } @@ -315,21 +313,23 @@ func Dial(ctx context.Context, addr, user string, opts DialOptions) (*Client, er // dialSSH establishes an SSH connection without JWT authentication func dialSSH(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*Client, error) { + if config.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, config.Timeout) + defer cancel() + } + dialer := &net.Dialer{} conn, err := dialer.DialContext(ctx, network, addr) if err != nil { return nil, fmt.Errorf("dial %s: %w", addr, err) } - clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config) + client, err := nbssh.Handshake(ctx, conn, addr, config) if err != nil { - if closeErr := conn.Close(); closeErr != nil { - log.Debugf("connection close after handshake failure: %v", closeErr) - } - return nil, fmt.Errorf("ssh handshake: %w", err) + return nil, err } - client := ssh.NewClient(clientConn, chans, reqs) return &Client{ client: client, }, nil @@ -410,12 +410,9 @@ func verifyHostKeyViaDaemon(hostname string, remote net.Addr, key ssh.PublicKey, } func connectToDaemon(daemonAddr string) (*grpc.ClientConn, error) { - addr := strings.TrimPrefix(daemonAddr, "tcp://") + target, opts := daemonaddr.DialTarget(daemonAddr) - conn, err := grpc.NewClient( - addr, - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) + conn, err := grpc.NewClient(target, opts...) if err != nil { log.Debugf("failed to create gRPC client for NetBird daemon at %s: %v", daemonAddr, err) return nil, fmt.Errorf("failed to connect to NetBird daemon: %w", err) diff --git a/client/ssh/client/client_privileged_test.go b/client/ssh/client/client_privileged_test.go new file mode 100644 index 000000000..12edbbc06 --- /dev/null +++ b/client/ssh/client/client_privileged_test.go @@ -0,0 +1,118 @@ +//go:build privileged + +package client + +import ( + "context" + "errors" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cryptossh "golang.org/x/crypto/ssh" + + "github.com/netbirdio/netbird/client/ssh/testutil" +) + +func TestSSHClient_CommandExecution(t *testing.T) { + if runtime.GOOS == "windows" && testutil.IsCI() { + t.Skip("Skipping Windows command execution tests in CI due to S4U authentication issues") + } + + server, _, client := setupTestSSHServerAndClient(t) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + defer func() { + err := client.Close() + assert.NoError(t, err) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + t.Run("ExecuteCommand captures output", func(t *testing.T) { + output, err := client.ExecuteCommand(ctx, "echo hello") + assert.NoError(t, err) + assert.Contains(t, string(output), "hello") + }) + + t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) { + err := client.ExecuteCommandWithIO(ctx, "echo world") + assert.NoError(t, err) + }) + + t.Run("commands with flags work", func(t *testing.T) { + output, err := client.ExecuteCommand(ctx, "echo -n test_flag") + assert.NoError(t, err) + assert.Equal(t, "test_flag", strings.TrimSpace(string(output))) + }) + + t.Run("non-zero exit codes don't return errors", func(t *testing.T) { + var testCmd string + if runtime.GOOS == "windows" { + testCmd = "echo hello | Select-String notfound" + } else { + testCmd = "echo 'hello' | grep 'notfound'" + } + _, err := client.ExecuteCommand(ctx, testCmd) + assert.NoError(t, err) + }) +} + +func TestSSHClient_ContextCancellation(t *testing.T) { + server, serverAddr, _ := setupTestSSHServerAndClient(t) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + t.Run("connection with short timeout", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + + currentUser := testutil.GetTestUsername(t) + _, err := Dial(ctx, serverAddr, currentUser, DialOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + // Check for actual timeout-related errors rather than string matching + assert.True(t, + errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled) || + strings.Contains(err.Error(), "timeout"), + "Expected timeout-related error, got: %v", err) + } + }) + + t.Run("command execution cancellation", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + currentUser := testutil.GetTestUsername(t) + client, err := Dial(ctx, serverAddr, currentUser, DialOptions{ + InsecureSkipVerify: true, + }) + require.NoError(t, err) + defer func() { + if err := client.Close(); err != nil { + t.Logf("client close error: %v", err) + } + }() + + cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cmdCancel() + + err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10") + if err != nil { + var exitMissingErr *cryptossh.ExitMissingError + isValidCancellation := errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled) || + errors.As(err, &exitMissingErr) + assert.True(t, isValidCancellation, "Should handle command cancellation properly") + } + }) +} diff --git a/client/ssh/client/client_test.go b/client/ssh/client/client_test.go index e38e02a86..191362940 100644 --- a/client/ssh/client/client_test.go +++ b/client/ssh/client/client_test.go @@ -15,7 +15,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - cryptossh "golang.org/x/crypto/ssh" "github.com/netbirdio/netbird/client/ssh" sshserver "github.com/netbirdio/netbird/client/ssh/server" @@ -78,53 +77,6 @@ func TestSSHClient_DialWithKey(t *testing.T) { assert.NotNil(t, client.client) } -func TestSSHClient_CommandExecution(t *testing.T) { - if runtime.GOOS == "windows" && testutil.IsCI() { - t.Skip("Skipping Windows command execution tests in CI due to S4U authentication issues") - } - - server, _, client := setupTestSSHServerAndClient(t) - defer func() { - err := server.Stop() - require.NoError(t, err) - }() - defer func() { - err := client.Close() - assert.NoError(t, err) - }() - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - t.Run("ExecuteCommand captures output", func(t *testing.T) { - output, err := client.ExecuteCommand(ctx, "echo hello") - assert.NoError(t, err) - assert.Contains(t, string(output), "hello") - }) - - t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) { - err := client.ExecuteCommandWithIO(ctx, "echo world") - assert.NoError(t, err) - }) - - t.Run("commands with flags work", func(t *testing.T) { - output, err := client.ExecuteCommand(ctx, "echo -n test_flag") - assert.NoError(t, err) - assert.Equal(t, "test_flag", strings.TrimSpace(string(output))) - }) - - t.Run("non-zero exit codes don't return errors", func(t *testing.T) { - var testCmd string - if runtime.GOOS == "windows" { - testCmd = "echo hello | Select-String notfound" - } else { - testCmd = "echo 'hello' | grep 'notfound'" - } - _, err := client.ExecuteCommand(ctx, testCmd) - assert.NoError(t, err) - }) -} - func TestSSHClient_ConnectionHandling(t *testing.T) { server, serverAddr, _ := setupTestSSHServerAndClient(t) defer func() { @@ -154,59 +106,6 @@ func TestSSHClient_ConnectionHandling(t *testing.T) { } } -func TestSSHClient_ContextCancellation(t *testing.T) { - server, serverAddr, _ := setupTestSSHServerAndClient(t) - defer func() { - err := server.Stop() - require.NoError(t, err) - }() - - t.Run("connection with short timeout", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) - defer cancel() - - currentUser := testutil.GetTestUsername(t) - _, err := Dial(ctx, serverAddr, currentUser, DialOptions{ - InsecureSkipVerify: true, - }) - if err != nil { - // Check for actual timeout-related errors rather than string matching - assert.True(t, - errors.Is(err, context.DeadlineExceeded) || - errors.Is(err, context.Canceled) || - strings.Contains(err.Error(), "timeout"), - "Expected timeout-related error, got: %v", err) - } - }) - - t.Run("command execution cancellation", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - currentUser := testutil.GetTestUsername(t) - client, err := Dial(ctx, serverAddr, currentUser, DialOptions{ - InsecureSkipVerify: true, - }) - require.NoError(t, err) - defer func() { - if err := client.Close(); err != nil { - t.Logf("client close error: %v", err) - } - }() - - cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cmdCancel() - - err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10") - if err != nil { - var exitMissingErr *cryptossh.ExitMissingError - isValidCancellation := errors.Is(err, context.DeadlineExceeded) || - errors.Is(err, context.Canceled) || - errors.As(err, &exitMissingErr) - assert.True(t, isValidCancellation, "Should handle command cancellation properly") - } - }) -} - func TestSSHClient_NoAuthMode(t *testing.T) { hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519) require.NoError(t, err) diff --git a/client/ssh/client/terminal_unix.go b/client/ssh/client/terminal_unix.go index aaa3418f9..a963dc8be 100644 --- a/client/ssh/client/terminal_unix.go +++ b/client/ssh/client/terminal_unix.go @@ -12,6 +12,8 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" "golang.org/x/term" + + nbssh "github.com/netbirdio/netbird/client/ssh" ) func (c *Client) setupTerminalMode(ctx context.Context, session *ssh.Session) error { @@ -82,37 +84,7 @@ func (c *Client) setupTerminal(session *ssh.Session, fd int) error { return fmt.Errorf("get terminal size: %w", err) } - modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - // Ctrl+C - ssh.VINTR: 3, - // Ctrl+\ - ssh.VQUIT: 28, - // Backspace - ssh.VERASE: 127, - // Ctrl+U - ssh.VKILL: 21, - // Ctrl+D - ssh.VEOF: 4, - ssh.VEOL: 0, - ssh.VEOL2: 0, - // Ctrl+Q - ssh.VSTART: 17, - // Ctrl+S - ssh.VSTOP: 19, - // Ctrl+Z - ssh.VSUSP: 26, - // Ctrl+O - ssh.VDISCARD: 15, - // Ctrl+R - ssh.VREPRINT: 18, - // Ctrl+W - ssh.VWERASE: 23, - // Ctrl+V - ssh.VLNEXT: 22, - } + modes := nbssh.DefaultTerminalModes terminal := os.Getenv("TERM") if terminal == "" { diff --git a/client/ssh/client/terminal_windows.go b/client/ssh/client/terminal_windows.go index 462438317..c6156fc26 100644 --- a/client/ssh/client/terminal_windows.go +++ b/client/ssh/client/terminal_windows.go @@ -10,6 +10,8 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" + + nbssh "github.com/netbirdio/netbird/client/ssh" ) const ( @@ -80,28 +82,14 @@ func (c *Client) setupTerminalMode(_ context.Context, session *ssh.Session) erro w, h := c.getWindowsConsoleSize() modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - ssh.ICRNL: 1, - ssh.OPOST: 1, - ssh.ONLCR: 1, - ssh.ISIG: 1, - ssh.ICANON: 1, - ssh.VINTR: 3, // Ctrl+C - ssh.VQUIT: 28, // Ctrl+\ - ssh.VERASE: 127, // Backspace - ssh.VKILL: 21, // Ctrl+U - ssh.VEOF: 4, // Ctrl+D - ssh.VEOL: 0, - ssh.VEOL2: 0, - ssh.VSTART: 17, // Ctrl+Q - ssh.VSTOP: 19, // Ctrl+S - ssh.VSUSP: 26, // Ctrl+Z - ssh.VDISCARD: 15, // Ctrl+O - ssh.VWERASE: 23, // Ctrl+W - ssh.VLNEXT: 22, // Ctrl+V - ssh.VREPRINT: 18, // Ctrl+R + ssh.ICRNL: 1, + ssh.OPOST: 1, + ssh.ONLCR: 1, + ssh.ISIG: 1, + ssh.ICANON: 1, + } + for mode, value := range nbssh.DefaultTerminalModes { + modes[mode] = value } if err := session.RequestPty("xterm-256color", h, w, modes); err != nil { diff --git a/client/ssh/common.go b/client/ssh/common.go index 92e647b7d..4ebf8842a 100644 --- a/client/ssh/common.go +++ b/client/ssh/common.go @@ -13,6 +13,7 @@ import ( "golang.org/x/crypto/ssh" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" ) const ( @@ -34,6 +35,19 @@ type HostKeyVerifier interface { VerifySSHHostKey(peerAddress string, key []byte) error } +// PeerKeyLookup returns the stored SSH host key for a peer address. +type PeerKeyLookup func(peerAddress string) ([]byte, bool) + +// VerifySSHHostKey implements HostKeyVerifier by looking up the stored key +// and comparing it against the presented key. +func (l PeerKeyLookup) VerifySSHHostKey(peerAddress string, presentedKey []byte) error { + storedKey, found := l(peerAddress) + if !found { + return ErrPeerNotFound + } + return VerifyHostKey(storedKey, presentedKey, peerAddress) +} + // DaemonHostKeyVerifier implements HostKeyVerifier using the NetBird daemon type DaemonHostKeyVerifier struct { client proto.DaemonServiceClient @@ -92,7 +106,8 @@ func printAuthInstructions(stderr io.Writer, authResponse *proto.RequestJWTAuthR // RequestJWTToken requests or retrieves a JWT token for SSH authentication func RequestJWTToken(ctx context.Context, client proto.DaemonServiceClient, stdout, stderr io.Writer, useCache bool, hint string, openBrowser func(string) error) (string, error) { - req := &proto.RequestJWTAuthRequest{} + // the ssh client runs in the user's session, the daemon does not: tell it what we can see + req := &proto.RequestJWTAuthRequest{HasGraphicalSession: util.HasGraphicalSession()} if hint != "" { req.Hint = &hint } @@ -193,4 +208,3 @@ func buildAddressList(hostname string, remote net.Addr) []string { } return addresses } - diff --git a/client/ssh/config/manager.go b/client/ssh/config/manager.go index 20695cb4d..e15330739 100644 --- a/client/ssh/config/manager.go +++ b/client/ssh/config/manager.go @@ -14,6 +14,7 @@ import ( log "github.com/sirupsen/logrus" nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/shared/management/domain" ) const ( @@ -218,11 +219,20 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string { if peer.IPv6.IsValid() { hostPatterns = append(hostPatterns, peer.IPv6.String()) } - if peer.FQDN != "" { + // Peer FQDNs and hostnames originate from remote peers, so they must be + // validated as plain DNS names before being embedded in the ssh_config + // "Match host" pattern list. This prevents injection of arbitrary + // ssh_config directives via embedded quotes, whitespace, newlines, the + // comma pattern separator, or the "*"/"?" pattern metacharacters. + if domain.IsValidDomainNoWildcard(peer.FQDN) { hostPatterns = append(hostPatterns, peer.FQDN) + } else if peer.FQDN != "" { + log.Warnf("skipping peer FQDN with invalid characters in SSH config: %q", peer.FQDN) } - if peer.Hostname != "" && peer.Hostname != peer.FQDN { + if peer.Hostname != peer.FQDN && domain.IsValidDomainNoWildcard(peer.Hostname) { hostPatterns = append(hostPatterns, peer.Hostname) + } else if peer.Hostname != "" && peer.Hostname != peer.FQDN { + log.Warnf("skipping peer hostname with invalid characters in SSH config: %q", peer.Hostname) } return hostPatterns } diff --git a/client/ssh/config/manager_test.go b/client/ssh/config/manager_test.go index 8e6be40a3..f65d0ba6d 100644 --- a/client/ssh/config/manager_test.go +++ b/client/ssh/config/manager_test.go @@ -148,6 +148,45 @@ func TestManager_MatchHostFormat(t *testing.T) { "should use Match host with comma-separated patterns") } +func TestManager_HostPatternInjection(t *testing.T) { + tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test") + require.NoError(t, err) + defer func() { assert.NoError(t, os.RemoveAll(tempDir)) }() + + manager := &Manager{ + sshConfigDir: filepath.Join(tempDir, "ssh_config.d"), + sshConfigFile: "99-netbird.conf", + } + + // A malicious peer FQDN/hostname attempts to break out of the Match host + // directive and inject arbitrary ssh_config (a ProxyCommand executing a + // command). It must be rejected, not written to the config. + peers := []PeerSSHInfo{ + { + Hostname: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x", + IP: netip.MustParseAddr("100.125.1.1"), + FQDN: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x.nb.internal", + }, + {Hostname: "peer2", IP: netip.MustParseAddr("100.125.1.2"), FQDN: "peer2.nb.internal"}, + } + + err = manager.SetupSSHClientConfig(peers) + require.NoError(t, err) + + configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile) + content, err := os.ReadFile(configPath) + require.NoError(t, err) + configStr := string(content) + + assert.NotContains(t, configStr, "ProxyCommand touch /tmp/pwned", + "injected directive must not appear in generated config") + assert.NotContains(t, configStr, "evil", + "malicious pattern must be dropped entirely") + // The valid peer must still be present, on a single Match host line. + assert.Contains(t, configStr, "Match host \"100.125.1.1,100.125.1.2,peer2.nb.internal,peer2\"", + "valid peers must survive, injected patterns dropped") +} + func TestManager_ForcedSSHConfig(t *testing.T) { // Set force environment variable t.Setenv(EnvForceSSHConfig, "true") diff --git a/client/ssh/handshake.go b/client/ssh/handshake.go new file mode 100644 index 000000000..e78a806be --- /dev/null +++ b/client/ssh/handshake.go @@ -0,0 +1,45 @@ +package ssh + +import ( + "context" + "fmt" + "io" + "net" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// Handshake runs the SSH client handshake on an already dialed conn and +// returns the resulting client. Dialing bounds only the TCP establishment; +// without a deadline on the socket a peer that accepts and then goes silent +// blocks the handshake forever, so the context deadline is applied to conn +// for the duration of the handshake. conn is closed on any error. +func Handshake(ctx context.Context, conn net.Conn, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { + if deadline, ok := ctx.Deadline(); ok { + if err := conn.SetDeadline(deadline); err != nil { + closeHandshake(conn, "conn after deadline error") + return nil, fmt.Errorf("set handshake deadline: %w", err) + } + } + + sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config) + if err != nil { + closeHandshake(conn, "conn after handshake error") + return nil, fmt.Errorf("ssh handshake: %w", err) + } + + if err := conn.SetDeadline(time.Time{}); err != nil { + closeHandshake(sshConn, "ssh conn after deadline clear error") + return nil, fmt.Errorf("clear handshake deadline: %w", err) + } + + return ssh.NewClient(sshConn, chans, reqs), nil +} + +func closeHandshake(c io.Closer, label string) { + if err := c.Close(); err != nil { + log.Debugf("ssh: close %s: %v", label, err) + } +} diff --git a/client/ssh/proxy/proxy.go b/client/ssh/proxy/proxy.go index 73b50122c..070515b57 100644 --- a/client/ssh/proxy/proxy.go +++ b/client/ssh/proxy/proxy.go @@ -9,7 +9,6 @@ import ( "net" "os" "strconv" - "strings" "sync" "time" @@ -17,8 +16,8 @@ import ( log "github.com/sirupsen/logrus" cryptossh "golang.org/x/crypto/ssh" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" + "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" nbssh "github.com/netbirdio/netbird/client/ssh" @@ -55,8 +54,8 @@ type SSHProxy struct { } func New(daemonAddr, targetHost string, targetPort int, stderr io.Writer, browserOpener func(string) error) (*SSHProxy, error) { - grpcAddr := strings.TrimPrefix(daemonAddr, "tcp://") - grpcConn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + target, opts := daemonaddr.DialTarget(daemonAddr) + grpcConn, err := grpc.NewClient(target, opts...) if err != nil { return nil, fmt.Errorf("connect to daemon: %w", err) } @@ -611,13 +610,10 @@ func (p *SSHProxy) dialBackend(ctx context.Context, addr, user, jwtToken string) return nil, fmt.Errorf("connect to server: %w", err) } - clientConn, chans, reqs, err := cryptossh.NewClientConn(conn, addr, config) - if err != nil { - _ = conn.Close() - return nil, fmt.Errorf("SSH handshake: %w", err) - } + handshakeCtx, cancel := context.WithTimeout(ctx, sshHandshakeTimeout) + defer cancel() - return cryptossh.NewClient(clientConn, chans, reqs), nil + return nbssh.Handshake(handshakeCtx, conn, addr, config) } func (p *SSHProxy) verifyHostKey(hostname string, remote net.Addr, key cryptossh.PublicKey) error { diff --git a/client/ssh/proxy/proxy_privileged_test.go b/client/ssh/proxy/proxy_privileged_test.go new file mode 100644 index 000000000..94495a3ae --- /dev/null +++ b/client/ssh/proxy/proxy_privileged_test.go @@ -0,0 +1,423 @@ +//go:build privileged + +package proxy + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "runtime" + "strconv" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cryptossh "golang.org/x/crypto/ssh" + + nbssh "github.com/netbirdio/netbird/client/ssh" + sshauth "github.com/netbirdio/netbird/client/ssh/auth" + "github.com/netbirdio/netbird/client/ssh/server" + "github.com/netbirdio/netbird/client/ssh/testutil" + nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" + sshuserhash "github.com/netbirdio/netbird/shared/sshauth" +) + +func (m *mockDaemon) setJWTToken(token string) { + m.impl.jwtToken = token +} + +func TestSSHProxy_Connect(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // TODO: Windows test times out - user switching and command execution tested on Linux + if runtime.GOOS == "windows" { + t.Skip("Skipping on Windows - covered by Linux tests") + } + + const ( + issuer = "https://test-issuer.example.com" + audience = "test-audience" + ) + + jwksServer, privateKey, jwksURL := setupJWKSServer(t) + defer jwksServer.Close() + + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + hostPubKey, err := nbssh.GeneratePublicKey(hostKey) + require.NoError(t, err) + + serverConfig := &server.Config{ + HostKeyPEM: hostKey, + JWT: &server.JWTConfig{ + Issuer: issuer, + Audiences: []string{audience}, + KeysLocation: jwksURL, + }, + } + sshServer := server.New(serverConfig) + sshServer.SetAllowRootLogin(true) + + // Configure SSH authorization for the test user + testUsername := testutil.GetTestUsername(t) + testJWTUser := "test-username" + testUserHash, err := sshuserhash.HashUserID(testJWTUser) + require.NoError(t, err) + + authConfig := &sshauth.Config{ + UserIDClaim: sshauth.DefaultUserIDClaim, + AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash}, + MachineUsers: map[string][]uint32{ + testUsername: {0}, // Index 0 in AuthorizedUsers + }, + } + sshServer.UpdateSSHAuth(authConfig) + + sshServerAddr := server.StartTestServer(t, sshServer) + defer func() { _ = sshServer.Stop() }() + + mockDaemon := startMockDaemon(t) + defer mockDaemon.stop() + + host, portStr, err := net.SplitHostPort(sshServerAddr) + require.NoError(t, err) + port, err := strconv.Atoi(portStr) + require.NoError(t, err) + + mockDaemon.setHostKey(host, hostPubKey) + + validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser) + mockDaemon.setJWTToken(validToken) + + proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil) + require.NoError(t, err) + + clientConn, proxyConn := net.Pipe() + defer func() { _ = clientConn.Close() }() + + origStdin := os.Stdin + origStdout := os.Stdout + defer func() { + os.Stdin = origStdin + os.Stdout = origStdout + }() + + stdinReader, stdinWriter, err := os.Pipe() + require.NoError(t, err) + stdoutReader, stdoutWriter, err := os.Pipe() + require.NoError(t, err) + + os.Stdin = stdinReader + os.Stdout = stdoutWriter + + go func() { + _, _ = io.Copy(stdinWriter, proxyConn) + }() + go func() { + _, _ = io.Copy(proxyConn, stdoutReader) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + connectErrCh := make(chan error, 1) + go func() { + connectErrCh <- proxyInstance.Connect(ctx) + }() + + sshConfig := &cryptossh.ClientConfig{ + User: testutil.GetTestUsername(t), + Auth: []cryptossh.AuthMethod{}, + HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), + Timeout: 3 * time.Second, + } + + sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig) + require.NoError(t, err, "Should connect to proxy server") + defer func() { _ = sshClientConn.Close() }() + + sshClient := cryptossh.NewClient(sshClientConn, chans, reqs) + + session, err := sshClient.NewSession() + require.NoError(t, err, "Should create session through full proxy to backend") + + outputCh := make(chan []byte, 1) + errCh := make(chan error, 1) + go func() { + output, err := session.Output("echo hello-from-proxy") + outputCh <- output + errCh <- err + }() + + select { + case output := <-outputCh: + err := <-errCh + require.NoError(t, err, "Command should execute successfully through proxy") + assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy") + case <-time.After(3 * time.Second): + t.Fatal("Command execution timed out") + } + + _ = session.Close() + _ = sshClient.Close() + _ = clientConn.Close() + cancel() +} + +// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting +// when forwarding commands to the backend. This is critical for tools like +// Ansible that send commands such as: +// +// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0' +// +// The single quotes must be preserved so the backend shell receives the +// subshell expression as a single argument to -c. +func TestSSHProxy_CommandQuoting(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + sshClient, cleanup := setupProxySSHClient(t) + defer cleanup() + + // These commands simulate what the SSH protocol delivers as exec payloads. + // When a user types: ssh host '/bin/sh -c "( echo hello )"' + // the local shell strips the outer single quotes, and the SSH exec request + // contains the raw string: /bin/sh -c "( echo hello )" + // + // The proxy must forward this string verbatim. Using session.Command() + // (shlex.Split + strings.Join) strips the inner double quotes, breaking + // the command on the backend. + tests := []struct { + name string + command string + expect string + }{ + { + name: "subshell_in_double_quotes", + command: `/bin/sh -c "( echo from-subshell ) && echo outer"`, + expect: "from-subshell\nouter\n", + }, + { + name: "printf_with_special_chars", + command: `/bin/sh -c "printf '%s\n' 'hello world'"`, + expect: "hello world\n", + }, + { + name: "nested_command_substitution", + command: `/bin/sh -c "echo $(echo nested)"`, + expect: "nested\n", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + session, err := sshClient.NewSession() + require.NoError(t, err) + defer func() { _ = session.Close() }() + + var stderrBuf bytes.Buffer + session.Stderr = &stderrBuf + + outputCh := make(chan []byte, 1) + errCh := make(chan error, 1) + go func() { + output, err := session.Output(tc.command) + outputCh <- output + errCh <- err + }() + + select { + case output := <-outputCh: + err := <-errCh + if stderrBuf.Len() > 0 { + t.Logf("stderr: %s", stderrBuf.String()) + } + require.NoError(t, err, "command should succeed: %s", tc.command) + assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command) + case <-time.After(5 * time.Second): + t.Fatalf("command timed out: %s", tc.command) + } + }) + } +} + +// setupProxySSHClient creates a full proxy test environment and returns +// an SSH client connected through the proxy to a backend NetBird SSH server. +func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) { + t.Helper() + + const ( + issuer = "https://test-issuer.example.com" + audience = "test-audience" + ) + + jwksServer, privateKey, jwksURL := setupJWKSServer(t) + + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + hostPubKey, err := nbssh.GeneratePublicKey(hostKey) + require.NoError(t, err) + + serverConfig := &server.Config{ + HostKeyPEM: hostKey, + JWT: &server.JWTConfig{ + Issuer: issuer, + Audiences: []string{audience}, + KeysLocation: jwksURL, + }, + } + sshServer := server.New(serverConfig) + sshServer.SetAllowRootLogin(true) + + testUsername := testutil.GetTestUsername(t) + testJWTUser := "test-username" + testUserHash, err := sshuserhash.HashUserID(testJWTUser) + require.NoError(t, err) + + authConfig := &sshauth.Config{ + UserIDClaim: sshauth.DefaultUserIDClaim, + AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash}, + MachineUsers: map[string][]uint32{ + testUsername: {0}, + }, + } + sshServer.UpdateSSHAuth(authConfig) + + sshServerAddr := server.StartTestServer(t, sshServer) + + mockDaemon := startMockDaemon(t) + + host, portStr, err := net.SplitHostPort(sshServerAddr) + require.NoError(t, err) + port, err := strconv.Atoi(portStr) + require.NoError(t, err) + + mockDaemon.setHostKey(host, hostPubKey) + + validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser) + mockDaemon.setJWTToken(validToken) + + proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil) + require.NoError(t, err) + + origStdin := os.Stdin + origStdout := os.Stdout + + stdinReader, stdinWriter, err := os.Pipe() + require.NoError(t, err) + stdoutReader, stdoutWriter, err := os.Pipe() + require.NoError(t, err) + + os.Stdin = stdinReader + os.Stdout = stdoutWriter + + clientConn, proxyConn := net.Pipe() + + go func() { _, _ = io.Copy(stdinWriter, proxyConn) }() + go func() { _, _ = io.Copy(proxyConn, stdoutReader) }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + + go func() { + _ = proxyInstance.Connect(ctx) + }() + + sshConfig := &cryptossh.ClientConfig{ + User: testutil.GetTestUsername(t), + Auth: []cryptossh.AuthMethod{}, + HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + } + + sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig) + require.NoError(t, err) + + client := cryptossh.NewClient(sshClientConn, chans, reqs) + + cleanupFn := func() { + _ = client.Close() + _ = clientConn.Close() + cancel() + os.Stdin = origStdin + os.Stdout = origStdout + _ = sshServer.Stop() + mockDaemon.stop() + jwksServer.Close() + } + + return client, cleanupFn +} + +func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) { + t.Helper() + privateKey, jwksJSON := generateTestJWKS(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(jwksJSON); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + })) + + return server, privateKey, server.URL +} + +func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) { + t.Helper() + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + publicKey := &privateKey.PublicKey + n := publicKey.N.Bytes() + e := publicKey.E + + jwk := nbjwt.JSONWebKey{ + Kty: "RSA", + Kid: "test-key-id", + Use: "sig", + N: base64.RawURLEncoding.EncodeToString(n), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()), + } + + jwks := nbjwt.Jwks{ + Keys: []nbjwt.JSONWebKey{jwk}, + } + + jwksJSON, err := json.Marshal(jwks) + require.NoError(t, err) + + return privateKey, jwksJSON +} + +func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string { + t.Helper() + claims := jwt.MapClaims{ + "iss": issuer, + "aud": audience, + "sub": user, + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = "test-key-id" + + tokenString, err := token.SignedString(privateKey) + require.NoError(t, err) + + return tokenString +} diff --git a/client/ssh/proxy/proxy_test.go b/client/ssh/proxy/proxy_test.go index b33d5f8f4..2795c786b 100644 --- a/client/ssh/proxy/proxy_test.go +++ b/client/ssh/proxy/proxy_test.go @@ -1,25 +1,12 @@ package proxy import ( - "bytes" "context" - "crypto/rand" - "crypto/rsa" - "encoding/base64" - "encoding/json" "fmt" - "io" - "math/big" "net" - "net/http" - "net/http/httptest" "os" - "runtime" - "strconv" "testing" - "time" - "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" cryptossh "golang.org/x/crypto/ssh" @@ -28,11 +15,7 @@ import ( "github.com/netbirdio/netbird/client/proto" nbssh "github.com/netbirdio/netbird/client/ssh" - sshauth "github.com/netbirdio/netbird/client/ssh/auth" - "github.com/netbirdio/netbird/client/ssh/server" "github.com/netbirdio/netbird/client/ssh/testutil" - nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" - sshuserhash "github.com/netbirdio/netbird/shared/sshauth" ) func TestMain(m *testing.M) { @@ -106,331 +89,6 @@ func TestSSHProxy_verifyHostKey(t *testing.T) { }) } -func TestSSHProxy_Connect(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") - } - - // TODO: Windows test times out - user switching and command execution tested on Linux - if runtime.GOOS == "windows" { - t.Skip("Skipping on Windows - covered by Linux tests") - } - - const ( - issuer = "https://test-issuer.example.com" - audience = "test-audience" - ) - - jwksServer, privateKey, jwksURL := setupJWKSServer(t) - defer jwksServer.Close() - - hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) - require.NoError(t, err) - hostPubKey, err := nbssh.GeneratePublicKey(hostKey) - require.NoError(t, err) - - serverConfig := &server.Config{ - HostKeyPEM: hostKey, - JWT: &server.JWTConfig{ - Issuer: issuer, - Audiences: []string{audience}, - KeysLocation: jwksURL, - }, - } - sshServer := server.New(serverConfig) - sshServer.SetAllowRootLogin(true) - - // Configure SSH authorization for the test user - testUsername := testutil.GetTestUsername(t) - testJWTUser := "test-username" - testUserHash, err := sshuserhash.HashUserID(testJWTUser) - require.NoError(t, err) - - authConfig := &sshauth.Config{ - UserIDClaim: sshauth.DefaultUserIDClaim, - AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash}, - MachineUsers: map[string][]uint32{ - testUsername: {0}, // Index 0 in AuthorizedUsers - }, - } - sshServer.UpdateSSHAuth(authConfig) - - sshServerAddr := server.StartTestServer(t, sshServer) - defer func() { _ = sshServer.Stop() }() - - mockDaemon := startMockDaemon(t) - defer mockDaemon.stop() - - host, portStr, err := net.SplitHostPort(sshServerAddr) - require.NoError(t, err) - port, err := strconv.Atoi(portStr) - require.NoError(t, err) - - mockDaemon.setHostKey(host, hostPubKey) - - validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser) - mockDaemon.setJWTToken(validToken) - - proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil) - require.NoError(t, err) - - clientConn, proxyConn := net.Pipe() - defer func() { _ = clientConn.Close() }() - - origStdin := os.Stdin - origStdout := os.Stdout - defer func() { - os.Stdin = origStdin - os.Stdout = origStdout - }() - - stdinReader, stdinWriter, err := os.Pipe() - require.NoError(t, err) - stdoutReader, stdoutWriter, err := os.Pipe() - require.NoError(t, err) - - os.Stdin = stdinReader - os.Stdout = stdoutWriter - - go func() { - _, _ = io.Copy(stdinWriter, proxyConn) - }() - go func() { - _, _ = io.Copy(proxyConn, stdoutReader) - }() - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - connectErrCh := make(chan error, 1) - go func() { - connectErrCh <- proxyInstance.Connect(ctx) - }() - - sshConfig := &cryptossh.ClientConfig{ - User: testutil.GetTestUsername(t), - Auth: []cryptossh.AuthMethod{}, - HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), - Timeout: 3 * time.Second, - } - - sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig) - require.NoError(t, err, "Should connect to proxy server") - defer func() { _ = sshClientConn.Close() }() - - sshClient := cryptossh.NewClient(sshClientConn, chans, reqs) - - session, err := sshClient.NewSession() - require.NoError(t, err, "Should create session through full proxy to backend") - - outputCh := make(chan []byte, 1) - errCh := make(chan error, 1) - go func() { - output, err := session.Output("echo hello-from-proxy") - outputCh <- output - errCh <- err - }() - - select { - case output := <-outputCh: - err := <-errCh - require.NoError(t, err, "Command should execute successfully through proxy") - assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy") - case <-time.After(3 * time.Second): - t.Fatal("Command execution timed out") - } - - _ = session.Close() - _ = sshClient.Close() - _ = clientConn.Close() - cancel() -} - -// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting -// when forwarding commands to the backend. This is critical for tools like -// Ansible that send commands such as: -// -// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0' -// -// The single quotes must be preserved so the backend shell receives the -// subshell expression as a single argument to -c. -func TestSSHProxy_CommandQuoting(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test in short mode") - } - - sshClient, cleanup := setupProxySSHClient(t) - defer cleanup() - - // These commands simulate what the SSH protocol delivers as exec payloads. - // When a user types: ssh host '/bin/sh -c "( echo hello )"' - // the local shell strips the outer single quotes, and the SSH exec request - // contains the raw string: /bin/sh -c "( echo hello )" - // - // The proxy must forward this string verbatim. Using session.Command() - // (shlex.Split + strings.Join) strips the inner double quotes, breaking - // the command on the backend. - tests := []struct { - name string - command string - expect string - }{ - { - name: "subshell_in_double_quotes", - command: `/bin/sh -c "( echo from-subshell ) && echo outer"`, - expect: "from-subshell\nouter\n", - }, - { - name: "printf_with_special_chars", - command: `/bin/sh -c "printf '%s\n' 'hello world'"`, - expect: "hello world\n", - }, - { - name: "nested_command_substitution", - command: `/bin/sh -c "echo $(echo nested)"`, - expect: "nested\n", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - session, err := sshClient.NewSession() - require.NoError(t, err) - defer func() { _ = session.Close() }() - - var stderrBuf bytes.Buffer - session.Stderr = &stderrBuf - - outputCh := make(chan []byte, 1) - errCh := make(chan error, 1) - go func() { - output, err := session.Output(tc.command) - outputCh <- output - errCh <- err - }() - - select { - case output := <-outputCh: - err := <-errCh - if stderrBuf.Len() > 0 { - t.Logf("stderr: %s", stderrBuf.String()) - } - require.NoError(t, err, "command should succeed: %s", tc.command) - assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command) - case <-time.After(5 * time.Second): - t.Fatalf("command timed out: %s", tc.command) - } - }) - } -} - -// setupProxySSHClient creates a full proxy test environment and returns -// an SSH client connected through the proxy to a backend NetBird SSH server. -func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) { - t.Helper() - - const ( - issuer = "https://test-issuer.example.com" - audience = "test-audience" - ) - - jwksServer, privateKey, jwksURL := setupJWKSServer(t) - - hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) - require.NoError(t, err) - hostPubKey, err := nbssh.GeneratePublicKey(hostKey) - require.NoError(t, err) - - serverConfig := &server.Config{ - HostKeyPEM: hostKey, - JWT: &server.JWTConfig{ - Issuer: issuer, - Audiences: []string{audience}, - KeysLocation: jwksURL, - }, - } - sshServer := server.New(serverConfig) - sshServer.SetAllowRootLogin(true) - - testUsername := testutil.GetTestUsername(t) - testJWTUser := "test-username" - testUserHash, err := sshuserhash.HashUserID(testJWTUser) - require.NoError(t, err) - - authConfig := &sshauth.Config{ - UserIDClaim: sshauth.DefaultUserIDClaim, - AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash}, - MachineUsers: map[string][]uint32{ - testUsername: {0}, - }, - } - sshServer.UpdateSSHAuth(authConfig) - - sshServerAddr := server.StartTestServer(t, sshServer) - - mockDaemon := startMockDaemon(t) - - host, portStr, err := net.SplitHostPort(sshServerAddr) - require.NoError(t, err) - port, err := strconv.Atoi(portStr) - require.NoError(t, err) - - mockDaemon.setHostKey(host, hostPubKey) - - validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser) - mockDaemon.setJWTToken(validToken) - - proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil) - require.NoError(t, err) - - origStdin := os.Stdin - origStdout := os.Stdout - - stdinReader, stdinWriter, err := os.Pipe() - require.NoError(t, err) - stdoutReader, stdoutWriter, err := os.Pipe() - require.NoError(t, err) - - os.Stdin = stdinReader - os.Stdout = stdoutWriter - - clientConn, proxyConn := net.Pipe() - - go func() { _, _ = io.Copy(stdinWriter, proxyConn) }() - go func() { _, _ = io.Copy(proxyConn, stdoutReader) }() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - - go func() { - _ = proxyInstance.Connect(ctx) - }() - - sshConfig := &cryptossh.ClientConfig{ - User: testutil.GetTestUsername(t), - Auth: []cryptossh.AuthMethod{}, - HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), - Timeout: 5 * time.Second, - } - - sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig) - require.NoError(t, err) - - client := cryptossh.NewClient(sshClientConn, chans, reqs) - - cleanupFn := func() { - _ = client.Close() - _ = clientConn.Close() - cancel() - os.Stdin = origStdin - os.Stdout = origStdout - _ = sshServer.Stop() - mockDaemon.stop() - jwksServer.Close() - } - - return client, cleanupFn -} - type mockDaemonServer struct { proto.UnimplementedDaemonServiceServer hostKeys map[string][]byte @@ -492,10 +150,6 @@ func (m *mockDaemon) setHostKey(addr string, pubKey []byte) { m.impl.hostKeys[addr] = pubKey } -func (m *mockDaemon) setJWTToken(token string) { - m.impl.jwtToken = token -} - func (m *mockDaemon) stop() { if m.server != nil { m.server.Stop() @@ -508,63 +162,3 @@ func mustParsePublicKey(t *testing.T, pubKeyBytes []byte) cryptossh.PublicKey { require.NoError(t, err) return pubKey } - -func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) { - t.Helper() - privateKey, jwksJSON := generateTestJWKS(t) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if _, err := w.Write(jwksJSON); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - })) - - return server, privateKey, server.URL -} - -func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) { - t.Helper() - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) - - publicKey := &privateKey.PublicKey - n := publicKey.N.Bytes() - e := publicKey.E - - jwk := nbjwt.JSONWebKey{ - Kty: "RSA", - Kid: "test-key-id", - Use: "sig", - N: base64.RawURLEncoding.EncodeToString(n), - E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()), - } - - jwks := nbjwt.Jwks{ - Keys: []nbjwt.JSONWebKey{jwk}, - } - - jwksJSON, err := json.Marshal(jwks) - require.NoError(t, err) - - return privateKey, jwksJSON -} - -func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string { - t.Helper() - claims := jwt.MapClaims{ - "iss": issuer, - "aud": audience, - "sub": user, - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Unix(), - } - - token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - token.Header["kid"] = "test-key-id" - - tokenString, err := token.SignedString(privateKey) - require.NoError(t, err) - - return tokenString -} diff --git a/client/ssh/server/command_execution.go b/client/ssh/server/command_execution.go index b0a85fe4b..c8b3240d0 100644 --- a/client/ssh/server/command_execution.go +++ b/client/ssh/server/command_execution.go @@ -75,8 +75,8 @@ func (s *Server) createCommand(logger *log.Entry, privilegeResult PrivilegeCheck } // Try su first for system integration (PAM/audit) when privileged - cmd, err := s.createSuCommand(logger, session, localUser, hasPty) - if err != nil || privilegeResult.UsedFallback { + cmd, err := s.createSuCommand(logger, session, localUser, hasPty) //nolint:staticcheck + if err != nil || privilegeResult.UsedFallback { //nolint:staticcheck // always errors on platforms without su logger.Debugf("su command failed, falling back to executor: %v", err) cmd, cleanup, err := s.createExecutorCommand(logger, session, localUser, hasPty) if err != nil { diff --git a/client/ssh/server/command_execution_windows.go b/client/ssh/server/command_execution_windows.go index e1ba777f6..feb8daa26 100644 --- a/client/ssh/server/command_execution_windows.go +++ b/client/ssh/server/command_execution_windows.go @@ -243,7 +243,7 @@ func (s *Server) setUserEnvironmentVariables(envMap map[string]string, userProfi // prepareCommandEnv prepares environment variables for command execution on Windows func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, session ssh.Session) []string { - username, domain := s.parseUsername(localUser.Username) + username, domain := parseUsername(localUser.Username) userEnv, err := s.getUserEnvironment(logger, username, domain) if err != nil { log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err) @@ -383,7 +383,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _ return false } - username, domain := s.parseUsername(localUser.Username) + username, domain := parseUsername(localUser.Username) shell := getUserShell(localUser.Uid) req := PtyExecutionRequest{ diff --git a/client/ssh/server/executor_unix_privileged_test.go b/client/ssh/server/executor_unix_privileged_test.go new file mode 100644 index 000000000..f1b0805d9 --- /dev/null +++ b/client/ssh/server/executor_unix_privileged_test.go @@ -0,0 +1,66 @@ +//go:build unix && privileged + +package server + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) { + pd := NewPrivilegeDropper() + + config := ExecutorConfig{ + UID: 1000, + GID: 1000, + Groups: []uint32{1000, 1001}, + WorkingDir: "/home/testuser", + Shell: "/bin/bash", + Command: "ls -la", + } + + cmd, err := pd.CreateExecutorCommand(context.Background(), config) + require.NoError(t, err) + require.NotNil(t, cmd) + + // Verify the command is calling netbird ssh exec + assert.Contains(t, cmd.Args, "ssh") + assert.Contains(t, cmd.Args, "exec") + assert.Contains(t, cmd.Args, "--uid") + assert.Contains(t, cmd.Args, "1000") + assert.Contains(t, cmd.Args, "--gid") + assert.Contains(t, cmd.Args, "1000") + assert.Contains(t, cmd.Args, "--groups") + assert.Contains(t, cmd.Args, "1000") + assert.Contains(t, cmd.Args, "1001") + assert.Contains(t, cmd.Args, "--working-dir") + assert.Contains(t, cmd.Args, "/home/testuser") + assert.Contains(t, cmd.Args, "--shell") + assert.Contains(t, cmd.Args, "/bin/bash") + assert.Contains(t, cmd.Args, "--cmd") + assert.Contains(t, cmd.Args, "ls -la") +} + +func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) { + pd := NewPrivilegeDropper() + + config := ExecutorConfig{ + UID: 1000, + GID: 1000, + Groups: []uint32{1000}, + WorkingDir: "/home/testuser", + Shell: "/bin/bash", + Command: "", + } + + cmd, err := pd.CreateExecutorCommand(context.Background(), config) + require.NoError(t, err) + require.NotNil(t, cmd) + + // Verify no command mode (command is empty so no --cmd flag) + assert.NotContains(t, cmd.Args, "--cmd") + assert.NotContains(t, cmd.Args, "--interactive") +} diff --git a/client/ssh/server/executor_unix_test.go b/client/ssh/server/executor_unix_test.go index 0c5108f57..171e78b83 100644 --- a/client/ssh/server/executor_unix_test.go +++ b/client/ssh/server/executor_unix_test.go @@ -73,61 +73,6 @@ func TestPrivilegeDropper_ValidatePrivileges(t *testing.T) { } } -func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) { - pd := NewPrivilegeDropper() - - config := ExecutorConfig{ - UID: 1000, - GID: 1000, - Groups: []uint32{1000, 1001}, - WorkingDir: "/home/testuser", - Shell: "/bin/bash", - Command: "ls -la", - } - - cmd, err := pd.CreateExecutorCommand(context.Background(), config) - require.NoError(t, err) - require.NotNil(t, cmd) - - // Verify the command is calling netbird ssh exec - assert.Contains(t, cmd.Args, "ssh") - assert.Contains(t, cmd.Args, "exec") - assert.Contains(t, cmd.Args, "--uid") - assert.Contains(t, cmd.Args, "1000") - assert.Contains(t, cmd.Args, "--gid") - assert.Contains(t, cmd.Args, "1000") - assert.Contains(t, cmd.Args, "--groups") - assert.Contains(t, cmd.Args, "1000") - assert.Contains(t, cmd.Args, "1001") - assert.Contains(t, cmd.Args, "--working-dir") - assert.Contains(t, cmd.Args, "/home/testuser") - assert.Contains(t, cmd.Args, "--shell") - assert.Contains(t, cmd.Args, "/bin/bash") - assert.Contains(t, cmd.Args, "--cmd") - assert.Contains(t, cmd.Args, "ls -la") -} - -func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) { - pd := NewPrivilegeDropper() - - config := ExecutorConfig{ - UID: 1000, - GID: 1000, - Groups: []uint32{1000}, - WorkingDir: "/home/testuser", - Shell: "/bin/bash", - Command: "", - } - - cmd, err := pd.CreateExecutorCommand(context.Background(), config) - require.NoError(t, err) - require.NotNil(t, cmd) - - // Verify no command mode (command is empty so no --cmd flag) - assert.NotContains(t, cmd.Args, "--cmd") - assert.NotContains(t, cmd.Args, "--interactive") -} - // TestPrivilegeDropper_ActualPrivilegeDrop tests actual privilege dropping // This test requires root privileges and will be skipped if not running as root func TestPrivilegeDropper_ActualPrivilegeDrop(t *testing.T) { diff --git a/client/ssh/server/getent_unix.go b/client/ssh/server/getent_unix.go index 18edb2fdf..a3a9641f8 100644 --- a/client/ssh/server/getent_unix.go +++ b/client/ssh/server/getent_unix.go @@ -69,7 +69,8 @@ func parseGetentPasswd(output string) (*user.User, string, error) { // validateGetentInput checks that the input is safe to pass to getent or id. // Allows POSIX usernames, numeric UIDs, and common NSS extensions -// (@ for Kerberos, $ for Samba, + for NIS compat). +// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is +// rejected so the input can never be parsed as a command-line flag. func validateGetentInput(input string) bool { maxLen := 32 if runtime.GOOS == "linux" { @@ -80,6 +81,10 @@ func validateGetentInput(input string) bool { return false } + if input[0] == '-' { + return false + } + for _, r := range input { if isAllowedGetentChar(r) { continue diff --git a/client/ssh/server/getent_unix_test.go b/client/ssh/server/getent_unix_test.go index e44563b79..a73214e17 100644 --- a/client/ssh/server/getent_unix_test.go +++ b/client/ssh/server/getent_unix_test.go @@ -157,6 +157,9 @@ func TestValidateGetentInput(t *testing.T) { {"numeric UID", "1001", true}, {"dots and underscores", "alice.bob_test", true}, {"hyphen", "alice-bob", true}, + {"leading hyphen rejected", "-i", false}, + {"leading double hyphen rejected", "--no-idn", false}, + {"lone hyphen rejected", "-", false}, {"kerberos principal", "user@REALM", true}, {"samba machine account", "MACHINE$", true}, {"NIS compat", "+user", true}, diff --git a/client/ssh/server/port_forwarding.go b/client/ssh/server/port_forwarding.go index a47fdb48a..81d3b9173 100644 --- a/client/ssh/server/port_forwarding.go +++ b/client/ssh/server/port_forwarding.go @@ -133,7 +133,12 @@ func (s *Server) checkPrivilegedPortAccess(forwardType string, port uint32, resu return nil } - if result.User != nil && isPrivilegedUsername(result.User.Username) { + // Only uid 0 may bind below the threshold, which is the kernel's own rule and + // is asked directly rather than through isPrivilegedOrUnknown: that helper + // reports an account it cannot evaluate as privileged, which is safe for a + // refusal and unsafe for a grant such as this one. Windows has returned + // above, so Uid here is a Unix uid and never a SID. + if result.User != nil && result.User.Uid == "0" { return nil } diff --git a/client/ssh/server/privileges_other.go b/client/ssh/server/privileges_other.go new file mode 100644 index 000000000..89440dea8 --- /dev/null +++ b/client/ssh/server/privileges_other.go @@ -0,0 +1,16 @@ +//go:build !windows + +package server + +// isProcessElevated is only meaningful on Windows; other platforms use the +// effective UID check in isCurrentProcessPrivileged. +func isProcessElevated() bool { + return false +} + +// isWindowsAccountPrivilegedOrUnknown is only reachable on Windows. Report +// privileged on other platforms so a caller refusing privileged accounts fails +// closed. +func isWindowsAccountPrivilegedOrUnknown(string) bool { + return true +} diff --git a/client/ssh/server/privileges_windows.go b/client/ssh/server/privileges_windows.go new file mode 100644 index 000000000..41ea00fd7 --- /dev/null +++ b/client/ssh/server/privileges_windows.go @@ -0,0 +1,228 @@ +//go:build windows + +package server + +import ( + "fmt" + "strings" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +var ( + netapi32 = windows.NewLazySystemDLL("netapi32.dll") + procNetUserGetLocalGroups = netapi32.NewProc("NetUserGetLocalGroups") +) + +const ( + // lgIncludeIndirect makes NetUserGetLocalGroups also return local groups + // the user belongs to through a global group. + lgIncludeIndirect = 0x1 + maxPreferredLength = 0xFFFFFFFF +) + +// localGroupUsersInfo0 mirrors LOCALGROUP_USERS_INFO_0. +type localGroupUsersInfo0 struct { + name *uint16 +} + +// isProcessElevated reports whether the current process token is elevated +// (TokenElevation): true for elevated administrators, the built-in +// Administrator, administrators with UAC disabled, and SYSTEM; false for +// standard users and administrators running with a UAC-filtered token. +func isProcessElevated() bool { + return windows.GetCurrentProcessToken().IsElevated() +} + +// isWindowsAccountPrivilegedOrUnknown reports whether the account is privileged +// on this machine: a well-known service account, a built-in Administrator +// (RID 500), or a member of the local Administrators group, directly or through +// nested groups. +// +// An account whose privilege cannot be determined counts as privileged, which +// is why the name says "or unknown". That is fail-closed for a caller that +// refuses privileged accounts, and fail-open for a caller that grants something +// to them, so only the former may use this. +func isWindowsAccountPrivilegedOrUnknown(username string) bool { + sid, _, _, err := windows.LookupSID("", username) + if err != nil { + log.Warnf("privilege check: SID lookup for %q failed, treating as privileged: %v", username, err) + return true + } + + if isPrivilegedUserSID(sid) { + return true + } + + member, err := isLocalAdminsMember(username) + if err != nil { + log.Warnf("privilege check: cannot determine Administrators membership for %q, treating as privileged: %v", username, err) + return true + } + return member +} + +// isPrivilegedUserSID reports whether the SID itself identifies a privileged +// principal, without consulting group membership. +func isPrivilegedUserSID(sid *windows.SID) bool { + wellKnown := []windows.WELL_KNOWN_SID_TYPE{ + windows.WinLocalSystemSid, + windows.WinLocalServiceSid, + windows.WinNetworkServiceSid, + windows.WinBuiltinAdministratorsSid, + } + for _, sidType := range wellKnown { + if sid.IsWellKnown(sidType) { + return true + } + } + return isBuiltinAdministratorSID(sid) +} + +// isBuiltinAdministratorSID reports whether the SID is a machine or domain +// built-in Administrator account (S-1-5-21-...-500). RID 500 is reserved for +// that account; it can be renamed but cannot be removed from the +// Administrators group. +func isBuiltinAdministratorSID(sid *windows.SID) bool { + if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY { + return false + } + count := sid.SubAuthorityCount() + if count < 2 || sid.SubAuthority(0) != 21 { + return false + } + return sid.SubAuthority(uint32(count-1)) == 500 +} + +// isLocalAdminsMember reports whether the account is a member of the local +// Administrators group. +// +// Local accounts are checked against the local SAM, which is authoritative for +// them and, unlike a token, cannot under-report: UAC filters the tokens of +// local administrators, and a filtered token carries Administrators as +// deny-only, which a membership check on the token would read as "not a +// member". Domain accounts are exempt from that filtering, so for them an S4U +// token is preferred because its group list is LSA's transitive expansion and +// therefore covers nested and universal groups plus the machine's own local +// groups. NetUserGetLocalGroups expands only one global-group hop but needs no +// logon, so it serves as the fallback when no token can be obtained. +func isLocalAdminsMember(username string) (bool, error) { + adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return false, fmt.Errorf("create Administrators SID: %w", err) + } + + account, domain := parseUsername(username) + if NewPrivilegeDropper().isLocalUser(domain) { + return localGroupsContainSID(account, adminSid) + } + + member, s4uErr := s4uTokenIsMember(account, domain, adminSid) + if s4uErr == nil { + return member, nil + } + log.Debugf("privilege check: S4U membership check for %q failed, falling back to local group enumeration: %v", username, s4uErr) + + member, err = localGroupsContainSID(buildUserCpn(account, domain), adminSid) + if err != nil { + return false, fmt.Errorf("S4U check: %w; local group enumeration: %w", s4uErr, err) + } + return member, nil +} + +// s4uTokenIsMember obtains an S4U token for the account and checks whether the +// given SID is enabled in it. +func s4uTokenIsMember(account, domain string, sid *windows.SID) (bool, error) { + token, err := generateS4UUserToken(log.NewEntry(log.StandardLogger()), account, domain) + if err != nil { + return false, err + } + defer func() { + if err := windows.CloseHandle(token); err != nil { + log.Debugf("close S4U token: %v", err) + } + }() + return windows.Token(token).IsMember(sid) +} + +// localGroupsContainSID reports whether the wanted group is among the local +// groups the account belongs to, directly or through a global group. +// +// The wanted SID is resolved to its group name once and compared against the +// enumerated names. Well-known SIDs resolve from a static table, so that lookup +// needs no domain controller, and it keeps the comparison correct for a renamed +// or localized group because both sides then carry the new name. Resolving each +// enumerated name back to a SID instead would add a lookup per group that can +// block until it times out while a domain controller is unreachable, and cannot +// change the outcome: the names enumerated here are local groups of this +// machine, whose names are unique, so a name match identifies the group. +// +// A failure to resolve the wanted SID is returned rather than reported as +// "not a member", so a privilege check built on this fails closed. +func localGroupsContainSID(username string, want *windows.SID) (bool, error) { + wantName, _, _, err := want.LookupAccount("") + if err != nil { + return false, fmt.Errorf("resolve group SID %s to a name: %w", want, err) + } + + groups, err := netUserGetLocalGroups(username) + if err != nil { + return false, err + } + + for _, group := range groups { + if strings.EqualFold(group, wantName) { + return true, nil + } + } + return false, nil +} + +// netUserGetLocalGroups returns the names of the local groups the account is a +// member of, including indirect membership through global groups. +func netUserGetLocalGroups(username string) ([]string, error) { + name16, err := windows.UTF16PtrFromString(username) + if err != nil { + return nil, fmt.Errorf("convert username: %w", err) + } + + var buf *byte + var entriesRead, totalEntries uint32 + status, _, _ := procNetUserGetLocalGroups.Call( + 0, // local server + uintptr(unsafe.Pointer(name16)), + 0, // level 0: LOCALGROUP_USERS_INFO_0 + lgIncludeIndirect, + uintptr(unsafe.Pointer(&buf)), + maxPreferredLength, + uintptr(unsafe.Pointer(&entriesRead)), + uintptr(unsafe.Pointer(&totalEntries)), + ) + if status != 0 { + return nil, fmt.Errorf("NetUserGetLocalGroups for %q: status %d", username, status) + } + if buf == nil { + return nil, nil + } + defer func() { + if err := windows.NetApiBufferFree(buf); err != nil { + log.Debugf("free NetApi buffer: %v", err) + } + }() + + // MAX_PREFERRED_LENGTH makes the API allocate as much as it needs, so a + // short read is not expected. Report it rather than silently returning a + // subset of the account's groups. + if entriesRead != totalEntries { + return nil, fmt.Errorf("NetUserGetLocalGroups for %q returned %d of %d groups", username, entriesRead, totalEntries) + } + + entries := unsafe.Slice((*localGroupUsersInfo0)(unsafe.Pointer(buf)), entriesRead) + groups := make([]string, 0, entriesRead) + for _, entry := range entries { + groups = append(groups, windows.UTF16PtrToString(entry.name)) + } + return groups, nil +} diff --git a/client/ssh/server/privileges_windows_test.go b/client/ssh/server/privileges_windows_test.go new file mode 100644 index 000000000..983fccdf7 --- /dev/null +++ b/client/ssh/server/privileges_windows_test.go @@ -0,0 +1,293 @@ +//go:build windows + +package server + +import ( + "os/user" + "testing" + "unsafe" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// filterNormalAccount limits NetUserEnum to normal user accounts. +const filterNormalAccount = 0x2 + +// TOKEN_ELEVATION_TYPE values. +const ( + tokenElevationTypeDefault = 1 + tokenElevationTypeFull = 2 + tokenElevationTypeLimited = 3 +) + +// tokenElevationType reads TokenElevationType from a token. +func tokenElevationType(token windows.Token) (uint32, error) { + var elevationType, returnedLen uint32 + err := windows.GetTokenInformation(token, windows.TokenElevationType, + (*byte)(unsafe.Pointer(&elevationType)), uint32(unsafe.Sizeof(elevationType)), &returnedLen) + if err != nil { + return 0, err + } + return elevationType, nil +} + +// userInfo0 mirrors USER_INFO_0. +type userInfo0 struct { + name *uint16 +} + +func mustParseSID(t *testing.T, s string) *windows.SID { + t.Helper() + sid, err := windows.StringToSid(s) + require.NoError(t, err, "parse SID %s", s) + return sid +} + +// localAccountNames returns the names of the local user accounts. +func localAccountNames(t *testing.T) []string { + t.Helper() + + var buf *byte + var entriesRead, totalEntries, resume uint32 + err := windows.NetUserEnum(nil, 0, filterNormalAccount, &buf, maxPreferredLength, + &entriesRead, &totalEntries, &resume) + require.NoError(t, err, "enumerate local users") + t.Cleanup(func() { + require.NoError(t, windows.NetApiBufferFree(buf), "free NetApi buffer") + }) + + entries := unsafe.Slice((*userInfo0)(unsafe.Pointer(buf)), entriesRead) + names := make([]string, 0, entriesRead) + for _, entry := range entries { + names = append(names, windows.UTF16PtrToString(entry.name)) + } + return names +} + +// localAccountNameByRID returns the name of the local account carrying the +// given RID. Accounts such as Administrator and Guest can be renamed and are +// localized, so tests must not name them literally. +func localAccountNameByRID(t *testing.T, rid uint32) string { + t.Helper() + + for _, name := range localAccountNames(t) { + sid, _, _, err := windows.LookupSID("", name) + if err != nil { + continue + } + if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY { + continue + } + count := sid.SubAuthorityCount() + if count < 2 || sid.SubAuthority(0) != 21 { + continue + } + if sid.SubAuthority(uint32(count-1)) == rid { + return name + } + } + + t.Fatalf("no local account with RID %d", rid) + return "" +} + +// wellKnownAccountName resolves a well-known SID to the qualified account name +// the local system uses for it, which is localized. +func wellKnownAccountName(t *testing.T, sidType windows.WELL_KNOWN_SID_TYPE) string { + t.Helper() + + sid, err := windows.CreateWellKnownSid(sidType) + require.NoError(t, err, "create well-known SID") + name, domain, _, err := sid.LookupAccount("") + require.NoError(t, err, "resolve %s to an account name", sid) + if domain == "" { + return name + } + return domain + `\` + name +} + +func TestIsBuiltinAdministratorSID(t *testing.T) { + tests := []struct { + name string + sid string + want bool + }{ + {"machine_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true}, + {"domain_administrator", "S-1-5-21-3390233681-4087452608-412898826-500", true}, + {"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false}, + {"guest_account", "S-1-5-21-1111111111-2222222222-3333333333-501", false}, + {"domain_admins_group", "S-1-5-21-1111111111-2222222222-3333333333-512", false}, + {"system", "S-1-5-18", false}, + {"administrators_group", "S-1-5-32-544", false}, + {"non_nt_authority", "S-1-1-0", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isBuiltinAdministratorSID(mustParseSID(t, tt.sid)) + assert.Equal(t, tt.want, result, "RID 500 detection for %s", tt.sid) + }) + } +} + +func TestIsPrivilegedUserSID(t *testing.T) { + tests := []struct { + name string + sid string + want bool + }{ + {"local_system", "S-1-5-18", true}, + {"local_service", "S-1-5-19", true}, + {"network_service", "S-1-5-20", true}, + {"administrators_group", "S-1-5-32-544", true}, + {"builtin_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true}, + {"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false}, + {"users_group", "S-1-5-32-545", false}, + {"everyone", "S-1-1-0", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isPrivilegedUserSID(mustParseSID(t, tt.sid)) + assert.Equal(t, tt.want, result, "SID privilege classification for %s", tt.sid) + }) + } +} + +func TestIsWindowsAccountPrivilegedOrUnknown(t *testing.T) { + tests := []struct { + name string + username string + want bool + }{ + {"system", wellKnownAccountName(t, windows.WinLocalSystemSid), true}, + {"local_service", wellKnownAccountName(t, windows.WinLocalServiceSid), true}, + {"network_service", wellKnownAccountName(t, windows.WinNetworkServiceSid), true}, + {"administrators_group", wellKnownAccountName(t, windows.WinBuiltinAdministratorsSid), true}, + // The built-in Administrator (RID 500) and Guest (RID 501) accounts + // exist on every Windows installation, though they may be disabled. + {"builtin_administrator", localAccountNameByRID(t, 500), true}, + {"guest", localAccountNameByRID(t, 501), false}, + // Unresolvable accounts fail closed. + {"nonexistent_user", "netbird-no-such-user", true}, + {"empty_username", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isWindowsAccountPrivilegedOrUnknown(tt.username) + assert.Equal(t, tt.want, result, "account privilege classification for %q", tt.username) + }) + } +} + +func TestIsProcessElevated(t *testing.T) { + elevated := isProcessElevated() + + // TokenElevationType is a second, independent view of the same token: + // Full means elevated and Limited means a filtered administrator, while + // Default covers both a standard user and an administrator with no linked + // token (UAC off, the built-in Administrator, SYSTEM), so it implies nothing. + elevationType, err := tokenElevationType(windows.GetCurrentProcessToken()) + require.NoError(t, err, "read token elevation type") + + adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + require.NoError(t, err, "create Administrators SID") + + // Token(0) makes CheckTokenMembership evaluate the caller's own token. It + // counts only enabled SIDs, so a filtered administrator reports false here. + member, err := windows.Token(0).IsMember(adminSid) + require.NoError(t, err, "check own Administrators membership") + + t.Logf("elevated=%v elevationType=%d memberOfAdministrators=%v", elevated, elevationType, member) + + switch elevationType { + case tokenElevationTypeFull: + assert.True(t, elevated, "a token of elevation type Full must report elevated") + case tokenElevationTypeLimited: + assert.False(t, elevated, "a filtered administrator token must not report elevated") + } + + // Administrators enabled in the token means the token wields administrative + // rights, which is what elevation reports. + if member { + assert.True(t, elevated, "token with enabled Administrators membership must report elevated") + } +} + +// TestS4UMembershipAgreesWithLocalGroups exercises the S4U token path used +// for domain accounts. S4U logons need the TCB privilege, so the test runs +// only as SYSTEM (which is how CI executes the suite). For local accounts the +// token's Administrators membership must agree with the SAM enumeration. +func TestS4UMembershipAgreesWithLocalGroups(t *testing.T) { + system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + require.NoError(t, err, "create SYSTEM SID") + current, err := user.Current() + require.NoError(t, err, "get current user") + if current.Uid != system.String() { + t.Skipf("S4U logon requires SYSTEM (running as %s)", current.Username) + } + + adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + require.NoError(t, err, "create Administrators SID") + + checked := 0 + for _, name := range localAccountNames(t) { + viaToken, err := s4uTokenIsMember(name, ".", adminSid) + if err != nil { + // Disabled or logon-restricted accounts cannot get an S4U logon. + t.Logf("skipping %s: %v", name, err) + continue + } + viaSAM, err := localGroupsContainSID(name, adminSid) + require.NoError(t, err, "enumerate local groups for %s", name) + + assert.Equal(t, viaSAM, viaToken, "S4U token and SAM enumeration must agree on Administrators membership for %s", name) + checked++ + } + // Ineligible accounts are skipped, so without this the test could report + // success while comparing nothing at all. + require.Positive(t, checked, "no local account completed an S4U logon, so nothing was compared") + t.Logf("checked %d local accounts via S4U", checked) +} + +// TestLocalGroupsContainSID_Administrator checks the positive case against the +// built-in Administrator, a member of Administrators on every installation. +func TestLocalGroupsContainSID_Administrator(t *testing.T) { + adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + require.NoError(t, err, "create Administrators SID") + + administrator := localAccountNameByRID(t, 500) + member, err := localGroupsContainSID(administrator, adminSid) + require.NoError(t, err, "enumerate local groups for %s", administrator) + assert.True(t, member, "%s is a member of the Administrators group", administrator) +} + +// TestLocalGroupsContainSID_UnresolvableGroupFailsClosed covers a wanted SID +// that resolves to no group: the error must surface rather than being reported +// as "not a member", so the privilege check treats the account as privileged. +func TestLocalGroupsContainSID_UnresolvableGroupFailsClosed(t *testing.T) { + unknown := mustParseSID(t, "S-1-5-21-1111111111-2222222222-3333333333-4444") + + _, err := localGroupsContainSID(localAccountNameByRID(t, 500), unknown) + require.Error(t, err, "must report an error when the wanted group cannot be identified") +} + +func TestLocalGroupsContainSID_Guest(t *testing.T) { + guestsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid) + require.NoError(t, err, "create Guests SID") + adminsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + require.NoError(t, err, "create Administrators SID") + + guest := localAccountNameByRID(t, 501) + + inGuests, err := localGroupsContainSID(guest, guestsSid) + require.NoError(t, err, "enumerate local groups for %s", guest) + assert.True(t, inGuests, "%s is a member of the Guests group", guest) + + inAdmins, err := localGroupsContainSID(guest, adminsSid) + require.NoError(t, err, "enumerate local groups for %s", guest) + assert.False(t, inAdmins, "%s is not a member of the Administrators group", guest) +} diff --git a/client/ssh/server/server_config_test.go b/client/ssh/server/server_config_test.go index f70e29963..983bd7a43 100644 --- a/client/ssh/server/server_config_test.go +++ b/client/ssh/server/server_config_test.go @@ -239,6 +239,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType string port uint32 username string + uid string expectError bool errorMsg string skipOnWindows bool @@ -248,6 +249,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "remote", port: 80, username: "testuser", + uid: "1000", expectError: true, errorMsg: "cannot bind to privileged port", skipOnWindows: true, @@ -257,6 +259,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "tcpip-forward", port: 443, username: "testuser", + uid: "1000", expectError: true, errorMsg: "cannot bind to privileged port", skipOnWindows: true, @@ -266,6 +269,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "remote", port: 8080, username: "testuser", + uid: "1000", expectError: false, }, { @@ -273,6 +277,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "remote", port: 0, username: "testuser", + uid: "1000", expectError: false, }, { @@ -280,13 +285,35 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { forwardType: "remote", port: 22, username: "root", + uid: "0", expectError: false, }, + { + // Only uid 0 is privileged, whatever the account is called. + name: "uid 0 under another name may bind a privileged port", + forwardType: "remote", + port: 22, + username: "toor", + uid: "0", + expectError: false, + skipOnWindows: true, + }, + { + name: "account named root without uid 0 may not", + forwardType: "remote", + port: 22, + username: "root", + uid: "1000", + expectError: true, + errorMsg: "cannot bind to privileged port", + skipOnWindows: true, + }, { name: "local forward privileged port allowed for non-root", forwardType: "local", port: 80, username: "testuser", + uid: "1000", expectError: false, }, } @@ -299,7 +326,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) { result := PrivilegeCheckResult{ Allowed: true, - User: &user.User{Username: tt.username}, + User: &user.User{Username: tt.username, Uid: tt.uid}, } err := server.checkPrivilegedPortAccess(tt.forwardType, tt.port, result) @@ -420,6 +447,13 @@ func TestServer_PortConflictHandling(t *testing.T) { func TestServer_IsPrivilegedUser(t *testing.T) { + // Windows classification depends on account SIDs and group membership, and + // the accounts involved carry localized, renameable names. It is covered by + // TestIsWindowsAccountPrivileged, which resolves them from well-known SIDs. + if runtime.GOOS == "windows" { + t.Skip("covered by TestIsWindowsAccountPrivileged") + } + tests := []struct { username string expected bool @@ -440,44 +474,16 @@ func TestServer_IsPrivilegedUser(t *testing.T) { expected: false, description: "empty username should not be privileged", }, - } - - // Add Windows-specific tests - if runtime.GOOS == "windows" { - tests = append(tests, []struct { - username string - expected bool - description string - }{ - { - username: "Administrator", - expected: true, - description: "Administrator should be considered privileged on Windows", - }, - { - username: "administrator", - expected: true, - description: "administrator should be considered privileged on Windows (case insensitive)", - }, - }...) - } else { - // On non-Windows systems, Administrator should not be privileged - tests = append(tests, []struct { - username string - expected bool - description string - }{ - { - username: "Administrator", - expected: false, - description: "Administrator should not be privileged on non-Windows systems", - }, - }...) + { + username: "Administrator", + expected: false, + description: "Administrator should not be privileged on non-Windows systems", + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - result := isPrivilegedUsername(tt.username) + result := isPrivilegedOrUnknown(tt.username) assert.Equal(t, tt.expected, result, tt.description) }) } diff --git a/client/ssh/server/sftp_windows.go b/client/ssh/server/sftp_windows.go index dc532b9e7..25cd17298 100644 --- a/client/ssh/server/sftp_windows.go +++ b/client/ssh/server/sftp_windows.go @@ -17,7 +17,7 @@ import ( // createSftpCommand creates a Windows SFTP command with user switching. // The caller must close the returned token handle after starting the process. func (s *Server) createSftpCommand(targetUser *user.User, sess ssh.Session) (*exec.Cmd, windows.Token, error) { - username, domain := s.parseUsername(targetUser.Username) + username, domain := parseUsername(targetUser.Username) netbirdPath, err := os.Executable() if err != nil { diff --git a/client/ssh/server/test.go b/client/ssh/server/test.go index 454d3afa3..e2be0551c 100644 --- a/client/ssh/server/test.go +++ b/client/ssh/server/test.go @@ -1,3 +1,11 @@ +// This file is intentionally named test.go (not test_test.go) so the exported +// StartTestServer helper is visible to the ssh/proxy and ssh/client external +// test packages, not just this package's own tests. The //go:build !js tag +// keeps its "testing" import — and the whole testing/flag/regexp transitive +// chain it drags in — out of the wasm client, which links ssh/server through +// the engine but never runs Go tests under GOOS=js. +//go:build !js + package server import ( diff --git a/client/ssh/server/user_utils.go b/client/ssh/server/user_utils.go index bc2aa2d7d..6c8142b30 100644 --- a/client/ssh/server/user_utils.go +++ b/client/ssh/server/user_utils.go @@ -16,11 +16,6 @@ var ( ErrPrivilegedUserSwitch = errors.New("cannot switch to privileged user - current user lacks required privileges") ) -// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.) -func isPlatformUnix() bool { - return getCurrentOS() != "windows" -} - // Dependency injection variables for testing - allows mocking dynamic runtime checks var ( getCurrentUser = currentUserWithGetent @@ -29,6 +24,9 @@ var ( getIsProcessPrivileged = isCurrentProcessPrivileged getEuid = os.Geteuid + + getProcessElevated = isProcessElevated + getWindowsAccountPrivilegedOrUnknown = isWindowsAccountPrivilegedOrUnknown ) const ( @@ -65,6 +63,13 @@ type PrivilegeCheckResult struct { RequiresUserSwitching bool } +// privilegeCheckContext holds all context needed for privilege checking +type privilegeCheckContext struct { + currentUser *user.User + currentUserPrivileged bool + allowRoot bool +} + // CheckPrivileges performs comprehensive privilege checking for all SSH features. // This is the single source of truth for privilege decisions across the SSH server. func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult { @@ -75,7 +80,7 @@ func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult // Handle empty username case - but still check root access controls if req.RequestedUsername == "" { - if isPrivilegedUsername(context.currentUser.Username) && !context.allowRoot { + if isPrivilegedOrUnknown(context.currentUser.Username) && !context.allowRoot { return PrivilegeCheckResult{ Allowed: false, Error: &PrivilegedUserError{Username: context.currentUser.Username}, @@ -135,7 +140,7 @@ func (s *Server) checkUserRequest(ctx *privilegeCheckContext, req PrivilegeCheck needsUserSwitching := !isSameResolvedUser(resolvedUser, ctx.currentUser) - if isPrivilegedUsername(resolvedUser.Username) && !ctx.allowRoot { + if isPrivilegedOrUnknown(resolvedUser.Username) && !ctx.allowRoot { return PrivilegeCheckResult{ Allowed: false, Error: &PrivilegedUserError{Username: resolvedUser.Username}, @@ -175,6 +180,42 @@ func (s *Server) resolveRequestedUser(requestedUsername string) (*user.User, err return u, nil } +// SetAllowRootLogin configures root login access +func (s *Server) SetAllowRootLogin(allow bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.allowRootLogin = allow +} + +// userNameLookup performs user lookup with root login permission check +func (s *Server) userNameLookup(username string) (*user.User, error) { + result, err := s.userPrivilegeCheck(username) + if err != nil { + return nil, err + } + return result.User, nil +} + +// userPrivilegeCheck performs user lookup with full privilege check result +func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) { + result := s.CheckPrivileges(PrivilegeCheckRequest{ + RequestedUsername: username, + FeatureSupportsUserSwitch: true, + FeatureName: FeatureSSHLogin, + }) + + if !result.Allowed { + return result, result.Error + } + + return result, nil +} + +// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.) +func isPlatformUnix() bool { + return getCurrentOS() != "windows" +} + // isSameResolvedUser compares two resolved user identities func isSameResolvedUser(user1, user2 *user.User) bool { if user1 == nil || user2 == nil { @@ -183,13 +224,6 @@ func isSameResolvedUser(user1, user2 *user.User) bool { return user1.Uid == user2.Uid } -// privilegeCheckContext holds all context needed for privilege checking -type privilegeCheckContext struct { - currentUser *user.User - currentUserPrivileged bool - allowRoot bool -} - // isSameUser checks if two usernames refer to the same user // SECURITY: This function must be conservative - it should only return true // when we're certain both usernames refer to the exact same user identity @@ -253,159 +287,30 @@ func isWindowsSameUser(requestedUsername, currentUsername string) bool { return strings.EqualFold(reqDomain, curDomain) } -// SetAllowRootLogin configures root login access -func (s *Server) SetAllowRootLogin(allow bool) { - s.mu.Lock() - defer s.mu.Unlock() - s.allowRootLogin = allow -} - -// userNameLookup performs user lookup with root login permission check -func (s *Server) userNameLookup(username string) (*user.User, error) { - result := s.CheckPrivileges(PrivilegeCheckRequest{ - RequestedUsername: username, - FeatureSupportsUserSwitch: true, - FeatureName: FeatureSSHLogin, - }) - - if !result.Allowed { - return nil, result.Error - } - - return result.User, nil -} - -// userPrivilegeCheck performs user lookup with full privilege check result -func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) { - result := s.CheckPrivileges(PrivilegeCheckRequest{ - RequestedUsername: username, - FeatureSupportsUserSwitch: true, - FeatureName: FeatureSSHLogin, - }) - - if !result.Allowed { - return result, result.Error - } - - return result, nil -} - -// isPrivilegedUsername checks if the given username represents a privileged user across platforms. -// On Unix: root -// On Windows: Administrator, SYSTEM (case-insensitive) -// Handles domain-qualified usernames like "DOMAIN\Administrator" or "user@domain.com" -func isPrivilegedUsername(username string) bool { +// isPrivilegedOrUnknown reports whether the given username represents a +// privileged user, or on Windows an account whose privilege could not be +// determined. +// On Unix: root. +// On Windows: well-known service accounts, built-in Administrator accounts, +// and members of the local Administrators group; handles domain-qualified +// usernames like "DOMAIN\user" or "user@domain.com". An account that cannot be +// resolved or evaluated is reported as privileged. +// +// Use this to refuse privileged accounts, never to grant them anything: the +// undetermined case is safe for a refusal and unsafe for a grant. +func isPrivilegedOrUnknown(username string) bool { if getCurrentOS() != "windows" { return username == "root" } - - bareUsername := username - // Handle Windows domain format: DOMAIN\username - if idx := strings.LastIndex(username, `\`); idx != -1 { - bareUsername = username[idx+1:] - } - // Handle email-style format: username@domain.com - if idx := strings.Index(bareUsername, "@"); idx != -1 { - bareUsername = bareUsername[:idx] - } - - return isWindowsPrivilegedUser(bareUsername) -} - -// isWindowsPrivilegedUser checks if a bare username (domain already stripped) represents a Windows privileged account -func isWindowsPrivilegedUser(bareUsername string) bool { - // common privileged usernames (case insensitive) - privilegedNames := []string{ - "administrator", - "admin", - "root", - "system", - "localsystem", - "networkservice", - "localservice", - } - - usernameLower := strings.ToLower(bareUsername) - for _, privilegedName := range privilegedNames { - if usernameLower == privilegedName { - return true - } - } - - // computer accounts (ending with $) are not privileged by themselves - // They only gain privileges through group membership or specific SIDs - - if targetUser, err := lookupUser(bareUsername); err == nil { - return isWindowsPrivilegedSID(targetUser.Uid) - } - - return false -} - -// isWindowsPrivilegedSID checks if a Windows SID represents a privileged account -func isWindowsPrivilegedSID(sid string) bool { - privilegedSIDs := []string{ - "S-1-5-18", // Local System (SYSTEM) - "S-1-5-19", // Local Service (NT AUTHORITY\LOCAL SERVICE) - "S-1-5-20", // Network Service (NT AUTHORITY\NETWORK SERVICE) - "S-1-5-32-544", // Administrators group (BUILTIN\Administrators) - "S-1-5-500", // Built-in Administrator account (local machine RID 500) - } - - for _, privilegedSID := range privilegedSIDs { - if sid == privilegedSID { - return true - } - } - - // Check for domain administrator accounts (RID 500 in any domain) - // Format: S-1-5-21-domain-domain-domain-500 - // This is reliable as RID 500 is reserved for the domain Administrator account - if strings.HasPrefix(sid, "S-1-5-21-") && strings.HasSuffix(sid, "-500") { - return true - } - - // Check for other well-known privileged RIDs in domain contexts - // RID 512 = Domain Admins group, RID 516 = Domain Controllers group - if strings.HasPrefix(sid, "S-1-5-21-") { - if strings.HasSuffix(sid, "-512") || // Domain Admins group - strings.HasSuffix(sid, "-516") || // Domain Controllers group - strings.HasSuffix(sid, "-519") { // Enterprise Admins group - return true - } - } - - return false + return getWindowsAccountPrivilegedOrUnknown(username) } // isCurrentProcessPrivileged checks if the current process is running with elevated privileges. // On Unix systems, this means running as root (UID 0). -// On Windows, this means running as Administrator or SYSTEM. +// On Windows, this means the process token is elevated (administrators, SYSTEM). func isCurrentProcessPrivileged() bool { if getCurrentOS() == "windows" { - return isWindowsElevated() + return getProcessElevated() } return getEuid() == 0 } - -// isWindowsElevated checks if the current process is running with elevated privileges on Windows -func isWindowsElevated() bool { - currentUser, err := getCurrentUser() - if err != nil { - log.Errorf("failed to get current user for privilege check, assuming non-privileged: %v", err) - return false - } - - if isWindowsPrivilegedSID(currentUser.Uid) { - log.Debugf("Windows user switching supported: running as privileged SID %s", currentUser.Uid) - return true - } - - if isPrivilegedUsername(currentUser.Username) { - log.Debugf("Windows user switching supported: running as privileged username %s", currentUser.Username) - return true - } - - log.Debugf("Windows user switching not supported: not running as privileged user (current: %s)", currentUser.Uid) - return false -} diff --git a/client/ssh/server/user_utils_test.go b/client/ssh/server/user_utils_test.go index 637dc10d0..2fa9b68ee 100644 --- a/client/ssh/server/user_utils_test.go +++ b/client/ssh/server/user_utils_test.go @@ -4,6 +4,7 @@ import ( "errors" "os/user" "runtime" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -27,8 +28,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri originalLookupUser := lookupUser originalGetCurrentOS := getCurrentOS originalGetEuid := getEuid - - // Reset caches to ensure clean test state + originalGetProcessElevated := getProcessElevated + originalGetWindowsAccountPrivilegedOrUnknown := getWindowsAccountPrivilegedOrUnknown // Set test values - inject platform dependencies getCurrentUser = func() (*user.User, error) { @@ -53,16 +54,31 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri return euid } - // Mock privilege detection based on the test user - getIsProcessPrivileged = func() bool { + // Simulate the Windows token elevation check based on the fixture user: + // the built-in Administrator (RID 500) and SYSTEM run elevated. + getProcessElevated = func() bool { if currentUser == nil { return false } - // Check both username and SID for Windows systems - if os == "windows" && isWindowsPrivilegedSID(currentUser.Uid) { + return currentUser.Uid == "S-1-5-18" || strings.HasSuffix(currentUser.Uid, "-500") + } + + // Simulate the Windows account classifier for the fixture accounts. + // "root" does not exist on Windows; the real classifier fails closed on + // unresolvable accounts, so it counts as privileged here too. + getWindowsAccountPrivilegedOrUnknown = func(username string) bool { + bare := username + if idx := strings.LastIndex(bare, `\`); idx != -1 { + bare = bare[idx+1:] + } + if idx := strings.Index(bare, "@"); idx != -1 { + bare = bare[:idx] + } + switch strings.ToLower(bare) { + case "administrator", "system", "root": return true } - return isPrivilegedUsername(currentUser.Username) + return false } // Return cleanup function @@ -71,10 +87,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri lookupUser = originalLookupUser getCurrentOS = originalGetCurrentOS getEuid = originalGetEuid - - getIsProcessPrivileged = isCurrentProcessPrivileged - - // Reset caches after test + getProcessElevated = originalGetProcessElevated + getWindowsAccountPrivilegedOrUnknown = originalGetWindowsAccountPrivilegedOrUnknown } } @@ -421,6 +435,9 @@ func TestUsedFallback_MeansNoPrivilegeDropping(t *testing.T) { } func TestPrivilegedUsernameDetection(t *testing.T) { + // Windows classification is syscall-backed (SID resolution, group + // membership) and is covered by privileges_windows_test.go; here only the + // Unix logic and the platform dispatch are exercised. tests := []struct { name string username string @@ -432,25 +449,9 @@ func TestPrivilegedUsernameDetection(t *testing.T) { {"unix_regular_user", "alice", "linux", false}, {"unix_root_capital", "Root", "linux", false}, // Case-sensitive - // Windows tests + // Windows dispatch to the (mocked) account classifier {"windows_administrator", "Administrator", "windows", true}, - {"windows_system", "SYSTEM", "windows", true}, - {"windows_admin", "admin", "windows", true}, - {"windows_admin_lowercase", "administrator", "windows", true}, // Case-insensitive - {"windows_domain_admin", "DOMAIN\\Administrator", "windows", true}, - {"windows_email_admin", "admin@domain.com", "windows", true}, {"windows_regular_user", "alice", "windows", false}, - {"windows_domain_user", "DOMAIN\\alice", "windows", false}, - {"windows_localsystem", "localsystem", "windows", true}, - {"windows_networkservice", "networkservice", "windows", true}, - {"windows_localservice", "localservice", "windows", true}, - - // Computer accounts (these depend on current user context in real implementation) - {"windows_computer_account", "WIN2K19-C2$", "windows", false}, // Computer account by itself not privileged - {"windows_domain_computer", "DOMAIN\\COMPUTER$", "windows", false}, // Domain computer account - - // Cross-platform - {"root_on_windows", "root", "windows", true}, // Root should be privileged everywhere } for _, tt := range tests { @@ -459,50 +460,8 @@ func TestPrivilegedUsernameDetection(t *testing.T) { cleanup := setupTestDependencies(nil, nil, tt.platform, 1000, nil, nil) defer cleanup() - result := isPrivilegedUsername(tt.username) - assert.Equal(t, tt.privileged, result) - }) - } -} - -func TestWindowsPrivilegedSIDDetection(t *testing.T) { - tests := []struct { - name string - sid string - privileged bool - description string - }{ - // Well-known system accounts - {"system_account", "S-1-5-18", true, "Local System (SYSTEM)"}, - {"local_service", "S-1-5-19", true, "Local Service"}, - {"network_service", "S-1-5-20", true, "Network Service"}, - {"administrators_group", "S-1-5-32-544", true, "Administrators group"}, - {"builtin_administrator", "S-1-5-500", true, "Built-in Administrator"}, - - // Domain accounts - {"domain_administrator", "S-1-5-21-1234567890-1234567890-1234567890-500", true, "Domain Administrator (RID 500)"}, - {"domain_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-512", true, "Domain Admins group"}, - {"domain_controllers_group", "S-1-5-21-1234567890-1234567890-1234567890-516", true, "Domain Controllers group"}, - {"enterprise_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-519", true, "Enterprise Admins group"}, - - // Regular users - {"regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1001", false, "Regular domain user"}, - {"another_regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1234", false, "Another regular user"}, - {"local_user", "S-1-5-21-1234567890-1234567890-1234567890-1000", false, "Local regular user"}, - - // Groups that are not privileged - {"domain_users", "S-1-5-21-1234567890-1234567890-1234567890-513", false, "Domain Users group"}, - {"power_users", "S-1-5-32-547", false, "Power Users group"}, - - // Invalid SIDs - {"malformed_sid", "S-1-5-invalid", false, "Malformed SID"}, - {"empty_sid", "", false, "Empty SID"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isWindowsPrivilegedSID(tt.sid) - assert.Equal(t, tt.privileged, result, "Failed for %s: %s", tt.description, tt.sid) + result := isPrivilegedOrUnknown(tt.username) + assert.Equal(t, tt.privileged, result, "privilege classification for %s on %s", tt.username, tt.platform) }) } } diff --git a/client/ssh/server/userswitching_windows.go b/client/ssh/server/userswitching_windows.go index 260e1301e..9e8cd5b30 100644 --- a/client/ssh/server/userswitching_windows.go +++ b/client/ssh/server/userswitching_windows.go @@ -91,7 +91,7 @@ func validateUsernameFormat(username string) error { func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, func(), error) { logger.Debugf("creating Windows executor command for user %s (Pty: %v)", localUser.Username, hasPty) - username, _ := s.parseUsername(localUser.Username) + username, _ := parseUsername(localUser.Username) if err := validateUsername(username); err != nil { return nil, nil, fmt.Errorf("invalid username %q: %w", username, err) } @@ -102,7 +102,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l // createUserSwitchCommand creates a command with Windows user switching. // Returns the command and a cleanup function that must be called after starting the process. func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) { - username, domain := s.parseUsername(localUser.Username) + username, domain := parseUsername(localUser.Username) shell := getUserShell(localUser.Uid) @@ -138,7 +138,7 @@ func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, } // parseUsername extracts username and domain from a Windows username -func (s *Server) parseUsername(fullUsername string) (username, domain string) { +func parseUsername(fullUsername string) (username, domain string) { // Handle DOMAIN\username format if idx := strings.LastIndex(fullUsername, `\`); idx != -1 { domain = fullUsername[:idx] diff --git a/client/ssh/session.go b/client/ssh/session.go new file mode 100644 index 000000000..999b6f251 --- /dev/null +++ b/client/ssh/session.go @@ -0,0 +1,84 @@ +package ssh + +import ( + "fmt" + "io" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// DefaultTerminalModes are the PTY modes used by the interactive terminal clients. +var DefaultTerminalModes = ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + ssh.VINTR: 3, // Ctrl+C + ssh.VQUIT: 28, // Ctrl+\ + ssh.VERASE: 127, // Backspace + ssh.VKILL: 21, // Ctrl+U + ssh.VEOF: 4, // Ctrl+D + ssh.VEOL: 0, + ssh.VEOL2: 0, + ssh.VSTART: 17, // Ctrl+Q + ssh.VSTOP: 19, // Ctrl+S + ssh.VSUSP: 26, // Ctrl+Z + ssh.VDISCARD: 15, // Ctrl+O + ssh.VREPRINT: 18, // Ctrl+R + ssh.VWERASE: 23, // Ctrl+W + ssh.VLNEXT: 22, // Ctrl+V +} + +// PTYSession is an interactive shell session with a PTY and its I/O pipes. +type PTYSession struct { + Session *ssh.Session + Stdin io.WriteCloser + Stdout io.Reader + Stderr io.Reader +} + +// StartPTYSession opens a session on the client, requests an xterm-256color PTY +// with the default terminal modes, wires up the I/O pipes and starts a shell. +// The session is closed on any error. +func StartPTYSession(client *ssh.Client, cols, rows int) (*PTYSession, error) { + session, err := client.NewSession() + if err != nil { + return nil, fmt.Errorf("new session: %w", err) + } + + pty, err := setupPTYSession(session, cols, rows) + if err != nil { + if closeErr := session.Close(); closeErr != nil { + log.Debugf("ssh: session close after setup error: %v", closeErr) + } + return nil, err + } + return pty, nil +} + +// setupPTYSession requests the PTY, opens the pipes and starts the shell on an +// already created session. +func setupPTYSession(session *ssh.Session, cols, rows int) (*PTYSession, error) { + if err := session.RequestPty("xterm-256color", rows, cols, DefaultTerminalModes); err != nil { + return nil, fmt.Errorf("request pty: %w", err) + } + + stdin, err := session.StdinPipe() + if err != nil { + return nil, fmt.Errorf("stdin pipe: %w", err) + } + stdout, err := session.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := session.StderrPipe() + if err != nil { + return nil, fmt.Errorf("stderr pipe: %w", err) + } + + if err := session.Shell(); err != nil { + return nil, fmt.Errorf("start shell: %w", err) + } + + return &PTYSession{Session: session, Stdin: stdin, Stdout: stdout, Stderr: stderr}, nil +} diff --git a/client/status/status.go b/client/status/status.go index 5b815aaa3..1c204cdb1 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -46,7 +46,10 @@ func ParseDaemonStatus(s string) DaemonStatus { // ConvertOptions holds parameters for ConvertToStatusOutputOverview. type ConvertOptions struct { - Anonymize bool + Anonymize bool + // AnonymizeLevel selects how much the anonymizer redacts. Only + // meaningful when Anonymize is set. + AnonymizeLevel anonymize.Level DaemonVersion string DaemonStatus DaemonStatus StatusFilter string @@ -55,6 +58,10 @@ type ConvertOptions struct { IPsFilter map[string]struct{} ConnectionTypeFilter string ProfileName string + // SessionExpiresAt is the absolute UTC instant at which the peer's SSO + // session expires. Zero when the peer is not SSO-tracked or login + // expiration is disabled. Sourced from StatusResponse.SessionExpiresAt. + SessionExpiresAt time.Time } type PeerStateDetailOutput struct { @@ -155,6 +162,11 @@ type OutputOverview struct { LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"` ProfileName string `json:"profileName" yaml:"profileName"` SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"` + // SessionExpiresAt is the absolute UTC instant at which the peer's SSO + // session expires. nil when the peer is not SSO-tracked or login + // expiration is disabled. Pointer (rather than zero-value time.Time) so + // JSON / YAML omit the field entirely with `,omitempty`. + SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty" yaml:"sessionExpiresAt,omitempty"` } // ConvertToStatusOutputOverview converts protobuf status to the output overview. @@ -201,9 +213,14 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO ProfileName: opts.ProfileName, SSHServerState: sshServerOverview, } + if !opts.SessionExpiresAt.IsZero() { + t := opts.SessionExpiresAt + overview.SessionExpiresAt = &t + } if opts.Anonymize { anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + anonymizer.SetLevel(opts.AnonymizeLevel) anonymizeOverview(anonymizer, &overview) } @@ -547,6 +564,15 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total) + var sessionExpiryString string + if o.SessionExpiresAt != nil && !o.SessionExpiresAt.IsZero() { + sessionExpiryString = fmt.Sprintf( + "Session expires: %s (in %s)\n", + o.SessionExpiresAt.Format(time.RFC3339), + FormatRemainingDuration(time.Until(*o.SessionExpiresAt)), + ) + } + var forwardingRulesString string if o.NumberOfForwardingRules > 0 { forwardingRulesString = fmt.Sprintf("Forwarding rules: %d\n", o.NumberOfForwardingRules) @@ -593,6 +619,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS "SSH Server: %s\n"+ "Networks: %s\n"+ "%s"+ + "%s"+ "Peers count: %s\n", fmt.Sprintf("%s/%s%s", goos, goarch, goarm), daemonVersion, @@ -612,6 +639,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS sshServerStatus, networks, forwardingRulesString, + sessionExpiryString, peersCountString, ) return summary @@ -722,6 +750,8 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState) } + pbFullStatus.Events = fullStatus.Events + return &pbFullStatus } @@ -950,6 +980,7 @@ func timeAgo(t time.Time) string { func anonymizePeerDetail(a *anonymize.Anonymizer, peer *PeerStateDetailOutput) { peer.FQDN = a.AnonymizeDomain(peer.FQDN) + peer.PubKey = a.AnonymizeWGKey(peer.PubKey) if localIP, port, err := net.SplitHostPort(peer.IceCandidateEndpoint.Local); err == nil { peer.IceCandidateEndpoint.Local = fmt.Sprintf("%s:%s", a.AnonymizeIPString(localIP), port) } @@ -981,6 +1012,7 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { overview.SignalState.URL = a.AnonymizeURI(overview.SignalState.URL) overview.SignalState.Error = a.AnonymizeString(overview.SignalState.Error) + overview.PubKey = a.AnonymizeWGKey(overview.PubKey) overview.IP = a.AnonymizeIPString(overview.IP) overview.IPv6 = a.AnonymizeIPString(overview.IPv6) for i, detail := range overview.Relays.Details { @@ -1025,3 +1057,57 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command) } } + +// FormatRemainingDuration renders a time.Duration for the "Session expires" +// line. Examples: "2h 15m", "47m 12s", "8s", "expired 3m ago". +// +// Granularity drops to seconds only under a minute, otherwise minutes are +// the smallest unit shown — sub-minute precision is noise for a deadline +// that's hours or days out. +func FormatRemainingDuration(d time.Duration) string { + if d <= 0 { + return "expired " + HumaniseDuration(-d) + " ago" + } + return HumaniseDuration(d) +} + +// HumaniseDuration renders a positive duration in compact form (e.g. +// "2h 15m", "47m", "8s"). Exposed alongside FormatRemainingDuration so +// callers that don't need the "expired … ago" wording can format +// positive durations directly. +func HumaniseDuration(d time.Duration) string { + if d < time.Minute { + s := int(d.Round(time.Second).Seconds()) + if s < 1 { + s = 1 + } + return fmt.Sprintf("%ds", s) + } + + const ( + day = 24 * time.Hour + hour = time.Hour + minute = time.Minute + ) + + days := int64(d / day) + d -= time.Duration(days) * day + hours := int64(d / hour) + d -= time.Duration(hours) * hour + minutes := int64(d / minute) + + switch { + case days > 0: + if hours == 0 { + return fmt.Sprintf("%dd", days) + } + return fmt.Sprintf("%dd %dh", days, hours) + case hours > 0: + if minutes == 0 { + return fmt.Sprintf("%dh", hours) + } + return fmt.Sprintf("%dh %dm", hours, minutes) + default: + return fmt.Sprintf("%dm", minutes) + } +} diff --git a/client/status/status_test.go b/client/status/status_test.go index 44fc30baf..2babd9342 100644 --- a/client/status/status_test.go +++ b/client/status/status_test.go @@ -648,6 +648,53 @@ func TestTimeAgo(t *testing.T) { } } +func TestHumaniseDuration(t *testing.T) { + cases := []struct { + in time.Duration + want string + }{ + {0, "1s"}, + {500 * time.Millisecond, "1s"}, + {8 * time.Second, "8s"}, + {59 * time.Second, "59s"}, + {time.Minute, "1m"}, + {47*time.Minute + 12*time.Second, "47m"}, + {time.Hour, "1h"}, + {2*time.Hour + 15*time.Minute, "2h 15m"}, + {2 * time.Hour, "2h"}, + {24 * time.Hour, "1d"}, + {2*24*time.Hour + 3*time.Hour, "2d 3h"}, + } + for _, tc := range cases { + got := HumaniseDuration(tc.in) + assert.Equal(t, tc.want, got, "input %s", tc.in) + } +} + +func TestFormatRemainingDuration_Expired(t *testing.T) { + assert.Equal(t, "expired 3m ago", FormatRemainingDuration(-3*time.Minute)) + assert.Equal(t, "expired 1s ago", FormatRemainingDuration(-500*time.Millisecond)) +} + +func TestSessionExpiresLineRendered(t *testing.T) { + in := overview // copy of the package-level fixture + deadline := time.Now().Add(2*time.Hour + 30*time.Minute).UTC() + in.SessionExpiresAt = &deadline + + out := in.GeneralSummary(false, false, false, false) + assert.Contains(t, out, "Session expires: ") + assert.Contains(t, out, deadline.Format(time.RFC3339)) + // 2h 30m drifts to "2h 29m" within 60s — match the family prefix. + assert.Contains(t, out, "(in 2h ") +} + +func TestSessionExpiresLineOmittedWhenNil(t *testing.T) { + in := overview + in.SessionExpiresAt = nil + out := in.GeneralSummary(false, false, false, false) + assert.NotContains(t, out, "Session expires") +} + func TestMapRelaysTransport(t *testing.T) { out := mapRelays([]*proto.RelayState{ {URI: "rels://relay.example:443", Available: true, Transport: "quic"}, diff --git a/client/system/info.go b/client/system/info.go index 477d5162b..daeabca13 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -2,8 +2,11 @@ package system import ( "context" + "errors" "net/netip" + "slices" "strings" + "time" log "github.com/sirupsen/logrus" "google.golang.org/grpc/metadata" @@ -71,20 +74,20 @@ type Info struct { BlockInbound bool DisableIPv6 bool - LazyConnectionEnabled bool - EnableSSHRoot bool EnableSSHSFTP bool EnableSSHLocalPortForwarding bool EnableSSHRemotePortForwarding bool DisableSSHAuth bool + + SyncMessageVersion *int } func (i *Info) SetFlags( rosenpassEnabled, rosenpassPermissive bool, serverSSHAllowed *bool, disableClientRoutes, disableServerRoutes, - disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6, lazyConnectionEnabled bool, + disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, syncMessageVersion *int, enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool, disableSSHAuth *bool, ) { @@ -102,7 +105,7 @@ func (i *Info) SetFlags( i.BlockInbound = blockInbound i.DisableIPv6 = disableIPv6 - i.LazyConnectionEnabled = lazyConnectionEnabled + i.SyncMessageVersion = syncMessageVersion if enableSSHRoot != nil { i.EnableSSHRoot = *enableSSHRoot @@ -121,6 +124,23 @@ func (i *Info) SetFlags( } } +// removeAddresses drops network addresses whose IP matches any of the given +// addresses, regardless of prefix length. Used to exclude the NetBird overlay +// address, which otherwise churns the meta as the interface comes and goes. +func (i *Info) removeAddresses(ips ...netip.Addr) { + if len(ips) == 0 { + return + } + filtered := i.NetworkAddresses[:0] + for _, addr := range i.NetworkAddresses { + if slices.Contains(ips, addr.NetIP.Addr()) { + continue + } + filtered = append(filtered, addr) + } + i.NetworkAddresses = filtered +} + // extractUserAgent extracts Netbird's agent (client) name and version from the outgoing context func extractUserAgent(ctx context.Context) string { md, hasMeta := metadata.FromOutgoingContext(ctx) @@ -147,14 +167,16 @@ func extractDeviceName(ctx context.Context, defaultName string) string { } // GetInfoWithChecks retrieves and parses the system information with applied checks. -func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks) (*Info, error) { +// excludeIPs are dropped from the reported network addresses (e.g. our own +// WireGuard overlay address, which otherwise churns the peer meta). +func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, error) { log.Debugf("gathering system information with checks: %d", len(checks)) processCheckPaths := make([]string, 0) for _, check := range checks { processCheckPaths = append(processCheckPaths, check.GetFiles()...) } - files, err := checkFileAndProcess(processCheckPaths) + files, err := checkFileAndProcess(ctx, processCheckPaths) if err != nil { return nil, err } @@ -162,7 +184,48 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks) (*Info, erro info := GetInfo(ctx) info.Files = files + info.removeAddresses(excludeIPs...) log.Debugf("all system information gathered successfully") return info, nil } + +// GetInfoWithChecksTimeout is GetInfoWithChecks bounded by timeout. Posture-check gathering +// runs uncancellable system calls (process enumeration, os.Stat), so calling it inline can +// block the caller for as long as such a call hangs. It runs in a goroutine instead: if it +// does not return within timeout the caller gets (nil, false) and should proceed with +// degraded behavior rather than block. On a gathering error it falls back to base GetInfo. +// +// The buffered channel lets the abandoned goroutine finish and exit once its blocking call +// returns, so it does not leak beyond the duration of that call. +func GetInfoWithChecksTimeout(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + infoCh := make(chan *Info, 1) + go func() { + info, err := GetInfoWithChecks(ctx, checks, excludeIPs...) + if err != nil { + if ctx.Err() != nil { + return + } + log.Warnf("failed to get system info with checks: %v", err) + info = GetInfo(ctx) + info.removeAddresses(excludeIPs...) + } + infoCh <- info + }() + + select { + case info := <-infoCh: + return info, true + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + log.Warnf("gathering system info with checks timed out after %s", timeout) + } else { + // Parent context canceled (e.g. shutdown), not a timeout. + log.Warnf("gathering system info with checks canceled: %v", ctx.Err()) + } + return nil, false + } +} diff --git a/client/system/info_android.go b/client/system/info_android.go index 794ff15ed..d4f479386 100644 --- a/client/system/info_android.go +++ b/client/system/info_android.go @@ -30,6 +30,11 @@ func GetInfo(ctx context.Context) *Info { kernelVersion = osInfo[2] } + addrs, err := networkAddresses() + if err != nil { + log.Warnf("discover network addresses: %s", err) + } + gio := &Info{ GoOS: runtime.GOOS, Kernel: kernel, @@ -41,6 +46,7 @@ func GetInfo(ctx context.Context) *Info { NetbirdVersion: version.NetbirdVersion(), UIVersion: extractUIVersion(ctx), KernelVersion: kernelVersion, + NetworkAddresses: addrs, SystemSerialNumber: serial(), SystemProductName: productModel(), SystemManufacturer: productManufacturer(), @@ -50,7 +56,7 @@ func GetInfo(ctx context.Context) *Info { } // checkFileAndProcess checks if the file path exists and if a process is running at that path. -func checkFileAndProcess(paths []string) ([]File, error) { +func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) { return []File{}, nil } diff --git a/client/system/info_darwin.go b/client/system/info_darwin.go index 4a31920ec..e7bf367f6 100644 --- a/client/system/info_darwin.go +++ b/client/system/info_darwin.go @@ -32,7 +32,7 @@ func GetInfo(ctx context.Context) *Info { sysName := string(bytes.Split(utsname.Sysname[:], []byte{0})[0]) machine := string(bytes.Split(utsname.Machine[:], []byte{0})[0]) release := string(bytes.Split(utsname.Release[:], []byte{0})[0]) - swVersion, err := exec.Command("sw_vers", "-productVersion").Output() + swVersion, err := exec.CommandContext(ctx, "sw_vers", "-productVersion").Output() if err != nil { log.Warnf("got an error while retrieving macOS version with sw_vers, error: %s. Using darwin version instead.\n", err) swVersion = []byte(release) diff --git a/client/system/info_ios.go b/client/system/info_ios.go index ad42b1edf..1b0c084b3 100644 --- a/client/system/info_ios.go +++ b/client/system/info_ios.go @@ -105,7 +105,7 @@ func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool { } // checkFileAndProcess checks if the file path exists and if a process is running at that path. -func checkFileAndProcess(paths []string) ([]File, error) { +func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) { return []File{}, nil } diff --git a/client/system/info_js.go b/client/system/info_js.go index 994d439a7..3323fb542 100644 --- a/client/system/info_js.go +++ b/client/system/info_js.go @@ -15,7 +15,7 @@ func UpdateStaticInfoAsync() { } // GetInfo retrieves system information for WASM environment -func GetInfo(_ context.Context) *Info { +func GetInfo(ctx context.Context) *Info { info := &Info{ GoOS: runtime.GOOS, Kernel: runtime.GOARCH, @@ -30,6 +30,13 @@ func GetInfo(_ context.Context) *Info { collectBrowserInfo(info) collectLocationInfo(info) collectSystemInfo(info) + + // A caller-provided device name wins, as on the other platforms. A peer + // registered over an API keeps reporting the name it was registered with, + // so its meta does not change on the first sync. + if name := extractDeviceName(ctx, info.Hostname); name != "" { + info.Hostname = name + } return info } @@ -103,7 +110,7 @@ func collectLocationInfo(info *Info) { } } -func checkFileAndProcess(_ []string) ([]File, error) { +func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) { return []File{}, nil } diff --git a/client/system/info_js_test.go b/client/system/info_js_test.go new file mode 100644 index 000000000..e2a33ada0 --- /dev/null +++ b/client/system/info_js_test.go @@ -0,0 +1,27 @@ +//go:build js + +package system + +import ( + "context" + "testing" +) + +// TestGetInfoHonorsDeviceName covers a caller-provided device name reaching the +// reported hostname, so a peer registered over an API keeps reporting the name +// it was registered with instead of renaming itself on its first sync. +func TestGetInfoHonorsDeviceName(t *testing.T) { + ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "session-name") + if got := GetInfo(ctx).Hostname; got != "session-name" { + t.Errorf("hostname should carry the caller's device name, got %q", got) + } +} + +// TestGetInfoWithoutDeviceNameKeepsFallback covers the embed layer's habit of +// always setting the context value: an empty name must not blank the hostname. +func TestGetInfoWithoutDeviceNameKeepsFallback(t *testing.T) { + ctx := context.WithValue(context.Background(), DeviceNameCtxKey, "") + if got := GetInfo(ctx).Hostname; got == "" { + t.Error("an empty device name must not blank the hostname") + } +} diff --git a/client/system/info_test.go b/client/system/info_test.go index 27821f3c5..a7fa02197 100644 --- a/client/system/info_test.go +++ b/client/system/info_test.go @@ -2,7 +2,9 @@ package system import ( "context" + "net/netip" "testing" + "time" "github.com/stretchr/testify/assert" "google.golang.org/grpc/metadata" @@ -34,6 +36,20 @@ func Test_CustomHostname(t *testing.T) { assert.Equal(t, want, got.Hostname) } +func TestGetInfoWithChecksTimeout_Success(t *testing.T) { + info, ok := GetInfoWithChecksTimeout(context.Background(), 30*time.Second, nil) + assert.True(t, ok, "expected gathering to complete within the timeout") + assert.NotNil(t, info) +} + +func TestGetInfoWithChecksTimeout_Timeout(t *testing.T) { + // A 1ns budget expires before the (real) system-info gathering can finish, so the + // caller must get (nil, false) instead of blocking on the in-flight goroutine. + info, ok := GetInfoWithChecksTimeout(context.Background(), time.Nanosecond, nil) + assert.False(t, ok, "expected timeout to be reported") + assert.Nil(t, info) +} + func Test_NetAddresses(t *testing.T) { addr, err := networkAddresses() if err != nil { @@ -43,3 +59,42 @@ func Test_NetAddresses(t *testing.T) { t.Errorf("no network addresses found") } } + +func TestInfo_RemoveAddresses(t *testing.T) { + addr := func(cidr string) NetworkAddress { + return NetworkAddress{NetIP: netip.MustParsePrefix(cidr)} + } + + info := &Info{ + NetworkAddresses: []NetworkAddress{ + addr("192.168.1.7/24"), + addr("100.76.70.97/32"), // overlay v4 (host mask /32) + addr("2001:818:c51b:4800:845:a65d:ae6f:623f/64"), // real global v6 + addr("fd00:1234::1/64"), // overlay v6 + }, + } + + // Overlay addresses as the engine knows them, with a different mask (/16, /64). + info.removeAddresses( + netip.MustParseAddr("100.76.70.97"), + netip.MustParseAddr("fd00:1234::1"), + ) + + want := []string{"192.168.1.7/24", "2001:818:c51b:4800:845:a65d:ae6f:623f/64"} + if len(info.NetworkAddresses) != len(want) { + t.Fatalf("got %d addresses, want %d: %v", len(info.NetworkAddresses), len(want), info.NetworkAddresses) + } + for i, w := range want { + if got := info.NetworkAddresses[i].NetIP.String(); got != w { + t.Errorf("address[%d] = %s, want %s", i, got, w) + } + } +} + +func TestInfo_RemoveAddresses_NoOp(t *testing.T) { + info := &Info{NetworkAddresses: []NetworkAddress{{NetIP: netip.MustParsePrefix("10.0.0.1/24")}}} + info.removeAddresses() + if len(info.NetworkAddresses) != 1 { + t.Errorf("expected no change with empty input, got %v", info.NetworkAddresses) + } +} diff --git a/client/system/network_addr.go b/client/system/network_addr.go index 5423cf8ad..505a6f0ea 100644 --- a/client/system/network_addr.go +++ b/client/system/network_addr.go @@ -1,4 +1,4 @@ -//go:build !ios +//go:build !ios && !android package system @@ -46,7 +46,9 @@ func toNetworkAddress(address net.Addr, mac string) (NetworkAddress, bool) { if !ok { return NetworkAddress{}, false } - if ipNet.IP.IsLoopback() { + // Skip link-local and multicast: they carry no routable peer info and the + // IPv6 link-local of a flapping NIC churns the meta on every up/down. + if ipNet.IP.IsLoopback() || ipNet.IP.IsLinkLocalUnicast() || ipNet.IP.IsMulticast() { return NetworkAddress{}, false } prefix, err := netip.ParsePrefix(ipNet.String()) diff --git a/client/system/network_addr_android.go b/client/system/network_addr_android.go new file mode 100644 index 000000000..99a71e105 --- /dev/null +++ b/client/system/network_addr_android.go @@ -0,0 +1,89 @@ +package system + +import ( + "net/netip" + "strings" +) + +var iFaceDiscover IFaceDiscover + +type IFaceDiscover interface { + IFaces() (string, error) +} + +// SetIFaceDiscover configures the Android interface discovery provider. +func SetIFaceDiscover(discover IFaceDiscover) { + iFaceDiscover = discover +} + +func networkAddresses() ([]NetworkAddress, error) { + if iFaceDiscover == nil { + return nil, nil + } + ifaces, err := iFaceDiscover.IFaces() + if err != nil { + return nil, err + } + + var netAddresses []NetworkAddress + for _, line := range strings.Split(ifaces, "\n") { + addresses, ok := interfaceAddresses(line) + if !ok { + continue + } + for _, address := range addresses { + netAddr, ok := toNetworkAddress(address) + if !ok { + continue + } + if isDuplicated(netAddresses, netAddr) { + continue + } + netAddresses = append(netAddresses, netAddr) + } + } + return netAddresses, nil +} + +func interfaceAddresses(line string) ([]string, bool) { + parts := strings.Split(line, "|") + if len(parts) != 2 { + return nil, false + } + flags := strings.Fields(parts[0]) + if len(flags) != 8 { + return nil, false + } + up, loopback := flags[3], flags[5] + if up != "true" || loopback == "true" { + return nil, false + } + return strings.Fields(parts[1]), true +} + +func toNetworkAddress(address string) (NetworkAddress, bool) { + prefix, err := netip.ParsePrefix(address) + if err != nil { + return NetworkAddress{}, false + } + if prefix.Addr().Is4In6() { + if prefix.Bits() < 96 { + return NetworkAddress{}, false + } + prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96) + } + ip := prefix.Addr() + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsMulticast() { + return NetworkAddress{}, false + } + return NetworkAddress{NetIP: prefix}, true +} + +func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool { + for _, duplicated := range addresses { + if duplicated.NetIP == addr.NetIP { + return true + } + } + return false +} diff --git a/client/system/network_addr_test.go b/client/system/network_addr_test.go new file mode 100644 index 000000000..b0be40f0a --- /dev/null +++ b/client/system/network_addr_test.go @@ -0,0 +1,45 @@ +//go:build !ios && !android + +package system + +import ( + "net" + "testing" +) + +func mustIPNet(t *testing.T, cidr string) *net.IPNet { + t.Helper() + ip, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + t.Fatalf("parse %q: %v", cidr, err) + } + ipNet.IP = ip + return ipNet +} + +func TestToNetworkAddress_Filtering(t *testing.T) { + const mac = "c8:4b:d6:b6:04:ac" + + tests := []struct { + name string + cidr string + want bool + }{ + {"ipv4 global", "10.65.16.181/23", true}, + {"ipv6 global", "2620:52:0:4110:102d:6a98:ee75:8b92/64", true}, + {"ipv4 loopback", "127.0.0.1/8", false}, + {"ipv6 loopback", "::1/128", false}, + {"ipv6 link-local", "fe80::871:4c25:23d7:2529/64", false}, + {"ipv4 link-local", "169.254.1.2/16", false}, + {"ipv6 multicast", "ff02::1/128", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, got := toNetworkAddress(mustIPNet(t, tt.cidr), mac) + if got != tt.want { + t.Errorf("toNetworkAddress(%s) ok = %v, want %v", tt.cidr, got, tt.want) + } + }) + } +} diff --git a/client/system/process.go b/client/system/process.go index 87e21eb9d..fefa7d913 100644 --- a/client/system/process.go +++ b/client/system/process.go @@ -3,24 +3,30 @@ package system import ( + "context" "os" "slices" - "github.com/shirou/gopsutil/v3/process" + "github.com/shirou/gopsutil/v4/process" ) -// getRunningProcesses returns a list of running process paths. -func getRunningProcesses() ([]string, error) { - processIDs, err := process.Pids() +// getRunningProcesses returns a list of running process paths. The context bounds the work: +// the per-PID loop bails as soon as ctx is done, and the gopsutil calls honor it where they +// can, so a stuck enumeration cannot run unbounded. +func getRunningProcesses(ctx context.Context) ([]string, error) { + processIDs, err := process.PidsWithContext(ctx) if err != nil { return nil, err } processMap := make(map[string]bool) for _, pID := range processIDs { + if err := ctx.Err(); err != nil { + return nil, err + } p := &process.Process{Pid: pID} - path, _ := p.Exe() + path, _ := p.ExeWithContext(ctx) if path != "" { processMap[path] = false } @@ -35,18 +41,21 @@ func getRunningProcesses() ([]string, error) { } // checkFileAndProcess checks if the file path exists and if a process is running at that path. -func checkFileAndProcess(paths []string) ([]File, error) { +func checkFileAndProcess(ctx context.Context, paths []string) ([]File, error) { files := make([]File, len(paths)) if len(paths) == 0 { return files, nil } - runningProcesses, err := getRunningProcesses() + runningProcesses, err := getRunningProcesses(ctx) if err != nil { return nil, err } for i, path := range paths { + if err := ctx.Err(); err != nil { + return nil, err + } file := File{Path: path} _, err := os.Stat(path) diff --git a/client/system/process_test.go b/client/system/process_test.go index 505808a9e..de1cfc1db 100644 --- a/client/system/process_test.go +++ b/client/system/process_test.go @@ -1,15 +1,18 @@ +//go:build windows || (linux && !android) || (darwin && !ios) || freebsd + package system import ( + "context" "testing" - "github.com/shirou/gopsutil/v3/process" + "github.com/shirou/gopsutil/v4/process" ) func Benchmark_getRunningProcesses(b *testing.B) { b.Run("getRunningProcesses new", func(b *testing.B) { for i := 0; i < b.N; i++ { - ps, err := getRunningProcesses() + ps, err := getRunningProcesses(context.Background()) if err != nil { b.Fatalf("unexpected error: %v", err) } @@ -29,12 +32,38 @@ func Benchmark_getRunningProcesses(b *testing.B) { } } }) - s, _ := getRunningProcesses() + s, _ := getRunningProcesses(context.Background()) b.Logf("getRunningProcesses returned %d processes", len(s)) s, _ = getRunningProcessesOld() b.Logf("getRunningProcessesOld returned %d processes", len(s)) } +func TestCheckFileAndProcess_ContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // With a canceled context and non-empty paths the gathering must bail with an error + // instead of running the (potentially blocking) process scan / stat loop. + if _, err := checkFileAndProcess(ctx, []string{"/does/not/exist"}); err == nil { + t.Fatal("expected error on canceled context, got nil") + } +} + +func TestCheckFileAndProcess_EmptyPaths(t *testing.T) { + // No check paths means no work to do: it must return immediately with no error, + // even on a canceled context (nothing to scan or stat). + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + files, err := checkFileAndProcess(ctx, nil) + if err != nil { + t.Fatalf("unexpected error for empty paths: %v", err) + } + if len(files) != 0 { + t.Fatalf("expected no files, got %d", len(files)) + } +} + func getRunningProcessesOld() ([]string, error) { processes, err := process.Processes() if err != nil { diff --git a/client/test/json-socket-docker.sh b/client/test/json-socket-docker.sh new file mode 100755 index 000000000..a878f13d6 --- /dev/null +++ b/client/test/json-socket-docker.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +set -eEuo pipefail + +usage() { + cat <<'EOF' +Usage: client/test/json-socket-docker.sh [tcp|unix|both] + +Builds the NetBird client Docker image from the local source tree, starts +`netbird service run` in a container with --enable-json-socket, and verifies +that the HTTP/JSON daemon gateway responds to Status requests. + +Modes: + tcp Validate tcp://0.0.0.0:8080 via a published localhost port (default) + unix Validate unix:///sock/netbird-http.sock via a bind-mounted socket dir + both Run both validations + +Environment: + CONTAINER_RUNTIME docker or podman. Auto-detected if unset. + IMAGE Image tag to build. Default: netbird-json-socket-test:local + TARGETARCH Go/Docker target arch. Default: `go env GOARCH` + PLATFORM Docker platform. Default: linux/$TARGETARCH + WAIT_TIMEOUT Seconds to wait for the JSON socket. Default: 30 +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +MODE="${1:-tcp}" +case "${MODE}" in + tcp|unix|both) ;; + *) + usage >&2 + echo "invalid mode: ${MODE}" >&2 + exit 2 + ;; +esac + +RUNTIME="${CONTAINER_RUNTIME:-}" +if [[ -z "${RUNTIME}" ]]; then + if command -v docker >/dev/null 2>&1; then + RUNTIME=docker + elif command -v podman >/dev/null 2>&1; then + RUNTIME=podman + else + echo "docker or podman is required" >&2 + exit 127 + fi +fi +if ! command -v "${RUNTIME}" >/dev/null 2>&1; then + echo "container runtime not found: ${RUNTIME}" >&2 + exit 127 +fi + +if ! command -v curl >/dev/null 2>&1; then + echo "curl is required" >&2 + exit 127 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IMAGE="${IMAGE:-netbird-json-socket-test:local}" +TARGETARCH="${TARGETARCH:-$(go env GOARCH)}" +PLATFORM="${PLATFORM:-linux/${TARGETARCH}}" +WAIT_TIMEOUT="${WAIT_TIMEOUT:-30}" +TMP_DIR="$(mktemp -d)" +CONTAINERS=() + +cleanup() { + local status=$? + for container in "${CONTAINERS[@]:-}"; do + "${RUNTIME}" rm -f "${container}" >/dev/null 2>&1 || true + done + rm -rf "${TMP_DIR}" + exit "${status}" +} +trap cleanup EXIT + +build_image() { + echo "==> Building Linux ${TARGETARCH} netbird binary" + mkdir -p "${TMP_DIR}/context/client" + cp "${ROOT_DIR}/client/Dockerfile" "${TMP_DIR}/context/Dockerfile" + cp "${ROOT_DIR}/client/netbird-entrypoint.sh" "${TMP_DIR}/context/client/netbird-entrypoint.sh" + + (cd "${ROOT_DIR}" && CGO_ENABLED=0 GOOS=linux GOARCH="${TARGETARCH}" go build -o "${TMP_DIR}/context/netbird" ./client) + + echo "==> Building ${IMAGE} for ${PLATFORM}" + "${RUNTIME}" build \ + --platform "${PLATFORM}" \ + --build-arg NETBIRD_BINARY=netbird \ + -t "${IMAGE}" \ + -f "${TMP_DIR}/context/Dockerfile" \ + "${TMP_DIR}/context" +} + +pick_port() { + python3 - <<'PY' +import socket +sock = socket.socket() +sock.bind(("127.0.0.1", 0)) +print(sock.getsockname()[1]) +sock.close() +PY +} + +assert_status_json() { + local response_file="$1" + if command -v python3 >/dev/null 2>&1; then + python3 - "${response_file}" <<'PY' +import json +import sys +with open(sys.argv[1], encoding="utf-8") as fh: + data = json.load(fh) +if not data.get("status"): + raise SystemExit("missing non-empty status field") +if "daemonVersion" not in data: + raise SystemExit("missing daemonVersion field") +print(f"status={data['status']} daemonVersion={data['daemonVersion']}") +PY + else + grep -q '"status"' "${response_file}" + grep -q '"daemonVersion"' "${response_file}" + cat "${response_file}" + fi +} + +container_logs() { + local container="$1" + echo "---- ${container} logs ----" >&2 + "${RUNTIME}" logs "${container}" >&2 || true + echo "--------------------------" >&2 +} + +wait_for_http_status() { + local container="$1" + local response="${TMP_DIR}/${container}.json" + local curl_err="${TMP_DIR}/${container}.curl.err" + shift + local deadline=$((SECONDS + WAIT_TIMEOUT)) + + while (( SECONDS < deadline )); do + if curl -fsS "$@" \ + -X POST \ + -H 'Content-Type: application/json' \ + -d '{}' \ + -o "${response}" \ + 2>"${curl_err}"; then + assert_status_json "${response}" + return 0 + fi + + if ! "${RUNTIME}" ps --format '{{.Names}}' | grep -Fxq "${container}"; then + echo "container exited before JSON socket became ready" >&2 + container_logs "${container}" + return 1 + fi + sleep 1 + done + + echo "timed out waiting for JSON socket after ${WAIT_TIMEOUT}s" >&2 + cat "${curl_err}" >&2 || true + container_logs "${container}" + return 1 +} + +run_netbird_container() { + local container="$1" + local json_socket="$2" + shift 2 + + CONTAINERS+=("${container}") + "${RUNTIME}" run --rm -d \ + --name "${container}" \ + -e NB_STATE_DIR=/tmp/netbird-state \ + --entrypoint /usr/local/bin/netbird \ + "$@" \ + "${IMAGE}" \ + --log-file console \ + --daemon-addr unix:///tmp/netbird.sock \ + service run \ + --enable-json-socket \ + --json-socket "${json_socket}" >/dev/null +} + +run_tcp_test() { + local port container + port="$(pick_port)" + container="nb-json-socket-tcp-$RANDOM-$RANDOM" + + echo "==> Validating TCP JSON socket on 127.0.0.1:${port}" + run_netbird_container "${container}" "tcp://0.0.0.0:8080" -p "127.0.0.1:${port}:8080" + wait_for_http_status "${container}" "http://127.0.0.1:${port}/daemon.DaemonService/Status" +} + +run_unix_test() { + local sock_dir sock_path container + sock_dir="${TMP_DIR}/sock" + sock_path="${sock_dir}/netbird-http.sock" + container="nb-json-socket-unix-$RANDOM-$RANDOM" + mkdir -p "${sock_dir}" + + echo "==> Validating Unix JSON socket at ${sock_path}" + run_netbird_container "${container}" "unix:///sock/netbird-http.sock" -v "${sock_dir}:/sock" + wait_for_http_status "${container}" --unix-socket "${sock_path}" "http://unix/daemon.DaemonService/Status" +} + +build_image + +case "${MODE}" in + tcp) + run_tcp_test + ;; + unix) + run_unix_test + ;; + both) + run_tcp_test + run_unix_test + ;; +esac + +echo "==> Docker JSON socket validation passed (${MODE})" diff --git a/client/testutil/privileged/runner_test.go b/client/testutil/privileged/runner_test.go new file mode 100644 index 000000000..d1945894d --- /dev/null +++ b/client/testutil/privileged/runner_test.go @@ -0,0 +1,196 @@ +//go:build privileged && (linux || darwin) + +// Package privileged provides a self-hosting harness that runs the repo's +// privileged-tagged test suite inside a --privileged --cap-add=NET_ADMIN +// container, so developers can exercise the root/system-mutating tests on a +// non-root host with a single `go test` invocation. +package privileged + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/moby/moby/api/types/container" + "github.com/ory/dockertest/v4" +) + +// containerImage / containerTag match the image used by the CI privileged job +// (.github/workflows/golang-test-linux.yml, test_client_on_docker). +const ( + containerImage = "golang" + containerTag = "1.25-alpine" +) + +const ( + containerWorkdir = "/app" + containerGoCache = "/root/.cache/go-build" + containerGoModCache = "/go/pkg/mod" +) + +// alpinePackages are the build/runtime deps the privileged tests need, mirroring +// the CI container setup. +const alpinePackages = "ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base" + +// privilegedTestPackages is the package list the suite runs, excluding the +// server-side trees and UI/upload helpers, matching the CI Docker job's filter. +const privilegedTestPackages = `go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server` + +// testWriter forwards container output to the test log line by line. +type testWriter struct{ t *testing.T } + +func (w testWriter) Write(p []byte) (int, error) { + for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") { + w.t.Log(line) + } + return len(p), nil +} + +// TestRunPrivilegedSuiteInDocker spins up a privileged container, mounts the repo, +// and runs `go test -tags 'devcert privileged'` inside it. When already running +// inside that container (DOCKER_CI=true) it returns immediately so the real +// privileged tests in the suite execute in place instead of recursing. +func TestRunPrivilegedSuiteInDocker(t *testing.T) { + if os.Getenv("DOCKER_CI") == "true" { + t.Skip("inside privileged container, skipping container spawn; privileged tests run in place") + } + + repoRoot, err := findRepoRoot() + if err != nil { + t.Fatalf("locate repo root: %v", err) + } + goCache, goModCache := hostGoCaches(t) + + // dockertest reads DOCKER_HOST; point it at the active context's socket when + // the default one is absent (macOS Docker Desktop, Colima, OrbStack). + if host := dockerHost(); host != "" { + t.Setenv("DOCKER_HOST", host) + } + + // NewPoolT registers container cleanup via t.Cleanup automatically. + pool := dockertest.NewPoolT(t, "", dockertest.WithMaxWait(30*time.Minute)) + + // Keep the container alive so the suite runs via Exec, which yields a clean + // exit code (the v4 Resource API exposes no container wait/exit-code). + resource := pool.RunT(t, containerImage, + dockertest.WithTag(containerTag), + dockertest.WithWorkingDir(containerWorkdir), + dockertest.WithMounts([]string{ + repoRoot + ":" + containerWorkdir, + goCache + ":" + containerGoCache, + goModCache + ":" + containerGoModCache, + }), + dockertest.WithEnv([]string{ + "CGO_ENABLED=1", + "CI=true", + "DOCKER_CI=true", + "CONTAINER=true", + "GOCACHE=" + containerGoCache, + "GOMODCACHE=" + containerGoModCache, + }), + dockertest.WithCmd([]string{"sleep", "infinity"}), + dockertest.WithHostConfig(func(hc *container.HostConfig) { + hc.Privileged = true + hc.CapAdd = []string{"NET_ADMIN"} + }), + dockertest.WithoutReuse(), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + result, err := resource.Exec(ctx, []string{"sh", "-c", buildTestScript()}) + if err != nil { + t.Fatalf("run privileged suite in container: %v", err) + } + + w := testWriter{t} + _, _ = w.Write([]byte(result.StdOut)) + _, _ = w.Write([]byte(result.StdErr)) + + if result.ExitCode != 0 { + t.Fatalf("privileged test suite failed in container (exit code %d)", result.ExitCode) + } +} + +// findRepoRoot walks up from the test's working directory to the module root. +func findRepoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("go.mod not found above %s", dir) + } + dir = parent + } +} + +// dockerHost returns a DOCKER_HOST override when the default socket is missing. +// An empty result means the caller should leave DOCKER_HOST untouched (it is +// already set, or the default unix socket exists). When neither is present +// (common on macOS Docker Desktop, Colima and OrbStack, which use a per-user +// socket), it resolves the active docker context's endpoint. +func dockerHost() string { + if os.Getenv("DOCKER_HOST") != "" { + return "" + } + if _, err := os.Stat("/var/run/docker.sock"); err == nil { + return "" + } + + out, err := exec.Command("docker", "context", "inspect", "-f", "{{.Endpoints.docker.Host}}").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// hostGoCaches resolves the host GOCACHE/GOMODCACHE so the container reuses the +// existing build/module cache for speed. +func hostGoCaches(t *testing.T) (string, string) { + t.Helper() + return goEnv(t, "GOCACHE"), goEnv(t, "GOMODCACHE") +} + +func goEnv(t *testing.T, key string) string { + t.Helper() + var out bytes.Buffer + cmd := exec.Command("go", "env", key) + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + t.Fatalf("go env %s: %v", key, err) + } + return strings.TrimSpace(out.String()) +} + +// buildTestScript builds the in-container command. PRIV_PKGS overrides the package +// list (default: the full filtered set); PRIV_RUN adds a -run test-name filter. +// Both empty reproduces the full privileged suite. +func buildTestScript() string { + pkgs := privilegedTestPackages + " | xargs" + if p := os.Getenv("PRIV_PKGS"); p != "" { + pkgs = "echo " + p + " | xargs" + } + + runFilter := "" + if r := os.Getenv("PRIV_RUN"); r != "" { + runFilter = "-run '" + r + "' " + } + + return fmt.Sprintf( + "apk update >/dev/null && apk add --no-cache %s >/dev/null && %s go test -buildvcs=false -tags 'devcert privileged' %s-v -timeout 20m -p 1", + alpinePackages, pkgs, runFilter, + ) +} diff --git a/client/ui/.gitignore b/client/ui/.gitignore new file mode 100644 index 000000000..9f233d8b6 --- /dev/null +++ b/client/ui/.gitignore @@ -0,0 +1,8 @@ +.task +bin +frontend/dist +frontend/node_modules +frontend/bindings +frontend/.vite +build/linux/appimage/build +build/windows/nsis/MicrosoftEdgeWebview2Setup.exe diff --git a/client/ui/Netbird.icns b/client/ui/Netbird.icns deleted file mode 100644 index 20af72825..000000000 Binary files a/client/ui/Netbird.icns and /dev/null differ diff --git a/client/ui/Taskfile.yml b/client/ui/Taskfile.yml new file mode 100644 index 000000000..2d0af9018 --- /dev/null +++ b/client/ui/Taskfile.yml @@ -0,0 +1,58 @@ +version: '3' + +includes: + common: ./build/Taskfile.yml + windows: ./build/windows/Taskfile.yml + darwin: ./build/darwin/Taskfile.yml + linux: ./build/linux/Taskfile.yml + +vars: + APP_NAME: "netbird-ui" + BIN_DIR: "bin" + VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}' + +tasks: + build: + summary: Builds the application + cmds: + - task: "{{OS}}:build" + + package: + summary: Packages a production build of the application + cmds: + - task: "{{OS}}:package" + + run: + summary: Runs the application + cmds: + - task: "{{OS}}:run" + + dev: + summary: Runs the application in development mode + cmds: + - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}} + + setup:docker: + summary: Builds Docker image for cross-compilation (~800MB download) + cmds: + - task: common:setup:docker + + build:server: + summary: Builds the application in server mode (no GUI, HTTP server only) + cmds: + - task: common:build:server + + run:server: + summary: Runs the application in server mode + cmds: + - task: common:run:server + + build:docker: + summary: Builds a Docker image for server mode deployment + cmds: + - task: common:build:docker + + run:docker: + summary: Builds and runs the Docker image + cmds: + - task: common:run:docker diff --git a/client/ui/assets/connected.png b/client/ui/assets/connected.png deleted file mode 100644 index 7dd2ab01a..000000000 Binary files a/client/ui/assets/connected.png and /dev/null differ diff --git a/client/ui/assets/disconnected.png b/client/ui/assets/disconnected.png deleted file mode 100644 index 421632b52..000000000 Binary files a/client/ui/assets/disconnected.png and /dev/null differ diff --git a/client/ui/assets/netbird-disconnected.ico b/client/ui/assets/netbird-disconnected.ico deleted file mode 100644 index 812e9d283..000000000 Binary files a/client/ui/assets/netbird-disconnected.ico and /dev/null differ diff --git a/client/ui/assets/netbird-disconnected.png b/client/ui/assets/netbird-disconnected.png deleted file mode 100644 index 79d4775ea..000000000 Binary files a/client/ui/assets/netbird-disconnected.png and /dev/null differ diff --git a/client/ui/assets/netbird-menu-16.png b/client/ui/assets/netbird-menu-16.png new file mode 100644 index 000000000..d5dcab446 Binary files /dev/null and b/client/ui/assets/netbird-menu-16.png differ diff --git a/client/ui/assets/netbird-menu-24.png b/client/ui/assets/netbird-menu-24.png new file mode 100644 index 000000000..087c1c2ae Binary files /dev/null and b/client/ui/assets/netbird-menu-24.png differ diff --git a/client/ui/assets/netbird-menu-about-18.png b/client/ui/assets/netbird-menu-about-18.png new file mode 100644 index 000000000..bb12c6367 Binary files /dev/null and b/client/ui/assets/netbird-menu-about-18.png differ diff --git a/client/ui/assets/netbird-menu-dot-connected-16.png b/client/ui/assets/netbird-menu-dot-connected-16.png new file mode 100644 index 000000000..3a7fa31a4 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connected-16.png differ diff --git a/client/ui/assets/netbird-menu-dot-connected-22.png b/client/ui/assets/netbird-menu-dot-connected-22.png new file mode 100644 index 000000000..78b068748 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connected-22.png differ diff --git a/client/ui/assets/netbird-menu-dot-connected.png b/client/ui/assets/netbird-menu-dot-connected.png new file mode 100644 index 000000000..fc8ce4d85 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connected.png differ diff --git a/client/ui/assets/netbird-menu-dot-connecting-16.png b/client/ui/assets/netbird-menu-dot-connecting-16.png new file mode 100644 index 000000000..f874706b5 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connecting-16.png differ diff --git a/client/ui/assets/netbird-menu-dot-connecting-22.png b/client/ui/assets/netbird-menu-dot-connecting-22.png new file mode 100644 index 000000000..d8e5970f5 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connecting-22.png differ diff --git a/client/ui/assets/netbird-menu-dot-connecting.png b/client/ui/assets/netbird-menu-dot-connecting.png new file mode 100644 index 000000000..3f8bc29d8 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-connecting.png differ diff --git a/client/ui/assets/netbird-menu-dot-error-16.png b/client/ui/assets/netbird-menu-dot-error-16.png new file mode 100644 index 000000000..cdc6254da Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-error-16.png differ diff --git a/client/ui/assets/netbird-menu-dot-error-22.png b/client/ui/assets/netbird-menu-dot-error-22.png new file mode 100644 index 000000000..d9bd013d6 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-error-22.png differ diff --git a/client/ui/assets/netbird-menu-dot-error.png b/client/ui/assets/netbird-menu-dot-error.png new file mode 100644 index 000000000..ce5d0e8ef Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-error.png differ diff --git a/client/ui/assets/netbird-menu-dot-idle-16.png b/client/ui/assets/netbird-menu-dot-idle-16.png new file mode 100644 index 000000000..354b5b860 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-idle-16.png differ diff --git a/client/ui/assets/netbird-menu-dot-idle-22.png b/client/ui/assets/netbird-menu-dot-idle-22.png new file mode 100644 index 000000000..675cf1ffe Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-idle-22.png differ diff --git a/client/ui/assets/netbird-menu-dot-idle.png b/client/ui/assets/netbird-menu-dot-idle.png new file mode 100644 index 000000000..79e7bbbf8 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-idle.png differ diff --git a/client/ui/assets/netbird-menu-dot-offline-16.png b/client/ui/assets/netbird-menu-dot-offline-16.png new file mode 100644 index 000000000..f9aa5c3e9 Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-offline-16.png differ diff --git a/client/ui/assets/netbird-menu-dot-offline-22.png b/client/ui/assets/netbird-menu-dot-offline-22.png new file mode 100644 index 000000000..5202c8baa Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-offline-22.png differ diff --git a/client/ui/assets/netbird-menu-dot-offline.png b/client/ui/assets/netbird-menu-dot-offline.png new file mode 100644 index 000000000..7aec5d01d Binary files /dev/null and b/client/ui/assets/netbird-menu-dot-offline.png differ diff --git a/client/ui/assets/netbird-systemtray-connected-dark.ico b/client/ui/assets/netbird-systemtray-connected-dark.ico deleted file mode 100644 index 0db8a0862..000000000 Binary files a/client/ui/assets/netbird-systemtray-connected-dark.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-connected-macos.png b/client/ui/assets/netbird-systemtray-connected-macos.png index ead210250..d29a7ade8 100644 Binary files a/client/ui/assets/netbird-systemtray-connected-macos.png and b/client/ui/assets/netbird-systemtray-connected-macos.png differ diff --git a/client/ui/assets/netbird-systemtray-connected-mono-dark.png b/client/ui/assets/netbird-systemtray-connected-mono-dark.png new file mode 100644 index 000000000..1f7d40121 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-connected-mono.png b/client/ui/assets/netbird-systemtray-connected-mono.png new file mode 100644 index 000000000..8a8710746 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connected-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-connected.ico b/client/ui/assets/netbird-systemtray-connected.ico deleted file mode 100644 index c16bec3f5..000000000 Binary files a/client/ui/assets/netbird-systemtray-connected.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-connecting-dark.ico b/client/ui/assets/netbird-systemtray-connecting-dark.ico deleted file mode 100644 index 615d40f07..000000000 Binary files a/client/ui/assets/netbird-systemtray-connecting-dark.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-connecting-macos.png b/client/ui/assets/netbird-systemtray-connecting-macos.png index 0fe7fa0db..306c6ddf5 100644 Binary files a/client/ui/assets/netbird-systemtray-connecting-macos.png and b/client/ui/assets/netbird-systemtray-connecting-macos.png differ diff --git a/client/ui/assets/netbird-systemtray-connecting-mono-dark.png b/client/ui/assets/netbird-systemtray-connecting-mono-dark.png new file mode 100644 index 000000000..f208cb6bf Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connecting-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-connecting-mono.png b/client/ui/assets/netbird-systemtray-connecting-mono.png new file mode 100644 index 000000000..e254321fd Binary files /dev/null and b/client/ui/assets/netbird-systemtray-connecting-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-connecting.ico b/client/ui/assets/netbird-systemtray-connecting.ico deleted file mode 100644 index 4e4c3a9b1..000000000 Binary files a/client/ui/assets/netbird-systemtray-connecting.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-disconnected-macos.png b/client/ui/assets/netbird-systemtray-disconnected-macos.png index 36b9a488f..48cfa7c60 100644 Binary files a/client/ui/assets/netbird-systemtray-disconnected-macos.png and b/client/ui/assets/netbird-systemtray-disconnected-macos.png differ diff --git a/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png b/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png new file mode 100644 index 000000000..035e71ba7 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-disconnected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-disconnected-mono.png b/client/ui/assets/netbird-systemtray-disconnected-mono.png new file mode 100644 index 000000000..d68c4dc5b Binary files /dev/null and b/client/ui/assets/netbird-systemtray-disconnected-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-disconnected.ico b/client/ui/assets/netbird-systemtray-disconnected.ico deleted file mode 100644 index dcb9f4bf8..000000000 Binary files a/client/ui/assets/netbird-systemtray-disconnected.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-error-dark.ico b/client/ui/assets/netbird-systemtray-error-dark.ico deleted file mode 100644 index 083816188..000000000 Binary files a/client/ui/assets/netbird-systemtray-error-dark.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-error-macos.png b/client/ui/assets/netbird-systemtray-error-macos.png index 9a9998bcf..580fe647c 100644 Binary files a/client/ui/assets/netbird-systemtray-error-macos.png and b/client/ui/assets/netbird-systemtray-error-macos.png differ diff --git a/client/ui/assets/netbird-systemtray-error-mono-dark.png b/client/ui/assets/netbird-systemtray-error-mono-dark.png new file mode 100644 index 000000000..6bcdacd44 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-error-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-error-mono.png b/client/ui/assets/netbird-systemtray-error-mono.png new file mode 100644 index 000000000..164d65a4f Binary files /dev/null and b/client/ui/assets/netbird-systemtray-error-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-error.ico b/client/ui/assets/netbird-systemtray-error.ico deleted file mode 100644 index 1abc45c2a..000000000 Binary files a/client/ui/assets/netbird-systemtray-error.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-needs-login-macos.png b/client/ui/assets/netbird-systemtray-needs-login-macos.png new file mode 100644 index 000000000..580fe647c Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-macos.png differ diff --git a/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png b/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png new file mode 100644 index 000000000..6bcdacd44 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-needs-login-mono.png b/client/ui/assets/netbird-systemtray-needs-login-mono.png new file mode 100644 index 000000000..164d65a4f Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-needs-login.png b/client/ui/assets/netbird-systemtray-needs-login.png new file mode 100644 index 000000000..722342989 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-needs-login.png differ diff --git a/client/ui/assets/netbird-systemtray-update-connected-dark.ico b/client/ui/assets/netbird-systemtray-update-connected-dark.ico deleted file mode 100644 index b11bb5492..000000000 Binary files a/client/ui/assets/netbird-systemtray-update-connected-dark.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-update-connected-macos.png b/client/ui/assets/netbird-systemtray-update-connected-macos.png index 8a6b2f2db..8b7b9f131 100644 Binary files a/client/ui/assets/netbird-systemtray-update-connected-macos.png and b/client/ui/assets/netbird-systemtray-update-connected-macos.png differ diff --git a/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png b/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png new file mode 100644 index 000000000..284efa880 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-connected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-update-connected-mono.png b/client/ui/assets/netbird-systemtray-update-connected-mono.png new file mode 100644 index 000000000..ed9ceb8a2 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-connected-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-update-connected.ico b/client/ui/assets/netbird-systemtray-update-connected.ico deleted file mode 100644 index d3ce2f0f3..000000000 Binary files a/client/ui/assets/netbird-systemtray-update-connected.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico b/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico deleted file mode 100644 index 123237f66..000000000 Binary files a/client/ui/assets/netbird-systemtray-update-disconnected-dark.ico and /dev/null differ diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-macos.png b/client/ui/assets/netbird-systemtray-update-disconnected-macos.png index 8b190034e..b6afa3937 100644 Binary files a/client/ui/assets/netbird-systemtray-update-disconnected-macos.png and b/client/ui/assets/netbird-systemtray-update-disconnected-macos.png differ diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png b/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png new file mode 100644 index 000000000..eb0c4bcf5 Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-disconnected-mono-dark.png differ diff --git a/client/ui/assets/netbird-systemtray-update-disconnected-mono.png b/client/ui/assets/netbird-systemtray-update-disconnected-mono.png new file mode 100644 index 000000000..519ea014a Binary files /dev/null and b/client/ui/assets/netbird-systemtray-update-disconnected-mono.png differ diff --git a/client/ui/assets/netbird-systemtray-update-disconnected.ico b/client/ui/assets/netbird-systemtray-update-disconnected.ico deleted file mode 100644 index 968dc4105..000000000 Binary files a/client/ui/assets/netbird-systemtray-update-disconnected.ico and /dev/null differ diff --git a/client/ui/assets/netbird.ico b/client/ui/assets/netbird.ico deleted file mode 100644 index 2bab8a503..000000000 Binary files a/client/ui/assets/netbird.ico and /dev/null differ diff --git a/client/ui/assets/svg/needs-login.svg b/client/ui/assets/svg/needs-login.svg new file mode 100644 index 000000000..5c01b48d4 --- /dev/null +++ b/client/ui/assets/svg/needs-login.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/client/ui/assets/svg/netbird-menu.svg b/client/ui/assets/svg/netbird-menu.svg new file mode 100644 index 000000000..bd4e9d65d --- /dev/null +++ b/client/ui/assets/svg/netbird-menu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/client/ui/authsession/service.go b/client/ui/authsession/service.go new file mode 100644 index 000000000..9c094c2a1 --- /dev/null +++ b/client/ui/authsession/service.go @@ -0,0 +1,129 @@ +//go:build !android && !ios && !freebsd && !js + +package authsession + +import ( + "context" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +type ExtendStartParams struct { + // Hint is the OIDC login_hint, typically the user's email. + Hint string `json:"hint"` +} + +type ExtendStartResult struct { + VerificationURI string `json:"verificationUri"` + VerificationURIComplete string `json:"verificationUriComplete"` + UserCode string `json:"userCode"` + DeviceCode string `json:"deviceCode"` + ExpiresIn int64 `json:"expiresIn"` +} + +type ExtendWaitParams struct { + DeviceCode string `json:"deviceCode"` + UserCode string `json:"userCode"` +} + +// ExtendResult: ExpiresAt is nil when the peer is ineligible for extension. +// Preempted means a newer WaitExtend took over the IdP poll — a no-op, not a failure. +type ExtendResult struct { + ExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"` + Preempted bool `json:"preempted,omitempty"` +} + +// DaemonConn duplicates services.DaemonConn to avoid an import cycle. +type DaemonConn interface { + Client() (proto.DaemonServiceClient, error) +} + +// Session bundles the session-auth daemon RPCs the UI drives. +type Session struct { + conn DaemonConn +} + +func NewSession(conn DaemonConn) *Session { + return &Session{conn: conn} +} + +// RequestExtend starts the SSO session-extension flow on the daemon. +func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) { + cli, err := s.conn.Client() + if err != nil { + return ExtendStartResult{}, err + } + + // a request from the UI implies a graphical session, which the daemon cannot detect itself + req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: true} + hint := p.Hint + if hint == "" { + pm := profilemanager.NewProfileManager() + if active, perr := pm.GetActiveProfile(); perr != nil { + log.Debugf("failed to get active profile for login hint: %v", perr) + } else if state, serr := pm.GetProfileState(active.ID); serr != nil { + log.Debugf("failed to get profile state for login hint: %v", serr) + } else { + hint = state.Email + } + } + if hint != "" { + req.Hint = &hint + } + + resp, err := cli.RequestExtendAuthSession(ctx, req) + if err != nil { + return ExtendStartResult{}, err + } + + return ExtendStartResult{ + VerificationURI: resp.GetVerificationURI(), + VerificationURIComplete: resp.GetVerificationURIComplete(), + UserCode: resp.GetUserCode(), + DeviceCode: resp.GetDeviceCode(), + ExpiresIn: resp.GetExpiresIn(), + }, nil +} + +// WaitExtend blocks until the user completes the SSO flow started by RequestExtend. +func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) { + cli, err := s.conn.Client() + if err != nil { + return ExtendResult{}, err + } + + resp, err := cli.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{ + DeviceCode: p.DeviceCode, + UserCode: p.UserCode, + }) + if err != nil { + if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Canceled { + return ExtendResult{Preempted: true}, nil + } + return ExtendResult{}, err + } + + out := ExtendResult{} + if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() { + t := ts.AsTime().UTC() + out.ExpiresAt = &t + } + return out, nil +} + +// DismissWarning suppresses the daemon's T-FinalWarningLead fallback dialog for +// the current deadline. Best-effort: a stale call is silently swallowed daemon-side. +func (s *Session) DismissWarning(ctx context.Context) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.DismissSessionWarning(ctx, &proto.DismissSessionWarningRequest{}) + return err +} diff --git a/client/ui/authsession/warning.go b/client/ui/authsession/warning.go new file mode 100644 index 000000000..91ae7f101 --- /dev/null +++ b/client/ui/authsession/warning.go @@ -0,0 +1,64 @@ +//go:build !android && !ios && !freebsd && !js + +// Package authsession holds the UI-side domain logic for the SSO +// session-extend feature. The Wails facades in client/ui/services/session*.go +// are thin adapters over these types. +package authsession + +import ( + "time" + + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" +) + +// Re-exported from sessionwatch so UI-side consumers don't import the +// daemon-internal package directly. +const ( + MetaWarning = sessionwatch.MetaSessionWarning + MetaFinal = sessionwatch.MetaSessionFinal + MetaExpiresAt = sessionwatch.MetaSessionExpiresAt + MetaLeadMinutes = sessionwatch.MetaSessionLeadMinutes + MetaDeadlineRejected = sessionwatch.MetaSessionDeadlineRejected +) + +// Warning is the typed payload emitted on the session-warning Wails events. +type Warning struct { + // Absolute UTC deadline; best-effort, stays zero when metadata is + // missing or malformed (e.g. an older daemon) and the UI falls back + // to the Status snapshot. + ExpiresAt time.Time `json:"sessionExpiresAt"` + // Configured lead time, so the UI need not hardcode the constant. + LeadMinutes int `json:"leadMinutes"` + // True on the final-warning fallback event. + Final bool `json:"final"` +} + +// WarningFromMetadata parses SystemEvent metadata into a Warning, or returns +// (nil, false) when the event is not a session-warning. A field that fails to +// parse stays zero; the event is still surfaced. +func WarningFromMetadata(meta map[string]string) (*Warning, bool) { + if meta == nil || meta[MetaWarning] != "true" { + return nil, false + } + + out := &Warning{ + Final: meta[MetaFinal] == "true", + } + if raw := meta[MetaExpiresAt]; raw != "" { + if t, err := sessionwatch.ParseExpiresAt(raw); err == nil { + out.ExpiresAt = t + } + } + if raw := meta[MetaLeadMinutes]; raw != "" { + if n, err := sessionwatch.ParseLeadMinutes(raw); err == nil { + out.LeadMinutes = n + } + } + return out, true +} + +// ParseExpiresAt re-exports sessionwatch.ParseExpiresAt so UI-side call sites +// don't import the daemon-internal package. +func ParseExpiresAt(s string) (time.Time, error) { + return sessionwatch.ParseExpiresAt(s) +} diff --git a/client/ui/authsession/warning_test.go b/client/ui/authsession/warning_test.go new file mode 100644 index 000000000..297073ded --- /dev/null +++ b/client/ui/authsession/warning_test.go @@ -0,0 +1,82 @@ +//go:build !android && !ios && !freebsd && !js + +package authsession + +import ( + "testing" + "time" +) + +func TestWarningFromMetadata_NotASessionWarning(t *testing.T) { + cases := []struct { + name string + meta map[string]string + }{ + {"nil metadata", nil}, + {"empty map", map[string]string{}}, + {"unrelated event", map[string]string{"new_version_available": "0.65.0"}}, + {"flag not 'true'", map[string]string{"session_warning": "1"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if w, ok := WarningFromMetadata(tc.meta); ok { + t.Fatalf("expected (nil, false), got (%+v, %v)", w, ok) + } + }) + } +} + +func TestWarningFromMetadata_FullPayload(t *testing.T) { + ts := "2026-05-18T13:30:00Z" + meta := map[string]string{ + "session_warning": "true", + "session_expires_at": ts, + "lead_minutes": "10", + } + + got, ok := WarningFromMetadata(meta) + if !ok { + t.Fatalf("expected the warning to be recognised, got ok=false") + } + want, _ := time.Parse(time.RFC3339, ts) + if !got.ExpiresAt.Equal(want.UTC()) { + t.Errorf("ExpiresAt = %v, want %v", got.ExpiresAt, want.UTC()) + } + if got.LeadMinutes != 10 { + t.Errorf("LeadMinutes = %d, want 10", got.LeadMinutes) + } +} + +func TestWarningFromMetadata_BadFieldsStillEmits(t *testing.T) { + // Older or buggy daemon: the flag is set but the timestamp/lead are + // missing or malformed. The UI should still get a warning so it can + // at least surface "session expires soon"; field zero-values are fine. + meta := map[string]string{ + "session_warning": "true", + "session_expires_at": "not-a-timestamp", + "lead_minutes": "abc", + } + + got, ok := WarningFromMetadata(meta) + if !ok { + t.Fatalf("warning should still be recognised even with malformed fields") + } + if !got.ExpiresAt.IsZero() { + t.Errorf("malformed timestamp should leave field zero, got %v", got.ExpiresAt) + } + if got.LeadMinutes != 0 { + t.Errorf("malformed lead_minutes should leave field 0, got %d", got.LeadMinutes) + } +} + +func TestWarningFromMetadata_MissingFieldsStillEmits(t *testing.T) { + // Only the flag is present (e.g. future-trimmed event). Still emit. + meta := map[string]string{"session_warning": "true"} + got, ok := WarningFromMetadata(meta) + if !ok { + t.Fatalf("warning should still be recognised when only flag is present") + } + if got.ExpiresAt.IsZero() != true || got.LeadMinutes != 0 { + t.Errorf("missing fields should be zero-valued, got %+v", got) + } +} diff --git a/client/ui/autostart_default.go b/client/ui/autostart_default.go new file mode 100644 index 000000000..162922579 --- /dev/null +++ b/client/ui/autostart_default.go @@ -0,0 +1,121 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "os" + "path/filepath" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/ui/preferences" + "github.com/netbirdio/netbird/client/ui/services" +) + +// autostartDefaultState carries the guard inputs of the one-time autostart +// default decision so the decision itself stays a pure, testable function. +type autostartDefaultState struct { + supported bool + mdmDisabled bool + priorInstall bool +} + +// shouldEnableAutostartDefault applies the first-run guards in order and +// returns whether autostart may be enabled, plus the reason when it may not. +func shouldEnableAutostartDefault(s autostartDefaultState) (bool, string) { + switch { + case !s.supported: + return false, "autostart not supported on this platform" + case s.mdmDisabled: + return false, "autostart disabled by MDM policy" + case s.priorInstall: + return false, "existing NetBird installation" + } + return true, "" +} + +// autostartDisabledByMDM reports whether the MDM policy manages the +// disableAutostart key in a way that must suppress the default. An +// unparseable managed value is treated as disabled to stay on the safe side. +func autostartDisabledByMDM(policy *mdm.Policy) bool { + if !policy.HasKey(mdm.KeyDisableAutostart) { + return false + } + disabled, ok := policy.GetBool(mdm.KeyDisableAutostart) + return !ok || disabled +} + +// netbirdFootprintExists reports whether the machine already carries NetBird +// daemon config or state, meaning this is not a genuinely fresh install. It is +// the update-safety gate for the autostart default: upgrading users always +// have a footprint, so an update can never trigger a autostart entry write. +func netbirdFootprintExists() bool { + candidates := []string{ + profilemanager.DefaultConfigPath, + filepath.Join(profilemanager.DefaultConfigPathDir, "config.json"), + filepath.Join(profilemanager.DefaultConfigPathDir, "state.json"), + } + for _, path := range candidates { + if path != "" && fileExists(path) { + return true + } + } + return false +} + +// applyAutostartDefault runs the one-time launch-on-login default for genuinely +// fresh installs. The autostartInitialized marker is persisted before any +// enable attempt so a crash mid-flow degrades to "never enabled" instead of +// retrying autostart entry writes on every launch. A user's later disable in +// Settings is never overridden: the marker guarantees at-most-once, ever. +func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) { + mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy()) + + if mdmDisabled { + if enabled, err := autostart.IsEnabled(ctx); err != nil { + log.Warnf("MDM disableAutostart: read autostart state: %v", err) + } else if enabled { + if err := autostart.SetEnabled(ctx, false); err != nil { + log.Warnf("MDM disableAutostart: force off failed: %v", err) + } else { + log.Info("MDM disableAutostart enforced: autostart turned off") + } + } + } + + priorFootprint := netbirdFootprintExists() || prefsFileExisted + + if prefs.Get().AutostartInitialized { + return + } + if err := prefs.SetAutostartInitialized(true); err != nil { + log.Warnf("persist autostart marker, skipping autostart default: %v", err) + return + } + + state := autostartDefaultState{ + supported: autostart.Supported(ctx), + mdmDisabled: mdmDisabled, + priorInstall: priorFootprint, + } + enable, reason := shouldEnableAutostartDefault(state) + if !enable { + log.Debugf("skipping autostart default: %s", reason) + return + } + + if err := autostart.SetEnabled(ctx, true); err != nil { + log.Warnf("enable autostart on fresh install: %v", err) + return + } + log.Info("autostart enabled by default on fresh install") +} + +// fileExists reports whether path exists. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/client/ui/autostart_default_test.go b/client/ui/autostart_default_test.go new file mode 100644 index 000000000..b7bdf9f2a --- /dev/null +++ b/client/ui/autostart_default_test.go @@ -0,0 +1,125 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/client/mdm" +) + +func TestShouldEnableAutostartDefault(t *testing.T) { + allPass := autostartDefaultState{ + supported: true, + mdmDisabled: false, + priorInstall: false, + } + + tests := []struct { + name string + mutate func(*autostartDefaultState) + wantEnable bool + wantReason string + }{ + { + name: "fresh install with all guards passing enables", + mutate: func(*autostartDefaultState) {}, + wantEnable: true, + }, + { + name: "unsupported platform skips", + mutate: func(s *autostartDefaultState) { s.supported = false }, + wantReason: "autostart not supported on this platform", + }, + { + name: "MDM disable skips", + mutate: func(s *autostartDefaultState) { s.mdmDisabled = true }, + wantReason: "autostart disabled by MDM policy", + }, + { + name: "existing installation (upgrade) skips", + mutate: func(s *autostartDefaultState) { s.priorInstall = true }, + wantReason: "existing NetBird installation", + }, + { + name: "unsupported wins over every other guard", + mutate: func(s *autostartDefaultState) { + s.supported = false + s.mdmDisabled = true + s.priorInstall = true + }, + wantReason: "autostart not supported on this platform", + }, + { + name: "MDM disable wins over prior install", + mutate: func(s *autostartDefaultState) { + s.mdmDisabled = true + s.priorInstall = true + }, + wantReason: "autostart disabled by MDM policy", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + state := allPass + tc.mutate(&state) + enable, reason := shouldEnableAutostartDefault(state) + assert.Equal(t, tc.wantEnable, enable, "enable decision should match for state %+v", state) + assert.Equal(t, tc.wantReason, reason, "skip reason should identify the failing guard") + }) + } +} + +func TestAutostartDisabledByMDM(t *testing.T) { + tests := []struct { + name string + values map[string]any + want bool + }{ + { + name: "empty policy does not disable", + values: nil, + want: false, + }, + { + name: "unrelated managed keys do not disable", + values: map[string]any{mdm.KeyDisableAutoConnect: true}, + want: false, + }, + { + name: "disableAutostart true disables", + values: map[string]any{mdm.KeyDisableAutostart: true}, + want: true, + }, + { + name: "disableAutostart registry DWORD 1 disables", + values: map[string]any{mdm.KeyDisableAutostart: int64(1)}, + want: true, + }, + { + name: "disableAutostart string true disables", + values: map[string]any{mdm.KeyDisableAutostart: "true"}, + want: true, + }, + { + name: "disableAutostart explicit false allows", + values: map[string]any{mdm.KeyDisableAutostart: false}, + want: false, + }, + { + name: "unparseable managed value is treated as disabled", + values: map[string]any{mdm.KeyDisableAutostart: "not-a-bool"}, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := autostartDisabledByMDM(mdm.NewPolicy(tc.values)) + assert.Equal(t, tc.want, got, "MDM disable decision should match for values %v", tc.values) + }) + } +} diff --git a/client/ui/build/Taskfile.yml b/client/ui/build/Taskfile.yml new file mode 100644 index 000000000..590d4791b --- /dev/null +++ b/client/ui/build/Taskfile.yml @@ -0,0 +1,295 @@ +version: '3' + +tasks: + go:mod:tidy: + summary: Runs `go mod tidy` + internal: true + cmds: + - go mod tidy + + install:frontend:deps: + summary: Install frontend dependencies + dir: frontend + sources: + - package.json + - pnpm-lock.yaml + generates: + - node_modules + preconditions: + - sh: pnpm --version + msg: "Looks like pnpm isn't installed. Install with: corepack enable && corepack prepare pnpm@latest --activate" + cmds: + - pnpm install + + build:frontend: + label: build:frontend (DEV={{.DEV}}) + summary: Build the frontend project + dir: frontend + sources: + - "**/*" + - exclude: node_modules/**/* + generates: + - dist/**/* + deps: + - task: install:frontend:deps + - task: generate:bindings + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + cmds: + - pnpm run {{.BUILD_COMMAND}} + env: + PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}' + vars: + BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}' + + + frontend:vendor:puppertino: + summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling + sources: + - frontend/public/puppertino/puppertino.css + generates: + - frontend/public/puppertino/puppertino.css + cmds: + - | + set -euo pipefail + mkdir -p frontend/public/puppertino + # If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error. + if [ ! -f frontend/public/puppertino/puppertino.css ]; then + echo "No bundled Puppertino found. Attempting to fetch from GitHub..." + if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then + curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true + echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css" + else + echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it." + fi + else + echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css" + fi + # Ensure index.html includes Puppertino CSS and button classes + INDEX_HTML=frontend/index.html + if [ -f "$INDEX_HTML" ]; then + if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then + # Insert Puppertino link tag after style.css link + awk ' + /href="\/style.css"\/?/ && !x { print; print " "; x=1; next }1 + ' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML" + fi + # Replace default .btn with Puppertino primary button classes if present + sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true + fi + + + generate:bindings: + label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}}) + summary: Generates bindings for the frontend + deps: + - task: go:mod:tidy + sources: + - "**/*.[jt]s" + - exclude: frontend/**/* + - frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output + - "**/*.go" + - go.mod + - go.sum + generates: + - frontend/bindings/**/* + cmds: + - wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true -ts + + generate:icons: + summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms). + dir: build + sources: + - "appicon.png" + - "appicon.icon" + generates: + - "darwin/icons.icns" + - "windows/icon.ico" + cmds: + - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin + + generate:tray:icons: + summary: Rebuild Windows multi-res .ico files from the per-state PNGs. + desc: | + The colored tray PNGs (assets/netbird-systemtray-.png) and the + macOS template variants are committed to the repo as the canonical + source. This task only regenerates the Windows multi-resolution .ico + files from those PNGs by downscaling each to 16/24/32/48 px and + packing them with icotool, so Shell_NotifyIcon picks the frame + matching the user's DPI instead of downscaling a single large PNG. + + Run after replacing any of the colored PNGs (e.g. when copying a new + version of the icons from client/ui/assets). The SVG sources in + assets/svg/ are kept for reference but are not built by default. + dir: assets + sources: + - "netbird-systemtray-connected.png" + - "netbird-systemtray-disconnected.png" + - "netbird-systemtray-connecting.png" + - "netbird-systemtray-error.png" + - "netbird-systemtray-update-connected.png" + - "netbird-systemtray-update-disconnected.png" + generates: + - "netbird-systemtray-*.ico" + preconditions: + - sh: command -v magick >/dev/null 2>&1 || command -v convert >/dev/null 2>&1 + msg: "ImageMagick is required to downscale PNGs (apt install imagemagick)" + - sh: command -v icotool >/dev/null 2>&1 + msg: "icotool is required to pack tray .ico files (apt install icoutils)" + cmds: + - | + set -euo pipefail + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + resize=$(command -v magick || echo convert) + for state in connected disconnected connecting error update-connected update-disconnected; do + for sz in 16 24 32 48; do + "$resize" "netbird-systemtray-$state.png" -resize ${sz}x${sz} "$tmp/$state-$sz.png" + done + icotool -c -o "netbird-systemtray-$state.ico" \ + "$tmp/$state-16.png" "$tmp/$state-24.png" "$tmp/$state-32.png" "$tmp/$state-48.png" + done + + dev:frontend: + summary: Runs the frontend in development mode + dir: frontend + deps: + - task: install:frontend:deps + cmds: + - pnpm exec vite --port {{.VITE_PORT}} --strictPort + + update:build-assets: + summary: Updates the build assets + dir: build + cmds: + - wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir . + + build:server: + summary: Builds the application in server mode (no GUI, HTTP server only) + desc: | + Builds the application with the server build tag enabled. + Server mode runs as a pure HTTP server without native GUI dependencies. + Usage: task build:server + deps: + - task: build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + cmds: + - go build -tags server {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}} + vars: + BUILD_FLAGS: "{{.BUILD_FLAGS}}" + + run:server: + summary: Builds and runs the application in server mode + deps: + - task: build:server + cmds: + - ./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}} + + build:docker: + summary: Builds a Docker image for server mode deployment + desc: | + Creates a minimal Docker image containing the server mode binary. + The image is based on distroless for security and small size. + Usage: task build:docker [TAG=myapp:latest] + cmds: + - docker build -t {{.TAG | default (printf "%s:latest" .APP_NAME)}} -f build/docker/Dockerfile.server . + vars: + TAG: "{{.TAG}}" + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required. Please install Docker first." + - sh: test -f build/docker/Dockerfile.server + msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it." + + run:docker: + summary: Builds and runs the Docker image + desc: | + Builds the Docker image and runs it, exposing port 8080. + Usage: task run:docker [TAG=myapp:latest] [PORT=8080] + Note: The internal container port is always 8080. The PORT variable + only changes the host port mapping. Ensure your app uses port 8080 + or modify the Dockerfile to match your ServerOptions.Port setting. + deps: + - task: build:docker + vars: + TAG: + ref: .TAG + cmds: + - docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}} + vars: + TAG: "{{.TAG}}" + PORT: "{{.PORT}}" + + setup:docker: + summary: Builds Docker image for cross-compilation (~800MB download) + desc: | + Builds the Docker image needed for cross-compiling to any platform. + Run this once to enable cross-platform builds from any OS. + cmds: + - docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/ + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required. Please install Docker first." + + ios:device:list: + summary: Lists connected iOS devices (UDIDs) + cmds: + - xcrun xcdevice list + + ios:run:device: + summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl) + vars: + PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/.xcodeproj + SCHEME: '{{.SCHEME}}' # e.g., ios.dev + CONFIG: '{{.CONFIG | default "Debug"}}' + DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}' + UDID: '{{.UDID}}' # from `task ios:device:list` + BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev + TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing + preconditions: + - sh: xcrun -f xcodebuild + msg: "xcodebuild not found. Please install Xcode." + - sh: xcrun -f devicectl + msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)." + - sh: test -n '{{.PROJECT}}' + msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)." + - sh: test -n '{{.SCHEME}}' + msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)." + - sh: test -n '{{.UDID}}' + msg: "Set UDID to your device UDID (see: task ios:device:list)." + - sh: test -n '{{.BUNDLE_ID}}' + msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)." + cmds: + - | + set -euo pipefail + echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}" + XCB_ARGS=( + -project "{{.PROJECT}}" + -scheme "{{.SCHEME}}" + -configuration "{{.CONFIG}}" + -destination "id={{.UDID}}" + -derivedDataPath "{{.DERIVED}}" + -allowProvisioningUpdates + -allowProvisioningDeviceRegistration + ) + # Optionally inject signing identifiers if provided + if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi + if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi + xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true + # If xcpretty isn't installed, run without it + if [ "${PIPESTATUS[0]}" -ne 0 ]; then + xcodebuild "${XCB_ARGS[@]}" build + fi + # Find built .app + APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1) + if [ -z "$APP_PATH" ]; then + echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2 + exit 1 + fi + echo "Installing: $APP_PATH" + xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH" + echo "Launching: {{.BUNDLE_ID}}" + xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}" diff --git a/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg b/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg new file mode 100644 index 000000000..83c4c22a9 --- /dev/null +++ b/client/ui/build/appicon.icon/Assets/wails_icon_vector.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/client/ui/build/appicon.icon/icon.json b/client/ui/build/appicon.icon/icon.json new file mode 100644 index 000000000..4a0371af3 --- /dev/null +++ b/client/ui/build/appicon.icon/icon.json @@ -0,0 +1,26 @@ +{ + "fill" : { + "solid" : "srgb:1.00000,1.00000,1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "image-name" : "wails_icon_vector.svg", + "name" : "wails_icon_vector" + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "specular" : true + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} diff --git a/client/ui/build/appicon.png b/client/ui/build/appicon.png new file mode 100644 index 000000000..977d2400a Binary files /dev/null and b/client/ui/build/appicon.png differ diff --git a/client/ui/build/build-ui-linux.sh b/client/ui/build/build-ui-linux.sh deleted file mode 100644 index eab08214d..000000000 --- a/client/ui/build/build-ui-linux.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -sudo apt update -sudo apt remove gir1.2-appindicator3-0.1 -sudo apt install -y libayatana-appindicator3-dev -go build \ No newline at end of file diff --git a/client/ui/build/config.yml b/client/ui/build/config.yml new file mode 100644 index 000000000..08b95b6bd --- /dev/null +++ b/client/ui/build/config.yml @@ -0,0 +1,78 @@ +# This file contains the configuration for this project. +# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets. +# Note that this will overwrite any changes you have made to the assets. +version: '3' + +# This information is used to generate the build assets. +info: + companyName: "NetBird GmbH" # The name of the company + productName: "NetBird" # The name of the application + productIdentifier: "io.netbird.client" # The unique product identifier + description: "NetBird desktop client" # The application description + copyright: "NetBird GmbH" # Copyright text + comments: "Some Product Comments" # Comments + version: "0.0.1" # The application version + # cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional) + # # Should match the name of your .icon file without the extension + # # If not set and Assets.car exists, defaults to "appicon" + +# iOS build configuration (uncomment to customise iOS project generation) +# Note: Keys under `ios` OVERRIDE values under `info` when set. +# ios: +# # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier) +# bundleID: "com.mycompany.myproduct" +# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName) +# displayName: "My Product" +# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion) +# version: "0.0.1" +# # The company/organisation name for templates and project settings +# company: "My Company" +# # Additional comments to embed in Info.plist metadata +# comments: "Some Product Comments" + +# Dev mode configuration +dev_mode: + root_path: . + log_level: warn + debounce: 1000 + ignore: + dir: + - .git + - node_modules + - frontend + - bin + file: + - .DS_Store + - .gitignore + - .gitkeep + watched_extension: + - "*.go" + - "*.js" # Watch for changes to JS/TS files included using the //wails:include directive. + - "*.ts" # The frontend directory will be excluded entirely by the setting above. + git_ignore: true + executes: + - cmd: wails3 build DEV=true + type: blocking + - cmd: wails3 task common:dev:frontend + type: background + - cmd: wails3 task run + type: primary + +# File Associations +# More information at: https://v3.wails.io/noit/done/yet +fileAssociations: +# - ext: wails +# name: Wails +# description: Wails Application File +# iconName: wailsFileIcon +# role: Editor +# - ext: jpg +# name: JPEG +# description: Image File +# iconName: jpegFileIcon +# role: Editor +# mimeType: image/jpeg # (optional) + +# Other data +other: + - name: My Other Data \ No newline at end of file diff --git a/client/ui/build/darwin/Info.dev.plist b/client/ui/build/darwin/Info.dev.plist new file mode 100644 index 000000000..78f5a7b1c --- /dev/null +++ b/client/ui/build/darwin/Info.dev.plist @@ -0,0 +1,38 @@ + + + + CFBundlePackageType + APPL + CFBundleName + NetBird + CFBundleDisplayName + NetBird + CFBundleExecutable + netbird-ui + CFBundleIdentifier + io.netbird.client + CFBundleVersion + 0.0.1 + CFBundleGetInfoString + This is a comment + CFBundleShortVersionString + 0.0.1 + CFBundleIconFile + icons + CFBundleIconName + appicon + LSMinimumSystemVersion + 10.15.0 + NSHighResolutionCapable + true + LSUIElement + 1 + NSHumanReadableCopyright + NetBird GmbH + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + \ No newline at end of file diff --git a/client/ui/build/darwin/Info.plist b/client/ui/build/darwin/Info.plist new file mode 100644 index 000000000..1e12b049b --- /dev/null +++ b/client/ui/build/darwin/Info.plist @@ -0,0 +1,36 @@ + + + + CFBundlePackageType + APPL + CFBundleName + NetBird + CFBundleDisplayName + NetBird + CFBundleExecutable + netbird-ui + CFBundleIdentifier + io.netbird.client + CFBundleVersion + 0.0.1 + CFBundleGetInfoString + This is a comment + CFBundleShortVersionString + 0.0.1 + CFBundleIconFile + icons + CFBundleIconName + appicon + LSMinimumSystemVersion + 10.15.0 + NSHighResolutionCapable + true + + LSUIElement + 1 + NSHumanReadableCopyright + NetBird GmbH + + \ No newline at end of file diff --git a/client/ui/build/darwin/Taskfile.yml b/client/ui/build/darwin/Taskfile.yml new file mode 100644 index 000000000..8a5c27bdc --- /dev/null +++ b/client/ui/build/darwin/Taskfile.yml @@ -0,0 +1,210 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +vars: + # Signing configuration - edit these values for your project + # SIGN_IDENTITY: "Developer ID Application: Your Company (TEAMID)" + # KEYCHAIN_PROFILE: "my-notarize-profile" + # ENTITLEMENTS: "build/darwin/entitlements.plist" + + # Docker image for cross-compilation (used when building on non-macOS) + CROSS_IMAGE: wails-cross + +tasks: + build: + summary: Builds the application + cmds: + - task: '{{if eq OS "darwin"}}build:native{{else}}build:docker{{end}}' + vars: + ARCH: '{{.ARCH}}' + DEV: '{{.DEV}}' + OUTPUT: '{{.OUTPUT}}' + EXTRA_TAGS: '{{.EXTRA_TAGS}}' + vars: + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + + build:native: + summary: Builds the application natively on macOS + internal: true + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + DEV: + ref: .DEV + - task: common:generate:icons + cmds: + - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}} + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}' + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + env: + GOOS: darwin + CGO_ENABLED: 1 + GOARCH: '{{.ARCH | default ARCH}}' + CGO_CFLAGS: "-mmacosx-version-min=10.15" + CGO_LDFLAGS: "-mmacosx-version-min=10.15" + MACOSX_DEPLOYMENT_TARGET: "10.15" + + build:docker: + summary: Cross-compiles for macOS using Docker (for Linux/Windows hosts) + internal: true + deps: + - task: common:build:frontend + - task: common:generate:icons + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required for cross-compilation. Please install Docker." + - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1 + msg: | + Docker image '{{.CROSS_IMAGE}}' not found. + Build it first: wails3 task setup:docker + cmds: + - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{.CROSS_IMAGE}} darwin {{.DOCKER_ARCH}} + - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin + - mkdir -p {{.BIN_DIR}} + - mv "bin/{{.APP_NAME}}-darwin-{{.DOCKER_ARCH}}" "{{.OUTPUT}}" + vars: + DOCKER_ARCH: '{{if eq .ARCH "arm64"}}arm64{{else if eq .ARCH "amd64"}}amd64{{else}}arm64{{end}}' + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + # Mount Go module cache for faster builds + GO_CACHE_MOUNT: + sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"' + # Extract replace directives from go.mod and create -v mounts for each + # Handles both relative (=> ../) and absolute (=> /) paths + REPLACE_MOUNTS: + sh: | + grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do + path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r') + # Convert relative paths to absolute + if [ "${path#/}" = "$path" ]; then + path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")" + fi + # Only mount if directory exists + if [ -d "$path" ]; then + echo "-v $path:$path:ro" + fi + done | tr '\n' ' ' + + build:universal: + summary: Builds darwin universal binary (arm64 + amd64) + deps: + - task: build + vars: + ARCH: amd64 + OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" + - task: build + vars: + ARCH: arm64 + OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + cmds: + - task: '{{if eq OS "darwin"}}build:universal:lipo:native{{else}}build:universal:lipo:go{{end}}' + + build:universal:lipo:native: + summary: Creates universal binary using native lipo (macOS) + internal: true + cmds: + - lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + - rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + + build:universal:lipo:go: + summary: Creates universal binary using wails3 tool lipo (Linux/Windows) + internal: true + cmds: + - wails3 tool lipo -output "{{.BIN_DIR}}/{{.APP_NAME}}" -input "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" -input "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + - rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + + package: + summary: Packages the application into a `.app` bundle + deps: + - task: build + cmds: + - task: create:app:bundle + + package:universal: + summary: Packages darwin universal binary (arm64 + amd64) + deps: + - task: build:universal + cmds: + - task: create:app:bundle + + + create:app:bundle: + summary: Creates an `.app` bundle + cmds: + - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS" + - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources" + - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources" + - | + if [ -f build/darwin/Assets.car ]; then + cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources" + fi + - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS" + - cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents" + - task: '{{if eq OS "darwin"}}codesign:adhoc{{else}}codesign:skip{{end}}' + + codesign:adhoc: + summary: Ad-hoc signs the app bundle (macOS only) + internal: true + cmds: + - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app" + + codesign:skip: + summary: Skips codesigning when cross-compiling + internal: true + cmds: + - 'echo "Skipping codesign (not available on {{OS}}). Sign the .app on macOS before distribution."' + + run: + deps: + - task: common:generate:icons + cmds: + - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS" + - mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources" + - cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources" + - | + if [ -f build/darwin/Assets.car ]; then + cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources" + fi + - cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS" + - cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist" + - codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app" + - '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}' + + sign: + summary: Signs the application bundle with Developer ID + desc: | + Signs the .app bundle for distribution. + Configure SIGN_IDENTITY in the vars section at the top of this file. + deps: + - task: package + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}} + preconditions: + - sh: '[ -n "{{.SIGN_IDENTITY}}" ]' + msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml" + + sign:notarize: + summary: Signs and notarizes the application bundle + desc: | + Signs the .app bundle and submits it for notarization. + Configure SIGN_IDENTITY and KEYCHAIN_PROFILE in the vars section at the top of this file. + + Setup (one-time): + wails3 signing credentials --apple-id "you@email.com" --team-id "TEAMID" --password "app-specific-password" --profile "my-profile" + deps: + - task: package + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}} --notarize --keychain-profile {{.KEYCHAIN_PROFILE}} + preconditions: + - sh: '[ -n "{{.SIGN_IDENTITY}}" ]' + msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml" + - sh: '[ -n "{{.KEYCHAIN_PROFILE}}" ]' + msg: "KEYCHAIN_PROFILE is required. Set it in the vars section at the top of build/darwin/Taskfile.yml" diff --git a/client/ui/build/darwin/icons.icns b/client/ui/build/darwin/icons.icns new file mode 100644 index 000000000..fb78a18a9 Binary files /dev/null and b/client/ui/build/darwin/icons.icns differ diff --git a/client/ui/build/docker/Dockerfile.cross b/client/ui/build/docker/Dockerfile.cross new file mode 100644 index 000000000..a487b8db0 --- /dev/null +++ b/client/ui/build/docker/Dockerfile.cross @@ -0,0 +1,203 @@ +# Cross-compile Wails v3 apps to any platform +# +# Darwin: Zig + macOS SDK +# Linux: Native GCC when host matches target, Zig for cross-arch +# Windows: Zig + bundled mingw +# +# Usage: +# docker build -t wails-cross -f Dockerfile.cross . +# docker run --rm -v $(pwd):/app wails-cross darwin arm64 +# docker run --rm -v $(pwd):/app wails-cross darwin amd64 +# docker run --rm -v $(pwd):/app wails-cross linux amd64 +# docker run --rm -v $(pwd):/app wails-cross linux arm64 +# docker run --rm -v $(pwd):/app wails-cross windows amd64 +# docker run --rm -v $(pwd):/app wails-cross windows arm64 + +FROM golang:1.25-bookworm + +ARG TARGETARCH + +# Install base tools, GCC, and GTK/WebKit dev packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl xz-utils nodejs npm pkg-config gcc libc6-dev \ + libgtk-3-dev libwebkit2gtk-4.1-dev \ + libgtk-4-dev libwebkitgtk-6.0-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Zig - automatically selects correct binary for host architecture +ARG ZIG_VERSION=0.14.0 +RUN ZIG_ARCH=$(case "${TARGETARCH}" in arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \ + curl -L "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" \ + | tar -xJ -C /opt \ + && ln -s /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig + +# Download macOS SDK (required for darwin targets) +ARG MACOS_SDK_VERSION=14.5 +RUN curl -L "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_SDK_VERSION}/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz" \ + | tar -xJ -C /opt \ + && mv /opt/MacOSX${MACOS_SDK_VERSION}.sdk /opt/macos-sdk + +ENV MACOS_SDK_PATH=/opt/macos-sdk + +# Create Zig CC wrappers for cross-compilation targets +# Darwin and Windows use Zig; Linux uses native GCC (run with --platform for cross-arch) + +# Darwin arm64 +COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-arm64 +#!/bin/sh +ARGS="" +SKIP_NEXT=0 +for arg in "$@"; do + if [ $SKIP_NEXT -eq 1 ]; then + SKIP_NEXT=0 + continue + fi + case "$arg" in + -target) SKIP_NEXT=1 ;; + -mmacosx-version-min=*) ;; + *) ARGS="$ARGS $arg" ;; + esac +done +exec zig cc -fno-sanitize=all -target aarch64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS +ZIGWRAP +RUN chmod +x /usr/local/bin/zcc-darwin-arm64 + +# Darwin amd64 +COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-amd64 +#!/bin/sh +ARGS="" +SKIP_NEXT=0 +for arg in "$@"; do + if [ $SKIP_NEXT -eq 1 ]; then + SKIP_NEXT=0 + continue + fi + case "$arg" in + -target) SKIP_NEXT=1 ;; + -mmacosx-version-min=*) ;; + *) ARGS="$ARGS $arg" ;; + esac +done +exec zig cc -fno-sanitize=all -target x86_64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS +ZIGWRAP +RUN chmod +x /usr/local/bin/zcc-darwin-amd64 + +# Windows amd64 - uses Zig's bundled mingw +COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-amd64 +#!/bin/sh +ARGS="" +SKIP_NEXT=0 +for arg in "$@"; do + if [ $SKIP_NEXT -eq 1 ]; then + SKIP_NEXT=0 + continue + fi + case "$arg" in + -target) SKIP_NEXT=1 ;; + -Wl,*) ;; + *) ARGS="$ARGS $arg" ;; + esac +done +exec zig cc -target x86_64-windows-gnu $ARGS +ZIGWRAP +RUN chmod +x /usr/local/bin/zcc-windows-amd64 + +# Windows arm64 - uses Zig's bundled mingw +COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-arm64 +#!/bin/sh +ARGS="" +SKIP_NEXT=0 +for arg in "$@"; do + if [ $SKIP_NEXT -eq 1 ]; then + SKIP_NEXT=0 + continue + fi + case "$arg" in + -target) SKIP_NEXT=1 ;; + -Wl,*) ;; + *) ARGS="$ARGS $arg" ;; + esac +done +exec zig cc -target aarch64-windows-gnu $ARGS +ZIGWRAP +RUN chmod +x /usr/local/bin/zcc-windows-arm64 + +# Build script +COPY <<'SCRIPT' /usr/local/bin/build.sh +#!/bin/sh +set -e + +OS=${1:-darwin} +ARCH=${2:-arm64} + +case "${OS}-${ARCH}" in + darwin-arm64|darwin-aarch64) + export CC=zcc-darwin-arm64 + export GOARCH=arm64 + export GOOS=darwin + ;; + darwin-amd64|darwin-x86_64) + export CC=zcc-darwin-amd64 + export GOARCH=amd64 + export GOOS=darwin + ;; + linux-arm64|linux-aarch64) + export CC=gcc + export GOARCH=arm64 + export GOOS=linux + ;; + linux-amd64|linux-x86_64) + export CC=gcc + export GOARCH=amd64 + export GOOS=linux + ;; + windows-arm64|windows-aarch64) + export CC=zcc-windows-arm64 + export GOARCH=arm64 + export GOOS=windows + ;; + windows-amd64|windows-x86_64) + export CC=zcc-windows-amd64 + export GOARCH=amd64 + export GOOS=windows + ;; + *) + echo "Usage: " + echo " os: darwin, linux, windows" + echo " arch: amd64, arm64" + exit 1 + ;; +esac + +export CGO_ENABLED=1 +export CGO_CFLAGS="-w" + +# Build frontend if exists and not already built (host may have built it) +if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then + (cd frontend && npm install --silent && npm run build --silent) +fi + +# Build +APP=${APP_NAME:-$(basename $(pwd))} +mkdir -p bin + +EXT="" +LDFLAGS="-s -w" +if [ "$GOOS" = "windows" ]; then + EXT=".exe" + LDFLAGS="-s -w -H windowsgui" +fi + +TAGS="production" +if [ -n "$EXTRA_TAGS" ]; then + TAGS="${TAGS},${EXTRA_TAGS}" +fi + +go build -tags "$TAGS" -trimpath -ldflags="$LDFLAGS" -o bin/${APP}-${GOOS}-${GOARCH}${EXT} . +echo "Built: bin/${APP}-${GOOS}-${GOARCH}${EXT}" +SCRIPT +RUN chmod +x /usr/local/bin/build.sh + +WORKDIR /app +ENTRYPOINT ["/usr/local/bin/build.sh"] +CMD ["darwin", "arm64"] diff --git a/client/ui/build/docker/Dockerfile.server b/client/ui/build/docker/Dockerfile.server new file mode 100644 index 000000000..58fb64f76 --- /dev/null +++ b/client/ui/build/docker/Dockerfile.server @@ -0,0 +1,41 @@ +# Wails Server Mode Dockerfile +# Multi-stage build for minimal image size + +# Build stage +FROM golang:alpine AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git + +# Copy source code +COPY . . + +# Remove local replace directive if present (for production builds) +RUN sed -i '/^replace/d' go.mod || true + +# Download dependencies +RUN go mod tidy + +# Build the server binary +RUN go build -tags server -ldflags="-s -w" -o server . + +# Runtime stage - minimal image +FROM gcr.io/distroless/static-debian12 + +# Copy the binary +COPY --from=builder /app/server /server + +# Copy frontend assets +COPY --from=builder /app/frontend/dist /frontend/dist + +# Expose the default port +EXPOSE 8080 + +# Bind to all interfaces (required for Docker) +# Can be overridden at runtime with -e WAILS_SERVER_HOST=... +ENV WAILS_SERVER_HOST=0.0.0.0 + +# Run the server +ENTRYPOINT ["/server"] diff --git a/client/ui/build/linux/Taskfile.yml b/client/ui/build/linux/Taskfile.yml new file mode 100644 index 000000000..94d041375 --- /dev/null +++ b/client/ui/build/linux/Taskfile.yml @@ -0,0 +1,235 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +vars: + # Signing configuration - edit these values for your project + # PGP_KEY: "path/to/signing-key.asc" + # SIGN_ROLE: "builder" # Options: origin, maint, archive, builder + # + # Password is stored securely in system keychain. Run: wails3 setup signing + + # Docker image for cross-compilation (used when building on non-Linux or no CC available) + CROSS_IMAGE: wails-cross + +tasks: + build: + summary: Builds the application for Linux + cmds: + # Linux requires CGO - use Docker when: + # 1. Cross-compiling from non-Linux, OR + # 2. No C compiler is available, OR + # 3. Target architecture differs from host architecture (cross-arch compilation) + - task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}' + vars: + ARCH: '{{.ARCH}}' + DEV: '{{.DEV}}' + OUTPUT: '{{.OUTPUT}}' + EXTRA_TAGS: '{{.EXTRA_TAGS}}' + vars: + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + # Determine target architecture (defaults to host ARCH if not specified) + TARGET_ARCH: '{{.ARCH | default ARCH}}' + # Check if a C compiler is available (gcc or clang) + HAS_CC: + sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"' + + build:native: + summary: Builds the application natively on Linux + internal: true + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + DEV: + ref: .DEV + - task: common:generate:icons + - task: generate:dotdesktop + cmds: + - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}} + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}' + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + env: + GOOS: linux + CGO_ENABLED: 1 + GOARCH: '{{.ARCH | default ARCH}}' + + build:docker: + summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available) + internal: true + deps: + - task: common:build:frontend + - task: common:generate:icons + - task: generate:dotdesktop + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required for cross-compilation to Linux. Please install Docker." + - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1 + msg: | + Docker image '{{.CROSS_IMAGE}}' not found. + Build it first: wails3 task setup:docker + cmds: + - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}} + - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin + - mkdir -p {{.BIN_DIR}} + - mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}" + vars: + DOCKER_ARCH: '{{.ARCH | default "amd64"}}' + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + # Mount Go module cache for faster builds + GO_CACHE_MOUNT: + sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"' + # Extract replace directives from go.mod and create -v mounts for each + REPLACE_MOUNTS: + sh: | + grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do + path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r') + # Convert relative paths to absolute + if [ "${path#/}" = "$path" ]; then + path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")" + fi + # Only mount if directory exists + if [ -d "$path" ]; then + echo "-v $path:$path:ro" + fi + done | tr '\n' ' ' + + package: + summary: Packages the application for Linux + deps: + - task: build + cmds: + - task: create:appimage + - task: create:deb + - task: create:rpm + - task: create:aur + + create:appimage: + summary: Creates an AppImage + dir: build/linux/appimage + deps: + - task: build + - task: generate:dotdesktop + cmds: + - cp "{{.APP_BINARY}}" "{{.APP_NAME}}" + - cp ../../appicon.png "{{.APP_NAME}}.png" + - wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build + vars: + APP_NAME: '{{.APP_NAME}}' + APP_BINARY: '../../../bin/{{.APP_NAME}}' + ICON: '{{.APP_NAME}}.png' + DESKTOP_FILE: '../{{.APP_NAME}}.desktop' + OUTPUT_DIR: '../../../bin' + + create:deb: + summary: Creates a deb package + deps: + - task: build + cmds: + - task: generate:dotdesktop + - task: generate:deb + + create:rpm: + summary: Creates a rpm package + deps: + - task: build + cmds: + - task: generate:dotdesktop + - task: generate:rpm + + create:aur: + summary: Creates a arch linux packager package + deps: + - task: build + cmds: + - task: generate:dotdesktop + - task: generate:aur + + generate:deb: + summary: Creates a deb package + cmds: + - wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin + + generate:rpm: + summary: Creates a rpm package + cmds: + - wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin + + generate:aur: + summary: Creates a arch linux packager package + cmds: + - wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin + + generate:dotdesktop: + summary: Generates a `.desktop` file + dir: build + cmds: + - mkdir -p {{.ROOT_DIR}}/build/linux/appimage + - wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}" + # Wrap Exec= with `env WEBKIT_DISABLE_DMABUF_RENDERER=1 ...` so launches + # from any desktop environment use the working renderer. See build/linux/Taskfile.yml :run for the matching dev-mode env block. + - sed -i -E 's|^Exec=([^ ]+)(.*)$|Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 \1\2|' {{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop + vars: + APP_NAME: '{{.APP_NAME}}' + EXEC: '{{.APP_NAME}}' + ICON: '{{.APP_NAME}}' + CATEGORIES: 'Development;' + OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop' + + run: + cmds: + - '{{.BIN_DIR}}/{{.APP_NAME}}' + env: + # WebKitGTK 2.50's default DMA-BUF renderer fails on RDP, VirtualBox/QEMU, + # and some bare WMs (Fluxbox, dwm) where DRM dumb-buffer access is + # restricted. Disabling it falls back to the GLES2/cairo path which works + # everywhere. Production launchers must set this too. + WEBKIT_DISABLE_DMABUF_RENDERER: "1" + + sign:deb: + summary: Signs the DEB package + desc: | + Signs the .deb package with a PGP key. + Configure PGP_KEY in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: create:deb + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" --pgp-key {{.PGP_KEY}} {{if .SIGN_ROLE}}--role {{.SIGN_ROLE}}{{end}} + preconditions: + - sh: '[ -n "{{.PGP_KEY}}" ]' + msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml" + + sign:rpm: + summary: Signs the RPM package + desc: | + Signs the .rpm package with a PGP key. + Configure PGP_KEY in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: create:rpm + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" --pgp-key {{.PGP_KEY}} + preconditions: + - sh: '[ -n "{{.PGP_KEY}}" ]' + msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml" + + sign:packages: + summary: Signs all Linux packages (DEB and RPM) + desc: | + Signs both .deb and .rpm packages with a PGP key. + Configure PGP_KEY in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + cmds: + - task: sign:deb + - task: sign:rpm + preconditions: + - sh: '[ -n "{{.PGP_KEY}}" ]' + msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml" diff --git a/client/ui/build/linux/appimage/build.sh b/client/ui/build/linux/appimage/build.sh new file mode 100644 index 000000000..85901c34e --- /dev/null +++ b/client/ui/build/linux/appimage/build.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Copyright (c) 2018-Present Lea Anthony +# SPDX-License-Identifier: MIT + +# Fail script on any error +set -euxo pipefail + +# Define variables +APP_DIR="${APP_NAME}.AppDir" + +# Create AppDir structure +mkdir -p "${APP_DIR}/usr/bin" +cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/" +cp "${ICON_PATH}" "${APP_DIR}/" +cp "${DESKTOP_FILE}" "${APP_DIR}/" + +if [[ $(uname -m) == *x86_64* ]]; then + # Download linuxdeploy and make it executable + wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage + chmod +x linuxdeploy-x86_64.AppImage + + # Run linuxdeploy to bundle the application + ./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage +else + # Download linuxdeploy and make it executable (arm64) + wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage + chmod +x linuxdeploy-aarch64.AppImage + + # Run linuxdeploy to bundle the application (arm64) + ./linuxdeploy-aarch64.AppImage --appdir "${APP_DIR}" --output appimage +fi + +# Rename the generated AppImage +mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage" + diff --git a/client/ui/build/linux/desktop b/client/ui/build/linux/desktop new file mode 100644 index 000000000..deadfe9f4 --- /dev/null +++ b/client/ui/build/linux/desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Version=1.0 +Name=NetBird +Comment=NetBird desktop client +# The Exec line includes %u to pass the URL to the application +Exec=/usr/local/bin/netbird-ui %u +Terminal=false +Type=Application +Icon=netbird-ui +Categories=Utility; +StartupWMClass=netbird-ui + + diff --git a/client/ui/build/linux/netbird-ui.desktop b/client/ui/build/linux/netbird-ui.desktop new file mode 100755 index 000000000..6b6ed42a5 --- /dev/null +++ b/client/ui/build/linux/netbird-ui.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=netbird-ui +Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui +Icon=netbird-ui +Categories=Development; +Terminal=false +Keywords=wails +Version=1.0 +StartupNotify=false diff --git a/client/ui/build/netbird.desktop b/client/ui/build/linux/netbird.desktop similarity index 54% rename from client/ui/build/netbird.desktop rename to client/ui/build/linux/netbird.desktop index b3a1b92dc..a81f3698a 100644 --- a/client/ui/build/netbird.desktop +++ b/client/ui/build/linux/netbird.desktop @@ -1,8 +1,9 @@ [Desktop Entry] Name=Netbird -Exec=/usr/bin/netbird-ui +Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui Icon=netbird Type=Application Terminal=false Categories=Utility; Keywords=netbird; +StartupWMClass=org.wails.netbird \ No newline at end of file diff --git a/client/ui/build/linux/nfpm/nfpm.yaml b/client/ui/build/linux/nfpm/nfpm.yaml new file mode 100644 index 000000000..764855a63 --- /dev/null +++ b/client/ui/build/linux/nfpm/nfpm.yaml @@ -0,0 +1,70 @@ +# Feel free to remove those if you don't want/need to use them. +# Make sure to check the documentation at https://nfpm.goreleaser.com +# +# The lines below are called `modelines`. See `:help modeline` + +name: "netbird-ui" +arch: ${GOARCH} +platform: "linux" +version: "0.0.1" +section: "default" +priority: "extra" +maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> +description: "NetBird desktop client" +vendor: "NetBird" +homepage: "https://wails.io" +license: "MIT" +release: "1" + +contents: + - src: "./bin/netbird-ui" + dst: "/usr/local/bin/netbird-ui" + - src: "./build/appicon.png" + dst: "/usr/share/icons/hicolor/128x128/apps/netbird-ui.png" + - src: "./build/linux/netbird-ui.desktop" + dst: "/usr/share/applications/netbird-ui.desktop" + +# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+) +depends: + - libgtk-4-1 (>= 4.14) + - libwebkitgtk-6.0-4 + - xdg-utils + +# Distribution-specific overrides for different package formats +overrides: + # RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux / openSUSE + rpm: + depends: + - (gtk4 >= 4.14 or libgtk-4-1 >= 4.14) + - (webkitgtk6.0 or libwebkitgtk-6_0-4) + - xdg-utils + + # Arch Linux packages + archlinux: + depends: + - gtk4 + - webkitgtk-6.0 + - xdg-utils + +# scripts section to ensure desktop database is updated after install +scripts: + postinstall: "./build/linux/nfpm/scripts/postinstall.sh" + # You can also add preremove, postremove if needed + # preremove: "./build/linux/nfpm/scripts/preremove.sh" + # postremove: "./build/linux/nfpm/scripts/postremove.sh" + +# replaces: +# - foobar +# provides: +# - bar +# depends: +# - gtk3 +# - libwebkit2gtk +# recommends: +# - whatever +# suggests: +# - something-else +# conflicts: +# - not-foo +# - not-bar +# changelog: "changelog.yaml" diff --git a/client/ui/build/linux/nfpm/scripts/postinstall.sh b/client/ui/build/linux/nfpm/scripts/postinstall.sh new file mode 100644 index 000000000..4bbb815a3 --- /dev/null +++ b/client/ui/build/linux/nfpm/scripts/postinstall.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +# Update desktop database for .desktop file changes +# This makes the application appear in application menus and registers its capabilities. +if command -v update-desktop-database >/dev/null 2>&1; then + echo "Updating desktop database..." + update-desktop-database -q /usr/share/applications +else + echo "Warning: update-desktop-database command not found. Desktop file may not be immediately recognized." >&2 +fi + +# Update MIME database for custom URL schemes (x-scheme-handler) +# This ensures the system knows how to handle your custom protocols. +if command -v update-mime-database >/dev/null 2>&1; then + echo "Updating MIME database..." + update-mime-database -n /usr/share/mime +else + echo "Warning: update-mime-database command not found. Custom URL schemes may not be immediately recognized." >&2 +fi + +exit 0 diff --git a/client/ui/build/linux/nfpm/scripts/postremove.sh b/client/ui/build/linux/nfpm/scripts/postremove.sh new file mode 100644 index 000000000..a9bf588e2 --- /dev/null +++ b/client/ui/build/linux/nfpm/scripts/postremove.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/client/ui/build/linux/nfpm/scripts/preinstall.sh b/client/ui/build/linux/nfpm/scripts/preinstall.sh new file mode 100644 index 000000000..a9bf588e2 --- /dev/null +++ b/client/ui/build/linux/nfpm/scripts/preinstall.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/client/ui/build/linux/nfpm/scripts/preremove.sh b/client/ui/build/linux/nfpm/scripts/preremove.sh new file mode 100644 index 000000000..a9bf588e2 --- /dev/null +++ b/client/ui/build/linux/nfpm/scripts/preremove.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/client/ui/build/windows/Taskfile.yml b/client/ui/build/windows/Taskfile.yml new file mode 100644 index 000000000..f51f7fbee --- /dev/null +++ b/client/ui/build/windows/Taskfile.yml @@ -0,0 +1,243 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +vars: + # Signing configuration - edit these values for your project + # SIGN_CERTIFICATE: "path/to/certificate.pfx" + # SIGN_THUMBPRINT: "certificate-thumbprint" # Alternative to SIGN_CERTIFICATE + # TIMESTAMP_SERVER: "http://timestamp.digicert.com" + # + # Password is stored securely in system keychain. Run: wails3 setup signing + + # Docker image for cross-compilation with CGO (used when CGO_ENABLED=1 on non-Windows) + CROSS_IMAGE: wails-cross + +tasks: + build: + summary: Builds the application for Windows + cmds: + # CGO Windows builds from Linux use mingw-w64 (lighter than docker). + # Docker is only needed if mingw-w64 is unavailable. + - task: build:native + vars: + ARCH: '{{.ARCH}}' + DEV: '{{.DEV}}' + EXTRA_TAGS: '{{.EXTRA_TAGS}}' + vars: + CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}' + + build:console: + summary: Builds a console-attached Windows binary so logs go to the terminal. + desc: | + Same as `windows:build` but links against the console PE subsystem + instead of windowsgui, so stdout/stderr (logrus, panics) print to the + terminal that launched the .exe. Useful for chasing tray, event-stream, + or daemon-RPC bugs that have no other feedback channel on Windows. + + Output is bin/netbird-ui-console.exe — kept distinct so the production + binary built by `windows:build` isn't shadowed. + + Cross-compile from Linux works the same way: + CGO_ENABLED=1 task windows:build:console + + Pass DEV=true to drop the `production` build tag so the WebKit/WebView2 + DevTools inspector (right-click → Inspect, or F12) stays enabled and the + frontend JS console is reachable — same DEV handling as windows:build: + CGO_ENABLED=1 task windows:build:console DEV=true + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + DEV: + ref: .DEV + - task: common:generate:icons + preconditions: + - sh: '[ "{{OS}}" = "windows" ] || [ "{{.CGO_ENABLED}}" != "1" ] || command -v {{.CC}}' + msg: "{{.CC}} not found. Install with: sudo apt-get install gcc-mingw-w64-x86-64 (Debian/Ubuntu) / sudo dnf install mingw64-gcc (Fedora)" + cmds: + - task: generate:syso + - go build {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}-console.exe" + - cmd: powershell Remove-item *.syso + platforms: [windows] + - cmd: rm -f *.syso + platforms: [linux, darwin] + vars: + # Identical to build:native's flags (including DEV handling) except no + # -H windowsgui, so the binary attaches to the launching console. With + # DEV=true the `production` tag is dropped, keeping the WebKit/WebView2 + # DevTools inspector enabled so the frontend JS console is reachable. + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s"{{end}}' + CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}' + CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}' + env: + GOOS: windows + CGO_ENABLED: '{{.CGO_ENABLED}}' + GOARCH: '{{.ARCH | default ARCH}}' + CC: '{{.CC}}' + + build:native: + summary: Builds for Windows natively, or cross-compiles from Linux/macOS via mingw-w64. + internal: true + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + DEV: + ref: .DEV + - task: common:generate:icons + preconditions: + # When cross-compiling with CGO from a non-Windows host, the mingw-w64 + # cross-gcc must be present. Native Windows builds skip this check. + - sh: '[ "{{OS}}" = "windows" ] || [ "{{.CGO_ENABLED}}" != "1" ] || command -v {{.CC}}' + msg: "{{.CC}} not found. Install with: sudo apt-get install gcc-mingw-w64-x86-64 (Debian/Ubuntu) / sudo dnf install mingw64-gcc (Fedora)" + cmds: + - task: generate:syso + - go build {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}.exe" + - cmd: powershell Remove-item *.syso + platforms: [windows] + - cmd: rm -f *.syso + platforms: [linux, darwin] + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -ldflags="-w -s -H windowsgui"{{end}}' + CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}' + CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}' + env: + GOOS: windows + CGO_ENABLED: '{{.CGO_ENABLED}}' + GOARCH: '{{.ARCH | default ARCH}}' + CC: '{{.CC}}' + + build:docker: + summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows) + internal: true + deps: + - task: common:build:frontend + - task: common:generate:icons + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required for CGO cross-compilation. Please install Docker." + - sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1 + msg: | + Docker image '{{.CROSS_IMAGE}}' not found. + Build it first: wails3 task setup:docker + cmds: + - task: generate:syso + - docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{.CROSS_IMAGE}} windows {{.DOCKER_ARCH}} + - docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin + - rm -f *.syso + vars: + DOCKER_ARCH: '{{.ARCH | default "amd64"}}' + # Mount Go module cache for faster builds + GO_CACHE_MOUNT: + sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"' + # Extract replace directives from go.mod and create -v mounts for each + REPLACE_MOUNTS: + sh: | + grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do + path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r') + # Convert relative paths to absolute + if [ "${path#/}" = "$path" ]; then + path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")" + fi + # Only mount if directory exists + if [ -d "$path" ]; then + echo "-v $path:$path:ro" + fi + done | tr '\n' ' ' + + package: + summary: Packages the application + cmds: + - task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}' + vars: + FORMAT: '{{.FORMAT | default "nsis"}}' + + generate:syso: + summary: Generates Windows `.syso` file + dir: build + cmds: + - wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso + vars: + ARCH: '{{.ARCH | default ARCH}}' + + create:nsis:installer: + summary: Creates an NSIS installer + dir: build/windows/nsis + deps: + - task: build + cmds: + # Create the Microsoft WebView2 bootstrapper if it doesn't exist + - wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis" + - | + {{if eq OS "windows"}} + makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi + {{else}} + makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi + {{end}} + vars: + ARCH: '{{.ARCH | default ARCH}}' + ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}' + + create:msix:package: + summary: Creates an MSIX package + deps: + - task: build + cmds: + - |- + wails3 tool msix \ + --config "{{.ROOT_DIR}}/wails.json" \ + --name "{{.APP_NAME}}" \ + --executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \ + --arch "{{.ARCH}}" \ + --out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \ + {{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \ + {{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \ + {{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}} + vars: + ARCH: '{{.ARCH | default ARCH}}' + CERT_PATH: '{{.CERT_PATH | default ""}}' + PUBLISHER: '{{.PUBLISHER | default ""}}' + USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}' + + install:msix:tools: + summary: Installs tools required for MSIX packaging + cmds: + - wails3 tool msix-install-tools + + run: + cmds: + - '{{.BIN_DIR}}/{{.APP_NAME}}.exe' + + sign: + summary: Signs the Windows executable + desc: | + Signs the .exe with an Authenticode certificate. + Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: build + cmds: + - wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.exe" {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}} + preconditions: + - sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]' + msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml" + + sign:installer: + summary: Signs the NSIS installer + desc: | + Creates and signs the NSIS installer. + Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file. + Password is retrieved from system keychain (run: wails3 setup signing) + deps: + - task: create:nsis:installer + cmds: + - wails3 tool sign --input "build/windows/nsis/{{.APP_NAME}}-installer.exe" {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}} + preconditions: + - sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]' + msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml" diff --git a/client/ui/build/windows/icon.ico b/client/ui/build/windows/icon.ico new file mode 100644 index 000000000..7abbfa5a3 Binary files /dev/null and b/client/ui/build/windows/icon.ico differ diff --git a/client/ui/build/windows/info.json b/client/ui/build/windows/info.json new file mode 100644 index 000000000..a67c8fd81 --- /dev/null +++ b/client/ui/build/windows/info.json @@ -0,0 +1,15 @@ +{ + "fixed": { + "file_version": "0.0.1" + }, + "info": { + "0000": { + "ProductVersion": "0.0.1", + "CompanyName": "NetBird", + "FileDescription": "NetBird desktop client", + "LegalCopyright": "NetBird GmbH", + "ProductName": "NetBird", + "Comments": "This is a comment" + } + } +} \ No newline at end of file diff --git a/client/ui/build/windows/msix/app_manifest.xml b/client/ui/build/windows/msix/app_manifest.xml new file mode 100644 index 000000000..0ae55ce77 --- /dev/null +++ b/client/ui/build/windows/msix/app_manifest.xml @@ -0,0 +1,55 @@ + + + + + + + NetBird + NetBird + NetBird desktop client + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/ui/build/windows/msix/template.xml b/client/ui/build/windows/msix/template.xml new file mode 100644 index 000000000..437a68097 --- /dev/null +++ b/client/ui/build/windows/msix/template.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + false + NetBird + NetBird + NetBird desktop client + Assets\AppIcon.png + + + + + + + diff --git a/client/ui/build/windows/nsis/project.nsi b/client/ui/build/windows/nsis/project.nsi new file mode 100644 index 000000000..8d2530972 --- /dev/null +++ b/client/ui/build/windows/nsis/project.nsi @@ -0,0 +1,114 @@ +Unicode true + +#### +## Please note: Template replacements don't work in this file. They are provided with default defines like +## mentioned underneath. +## If the keyword is not defined, "wails_tools.nsh" will populate them. +## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually +## from outside of Wails for debugging and development of the installer. +## +## For development first make a wails nsis build to populate the "wails_tools.nsh": +## > wails build --target windows/amd64 --nsis +## Then you can call makensis on this file with specifying the path to your binary: +## For a AMD64 only installer: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe +## For a ARM64 only installer: +## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe +## For a installer with both architectures: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe +#### +## The following information is taken from the wails_tools.nsh file, but they can be overwritten here. +#### +## !define INFO_PROJECTNAME "my-project" # Default "netbird-ui" +## !define INFO_COMPANYNAME "My Company" # Default "NetBird" +## !define INFO_PRODUCTNAME "My Product Name" # Default "NetBird" +## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.0.1" +## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© 2026, My Company" +### +## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe" +## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +#### +## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html +#### +## Include the wails tools +#### +!include "wails_tools.nsh" + +# The version information for this two must consist of 4 parts +VIProductVersion "${INFO_PRODUCTVERSION}.0" +VIFileVersion "${INFO_PRODUCTVERSION}.0" + +VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" +VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" +VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}" +VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}" + +# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware +ManifestDPIAware true + +!include "MUI.nsh" + +!define MUI_ICON "..\icon.ico" +!define MUI_UNICON "..\icon.ico" +# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314 +!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps +!define MUI_ABORTWARNING # This will warn the user if they exit from the installer. + +!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. +# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer +!insertmacro MUI_PAGE_DIRECTORY # In which folder install page. +!insertmacro MUI_PAGE_INSTFILES # Installing page. +!insertmacro MUI_PAGE_FINISH # Finished installation page. + +!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page + +!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer + +## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1 +#!uninstfinalize 'signtool --file "%1"' +#!finalize 'signtool --file "%1"' + +Name "${INFO_PRODUCTNAME}" +OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file. +InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder). +ShowInstDetails show # This will always show the installation details. + +Function .onInit + !insertmacro wails.checkArchitecture +FunctionEnd + +Section + !insertmacro wails.setShellContext + + !insertmacro wails.webview2runtime + + SetOutPath $INSTDIR + + !insertmacro wails.files + + CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + + !insertmacro wails.associateFiles + !insertmacro wails.associateCustomProtocols + + !insertmacro wails.writeUninstaller +SectionEnd + +Section "uninstall" + !insertmacro wails.setShellContext + + RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath + + RMDir /r $INSTDIR + + Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" + Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" + + !insertmacro wails.unassociateFiles + !insertmacro wails.unassociateCustomProtocols + + !insertmacro wails.deleteUninstaller +SectionEnd diff --git a/client/ui/build/windows/nsis/wails_tools.nsh b/client/ui/build/windows/nsis/wails_tools.nsh new file mode 100644 index 000000000..b63101b32 --- /dev/null +++ b/client/ui/build/windows/nsis/wails_tools.nsh @@ -0,0 +1,236 @@ +# DO NOT EDIT - Generated automatically by `wails build` + +!include "x64.nsh" +!include "WinVer.nsh" +!include "FileFunc.nsh" + +!ifndef INFO_PROJECTNAME + !define INFO_PROJECTNAME "netbird-ui" +!endif +!ifndef INFO_COMPANYNAME + !define INFO_COMPANYNAME "NetBird" +!endif +!ifndef INFO_PRODUCTNAME + !define INFO_PRODUCTNAME "NetBird" +!endif +!ifndef INFO_PRODUCTVERSION + !define INFO_PRODUCTVERSION "0.0.1" +!endif +!ifndef INFO_COPYRIGHT + !define INFO_COPYRIGHT "NetBird GmbH" +!endif +!ifndef PRODUCT_EXECUTABLE + !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" +!endif +!ifndef UNINST_KEY_NAME + !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +!endif +!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}" + +!ifndef REQUEST_EXECUTION_LEVEL + !define REQUEST_EXECUTION_LEVEL "admin" +!endif + +RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" + +!ifdef ARG_WAILS_AMD64_BINARY + !define SUPPORTS_AMD64 +!endif + +!ifdef ARG_WAILS_ARM64_BINARY + !define SUPPORTS_ARM64 +!endif + +!ifdef SUPPORTS_AMD64 + !ifdef SUPPORTS_ARM64 + !define ARCH "amd64_arm64" + !else + !define ARCH "amd64" + !endif +!else + !ifdef SUPPORTS_ARM64 + !define ARCH "arm64" + !else + !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY" + !endif +!endif + +!macro wails.checkArchitecture + !ifndef WAILS_WIN10_REQUIRED + !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later." + !endif + + !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED + !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}" + !endif + + ${If} ${AtLeastWin10} + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + Goto ok + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + Goto ok + ${EndIf} + !endif + + IfSilent silentArch notSilentArch + silentArch: + SetErrorLevel 65 + Abort + notSilentArch: + MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}" + Quit + ${else} + IfSilent silentWin notSilentWin + silentWin: + SetErrorLevel 64 + Abort + notSilentWin: + MessageBox MB_OK "${WAILS_WIN10_REQUIRED}" + Quit + ${EndIf} + + ok: +!macroend + +!macro wails.files + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}" + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" + ${EndIf} + !endif +!macroend + +!macro wails.writeUninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + SetRegView 64 + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" +!macroend + +!macro wails.deleteUninstaller + Delete "$INSTDIR\uninstall.exe" + + SetRegView 64 + DeleteRegKey HKLM "${UNINST_KEY}" +!macroend + +!macro wails.setShellContext + ${If} ${REQUEST_EXECUTION_LEVEL} == "admin" + SetShellVarContext all + ${else} + SetShellVarContext current + ${EndIf} +!macroend + +# Install webview2 by launching the bootstrapper +# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment +!macro wails.webview2runtime + !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT + !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime" + !endif + + SetRegView 64 + # If the admin key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + + ${If} ${REQUEST_EXECUTION_LEVEL} == "user" + # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + ${EndIf} + + SetDetailsPrint both + DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}" + SetDetailsPrint listonly + + InitPluginsDir + CreateDirectory "$pluginsdir\webview2bootstrapper" + SetOutPath "$pluginsdir\webview2bootstrapper" + File "MicrosoftEdgeWebview2Setup.exe" + ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' + + SetDetailsPrint both + ok: +!macroend + +# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b +!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0" + + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}" + + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open" + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}` +!macroend + +!macro APP_UNASSOCIATE EXT FILECLASS + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup` + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0" + + DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}` +!macroend + +!macro wails.associateFiles + ; Create file associations + +!macroend + +!macro wails.unassociateFiles + ; Delete app associations + +!macroend + +!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}" +!macroend + +!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" +!macroend + +!macro wails.associateCustomProtocols + ; Create custom protocols associations + +!macroend + +!macro wails.unassociateCustomProtocols + ; Delete app custom protocol associations + +!macroend \ No newline at end of file diff --git a/client/ui/build/windows/wails.exe.manifest b/client/ui/build/windows/wails.exe.manifest new file mode 100644 index 000000000..f8b7b8e14 --- /dev/null +++ b/client/ui/build/windows/wails.exe.manifest @@ -0,0 +1,22 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + + + + + + + + diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go deleted file mode 100644 index d2f38cfd7..000000000 --- a/client/ui/client_ui.go +++ /dev/null @@ -1,1986 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - _ "embed" - "errors" - "flag" - "fmt" - "net/url" - "os" - "os/exec" - "os/user" - "path" - "runtime" - "strconv" - "strings" - "sync" - "time" - "unicode" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/app" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" - "fyne.io/systray" - "github.com/cenkalti/backoff/v4" - log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - - "github.com/netbirdio/netbird/client/iface" - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/mdm" - "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/client/ui/desktop" - "github.com/netbirdio/netbird/client/ui/event" - "github.com/netbirdio/netbird/client/ui/notifier" - "github.com/netbirdio/netbird/client/ui/process" - "github.com/netbirdio/netbird/util" - - "github.com/netbirdio/netbird/version" -) - -const ( - defaultFailTimeout = 3 * time.Second - failFastTimeout = time.Second -) - -const ( - censoredPreSharedKey = "**********" - maxSSHJWTCacheTTL = 86_400 // 24 hours in seconds - // mdmFieldSuffix is appended to plain-text Entry widgets in the - // advanced Settings window when the underlying field is enforced - // by MDM, so the user sees the lock indicator inline next to the - // value. Stripped before any read site that feeds the value back - // into a SetConfig request (saveSettings / parseNumericSettings). - mdmFieldSuffix = " (MDM)" -) - -// main is the entry point for the UI tray/client binary. Parses CLI -// flags, initialises logging, builds the Fyne application and tray -// icons, and constructs the service client (which may open a -// requested UI window). When a window-mode flag is set the Fyne event -// loop runs and main returns; otherwise main enforces single-instance -// behaviour (signalling an existing instance to show its window when -// present), sets up signal handling + default fonts, and runs the -// system tray loop. -func main() { - flags := parseFlags() - - // Initialize file logging if needed. - var logFile string - if flags.saveLogsInFile { - file, err := initLogFile() - if err != nil { - log.Errorf("error while initializing log: %v", err) - return - } - logFile = file - } else { - _ = util.InitLog("trace", util.LogConsole) - } - - // Create the Fyne application. - a := app.NewWithID("NetBird") - a.SetIcon(fyne.NewStaticResource("netbird", iconDisconnected)) - - // Show error message window if needed. - if flags.errorMsg != "" { - showErrorMessage(flags.errorMsg) - return - } - - // Create the service client (this also builds the settings or networks UI if requested). - client := newServiceClient(&newServiceClientArgs{ - addr: flags.daemonAddr, - logFile: logFile, - app: a, - showSettings: flags.showSettings, - showNetworks: flags.showNetworks, - showLoginURL: flags.showLoginURL, - showDebug: flags.showDebug, - showProfiles: flags.showProfiles, - showQuickActions: flags.showQuickActions, - showUpdate: flags.showUpdate, - showUpdateVersion: flags.showUpdateVersion, - }) - - // Watch for theme/settings changes to update the icon. - go watchSettingsChanges(a, client) - - // Run in window mode if any UI flag was set. - if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles || flags.showQuickActions || flags.showUpdate { - a.Run() - return - } - - // Check for another running process. - pid, running, err := process.IsAnotherProcessRunning() - if err != nil { - log.Errorf("error while checking process: %v", err) - return - } - if running { - log.Infof("another process is running with pid %d, sending signal to show window", pid) - if err := sendShowWindowSignal(pid); err != nil { - log.Errorf("send signal to running instance: %v", err) - } - return - } - - client.setupSignalHandler(client.ctx) - - client.setDefaultFonts() - systray.Run(client.onTrayReady, client.onTrayExit) -} - -type cliFlags struct { - daemonAddr string - showSettings bool - showNetworks bool - showProfiles bool - showDebug bool - showLoginURL bool - showQuickActions bool - errorMsg string - saveLogsInFile bool - showUpdate bool - showUpdateVersion string -} - -// parseFlags reads and returns all needed command-line flags. -func parseFlags() *cliFlags { - var flags cliFlags - - defaultDaemonAddr := "unix:///var/run/netbird.sock" - if runtime.GOOS == "windows" { - defaultDaemonAddr = "tcp://127.0.0.1:41731" - } - flag.StringVar(&flags.daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]") - flag.BoolVar(&flags.showSettings, "settings", false, "run settings window") - flag.BoolVar(&flags.showNetworks, "networks", false, "run networks window") - flag.BoolVar(&flags.showProfiles, "profiles", false, "run profiles window") - flag.BoolVar(&flags.showDebug, "debug", false, "run debug window") - flag.BoolVar(&flags.showQuickActions, "quick-actions", false, "run quick actions window") - flag.StringVar(&flags.errorMsg, "error-msg", "", "displays an error message window") - flag.BoolVar(&flags.saveLogsInFile, "use-log-file", false, fmt.Sprintf("save logs in a file: %s/netbird-ui-PID.log", os.TempDir())) - flag.BoolVar(&flags.showLoginURL, "login-url", false, "show login URL in a popup window") - flag.BoolVar(&flags.showUpdate, "update", false, "show update progress window") - flag.StringVar(&flags.showUpdateVersion, "update-version", "", "version to update to") - flag.Parse() - return &flags -} - -// initLogFile initializes logging into a file. -func initLogFile() (string, error) { - logFile := path.Join(os.TempDir(), fmt.Sprintf("netbird-ui-%d.log", os.Getpid())) - return logFile, util.InitLog("trace", logFile) -} - -// watchSettingsChanges listens for Fyne theme/settings changes and updates the client icon. -func watchSettingsChanges(a fyne.App, client *serviceClient) { - a.Settings().AddListener(func(settings fyne.Settings) { - client.updateIcon() - }) -} - -// showErrorMessage displays an error message in a simple window. -func showErrorMessage(msg string) { - a := app.New() - w := a.NewWindow("NetBird Error") - label := widget.NewLabel(msg) - label.Wrapping = fyne.TextWrapWord - w.SetContent(label) - w.Resize(fyne.NewSize(400, 100)) - w.Show() - a.Run() -} - -//go:embed assets/netbird-systemtray-connected-macos.png -var iconConnectedMacOS []byte - -//go:embed assets/netbird-systemtray-disconnected-macos.png -var iconDisconnectedMacOS []byte - -//go:embed assets/netbird-systemtray-update-disconnected-macos.png -var iconUpdateDisconnectedMacOS []byte - -//go:embed assets/netbird-systemtray-update-connected-macos.png -var iconUpdateConnectedMacOS []byte - -//go:embed assets/netbird-systemtray-connecting-macos.png -var iconConnectingMacOS []byte - -//go:embed assets/netbird-systemtray-error-macos.png -var iconErrorMacOS []byte - -//go:embed assets/connected.png -var iconConnectedDot []byte - -//go:embed assets/disconnected.png -var iconDisconnectedDot []byte - -type serviceClient struct { - ctx context.Context - cancel context.CancelFunc - addr string - conn proto.DaemonServiceClient - connLock sync.Mutex - - eventHandler *eventHandler - - profileManager *profilemanager.ProfileManager - - icAbout []byte - icConnected []byte - icConnectedDot []byte - icDisconnected []byte - icDisconnectedDot []byte - icUpdateConnected []byte - icUpdateDisconnected []byte - icConnecting []byte - icError []byte - - // systray menu items - mStatus *systray.MenuItem - mUp *systray.MenuItem - mDown *systray.MenuItem - mSettings *systray.MenuItem - mProfile *profileMenu - mAbout *systray.MenuItem - mGitHub *systray.MenuItem - mVersionUI *systray.MenuItem - mVersionDaemon *systray.MenuItem - mUpdate *systray.MenuItem - mQuit *systray.MenuItem - mNetworks *systray.MenuItem - mAllowSSH *systray.MenuItem - mAutoConnect *systray.MenuItem - mEnableRosenpass *systray.MenuItem - mLazyConnEnabled *systray.MenuItem - mBlockInbound *systray.MenuItem - mNotifications *systray.MenuItem - mAdvancedSettings *systray.MenuItem - mCreateDebugBundle *systray.MenuItem - mExitNode *systray.MenuItem - - // application with main windows. - app fyne.App - notifier notifier.Notifier - wSettings fyne.Window - showAdvancedSettings bool - sendNotification bool - - // input elements for settings form - iMngURL *widget.Entry - iLogFile *widget.Entry - iPreSharedKey *widget.Entry - iInterfaceName *widget.Entry - iInterfacePort *widget.Entry - iMTU *widget.Entry - - // switch elements for settings form - sRosenpassPermissive *widget.Check - sNetworkMonitor *widget.Check - sDisableDNS *widget.Check - sDisableClientRoutes *widget.Check - sDisableServerRoutes *widget.Check - sDisableIPv6 *widget.Check - sBlockLANAccess *widget.Check - sEnableSSHRoot *widget.Check - sEnableSSHSFTP *widget.Check - sEnableSSHLocalPortForward *widget.Check - sEnableSSHRemotePortForward *widget.Check - sDisableSSHAuth *widget.Check - iSSHJWTCacheTTL *widget.Entry - - // observable settings over corresponding iMngURL and iPreSharedKey values. - managementURL string - preSharedKey string - - RosenpassPermissive bool - interfaceName string - interfacePort int - mtu uint16 - networkMonitor bool - disableDNS bool - disableClientRoutes bool - disableServerRoutes bool - disableIPv6 bool - blockLANAccess bool - enableSSHRoot bool - enableSSHSFTP bool - enableSSHLocalPortForward bool - enableSSHRemotePortForward bool - disableSSHAuth bool - sshJWTCacheTTL int - - connected bool - daemonVersion string - updateIndicationLock sync.Mutex - isUpdateIconActive bool - isEnforcedUpdate bool - lastNotifiedVersion string - profilesEnabled bool - networksEnabled bool - // networksMenuEnabled caches the last applied enabled-state of the - // mNetworks + mExitNode submenu items. Combines features.DisableNetworks - // AND s.connected — both must be true for the menus to be active. - // Zero value (false) matches the Disable() call at AddMenuItem time. - networksMenuEnabled bool - showNetworks bool - wNetworks fyne.Window - wProfiles fyne.Window - wQuickActions fyne.Window - - eventManager *event.Manager - - exitNodeMu sync.Mutex - mExitNodeItems []menuHandler - exitNodeRetryCancel context.CancelFunc - mExitNodeSeparator *systray.MenuItem - mExitNodeDeselectAll *systray.MenuItem - logFile string - wLoginURL fyne.Window - wUpdateProgress fyne.Window - updateContextCancel context.CancelFunc - - connectCancel context.CancelFunc - - // mdmManagedFields caches the names of MDM-enforced policy keys - // surfaced by the daemon in GetConfigResponse. Each refresh of - // daemon config (loadSettings, getSrvConfig, config_changed event) - // updates this set and re-applies the lock/badge to the affected - // menu items and settings-form widgets. - mdmManagedFields map[string]bool -} - -type menuHandler struct { - *systray.MenuItem - cancel context.CancelFunc -} - -type newServiceClientArgs struct { - addr string - logFile string - app fyne.App - showSettings bool - showNetworks bool - showDebug bool - showLoginURL bool - showProfiles bool - showQuickActions bool - showUpdate bool - showUpdateVersion string -} - -// newServiceClient instance constructor -// -// This constructor also builds the UI elements for the settings window. -func newServiceClient(args *newServiceClientArgs) *serviceClient { - ctx, cancel := context.WithCancel(context.Background()) - s := &serviceClient{ - ctx: ctx, - cancel: cancel, - addr: args.addr, - app: args.app, - notifier: notifier.New(args.app), - logFile: args.logFile, - sendNotification: false, - - showAdvancedSettings: args.showSettings, - showNetworks: args.showNetworks, - networksEnabled: true, - } - - s.eventHandler = newEventHandler(s) - s.profileManager = profilemanager.NewProfileManager() - s.setNewIcons() - - switch { - case args.showSettings: - s.showSettingsUI() - case args.showNetworks: - s.showNetworksUI() - case args.showLoginURL: - s.showLoginURL() - case args.showDebug: - s.showDebugUI() - case args.showProfiles: - s.showProfilesUI() - case args.showQuickActions: - s.showQuickActionsUI() - case args.showUpdate: - s.showUpdateProgress(ctx, args.showUpdateVersion) - } - - return s -} - -func (s *serviceClient) setNewIcons() { - s.icAbout = iconAbout - s.icConnectedDot = iconConnectedDot - s.icDisconnectedDot = iconDisconnectedDot - if s.app.Settings().ThemeVariant() == theme.VariantDark { - s.icConnected = iconConnectedDark - s.icDisconnected = iconDisconnected - s.icUpdateConnected = iconUpdateConnectedDark - s.icUpdateDisconnected = iconUpdateDisconnectedDark - s.icConnecting = iconConnectingDark - s.icError = iconErrorDark - } else { - s.icConnected = iconConnected - s.icDisconnected = iconDisconnected - s.icUpdateConnected = iconUpdateConnected - s.icUpdateDisconnected = iconUpdateDisconnected - s.icConnecting = iconConnecting - s.icError = iconError - } -} - -func (s *serviceClient) updateIcon() { - s.setNewIcons() - s.updateIndicationLock.Lock() - if s.connected { - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) - } else { - systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) - } - } else { - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) - } else { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - } - } - s.updateIndicationLock.Unlock() -} - -func (s *serviceClient) showSettingsUI() { - // DisableUpdateSettings no longer gates the window from opening: - // the daemon blocks every actual mutation at SetConfig / Login, - // so the window is safe to show as a read-only view. The previous - // early-return also blocked Advanced Settings whenever update - // editing was off, which conflated two distinct kill switches - // (see comment in checkAndUpdateFeatures). - - // add settings window UI elements. - s.wSettings = s.app.NewWindow("NetBird Settings") - s.wSettings.SetOnClosed(s.cancel) - - s.iMngURL = widget.NewEntry() - - s.iLogFile = widget.NewEntry() - s.iLogFile.Disable() - s.iPreSharedKey = widget.NewPasswordEntry() - s.iInterfaceName = widget.NewEntry() - s.iInterfacePort = widget.NewEntry() - s.iMTU = widget.NewEntry() - - s.sRosenpassPermissive = widget.NewCheck("Enable Rosenpass permissive mode", nil) - - s.sNetworkMonitor = widget.NewCheck("Restarts NetBird when the network changes", nil) - s.sDisableDNS = widget.NewCheck("Keeps system DNS settings unchanged", nil) - s.sDisableClientRoutes = widget.NewCheck("This peer won't route traffic to other peers", nil) - s.sDisableServerRoutes = widget.NewCheck("This peer won't act as router for others", nil) - s.sDisableIPv6 = widget.NewCheck("Disable IPv6 overlay addressing", nil) - s.sBlockLANAccess = widget.NewCheck("Blocks local network access when used as exit node", nil) - s.sEnableSSHRoot = widget.NewCheck("Enable SSH Root Login", nil) - s.sEnableSSHSFTP = widget.NewCheck("Enable SSH SFTP", nil) - s.sEnableSSHLocalPortForward = widget.NewCheck("Enable SSH Local Port Forwarding", nil) - s.sEnableSSHRemotePortForward = widget.NewCheck("Enable SSH Remote Port Forwarding", nil) - s.sDisableSSHAuth = widget.NewCheck("Disable SSH Authentication", nil) - s.iSSHJWTCacheTTL = widget.NewEntry() - - s.wSettings.SetContent(s.getSettingsForm()) - s.wSettings.Resize(fyne.NewSize(600, 400)) - s.wSettings.SetFixedSize(true) - - s.getSrvConfig() - s.wSettings.Show() -} - -func (s *serviceClient) getConnectionForm() *widget.Form { - var activeProfName string - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - } else { - activeProfName = activeProf.Name - } - return &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Profile", Widget: widget.NewLabel(activeProfName)}, - {Text: "Management URL", Widget: s.iMngURL}, - {Text: "Pre-shared Key", Widget: s.iPreSharedKey}, - {Text: "Quantum-Resistance", Widget: s.sRosenpassPermissive}, - {Text: "Interface Name", Widget: s.iInterfaceName}, - {Text: "Interface Port", Widget: s.iInterfacePort, HintText: "If set to 0, a random free port will be used"}, - {Text: "MTU", Widget: s.iMTU}, - {Text: "Log File", Widget: s.iLogFile}, - }, - } -} - -func (s *serviceClient) saveSettings() { - // Check if update settings are disabled by daemon - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - // Continue with default behavior if features can't be retrieved - } else if features != nil && features.DisableUpdateSettings { - log.Warn("Configuration updates are disabled by daemon") - dialog.ShowError(fmt.Errorf("configuration updates are disabled by daemon"), s.wSettings) - return - } - - if err := s.validateSettings(); err != nil { - dialog.ShowError(err, s.wSettings) - return - } - - port, mtu, err := s.parseNumericSettings() - if err != nil { - dialog.ShowError(err, s.wSettings) - return - } - - iMngURL := strings.TrimSpace(strings.TrimSuffix(s.iMngURL.Text, mdmFieldSuffix)) - - if s.hasSettingsChanged(iMngURL, port, mtu) { - if err := s.applySettingsChanges(iMngURL, port, mtu); err != nil { - dialog.ShowError(err, s.wSettings) - return - } - } - - s.wSettings.Close() -} - -func (s *serviceClient) validateSettings() error { - if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey { - if _, err := wgtypes.ParseKey(s.iPreSharedKey.Text); err != nil { - return fmt.Errorf("invalid pre-shared key value") - } - } - return nil -} - -func (s *serviceClient) parseNumericSettings() (int64, int64, error) { - port, err := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(s.iInterfacePort.Text, mdmFieldSuffix)), 10, 64) - if err != nil { - return 0, 0, errors.New("invalid interface port") - } - if port < 0 || port > 65535 { - return 0, 0, errors.New("invalid interface port: out of range 0-65535") - } - - var mtu int64 - mtuText := strings.TrimSpace(s.iMTU.Text) - if mtuText != "" { - mtu, err = strconv.ParseInt(mtuText, 10, 64) - if err != nil { - return 0, 0, errors.New("invalid MTU value") - } - if mtu < iface.MinMTU || mtu > iface.MaxMTU { - return 0, 0, fmt.Errorf("MTU must be between %d and %d bytes", iface.MinMTU, iface.MaxMTU) - } - } - - return port, mtu, nil -} - -func (s *serviceClient) hasSettingsChanged(iMngURL string, port, mtu int64) bool { - return s.managementURL != iMngURL || - s.preSharedKey != s.iPreSharedKey.Text || - s.RosenpassPermissive != s.sRosenpassPermissive.Checked || - s.interfaceName != s.iInterfaceName.Text || - s.interfacePort != int(port) || - s.mtu != uint16(mtu) || - s.networkMonitor != s.sNetworkMonitor.Checked || - s.disableDNS != s.sDisableDNS.Checked || - s.disableClientRoutes != s.sDisableClientRoutes.Checked || - s.disableServerRoutes != s.sDisableServerRoutes.Checked || - s.disableIPv6 != s.sDisableIPv6.Checked || - s.blockLANAccess != s.sBlockLANAccess.Checked || - s.hasSSHChanges() -} - -func (s *serviceClient) applySettingsChanges(iMngURL string, port, mtu int64) error { - s.managementURL = iMngURL - s.preSharedKey = s.iPreSharedKey.Text - s.mtu = uint16(mtu) - - req, err := s.buildSetConfigRequest(iMngURL, port, mtu) - if err != nil { - return fmt.Errorf("build config request: %w", err) - } - - if err := s.sendConfigUpdate(req); err != nil { - return fmt.Errorf("set configuration: %w", err) - } - - return nil -} - -func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) (*proto.SetConfigRequest, error) { - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - return nil, fmt.Errorf("get active profile: %w", err) - } - - req := &proto.SetConfigRequest{ - ProfileName: activeProf.ID.String(), - Username: currUser.Username, - } - - if iMngURL != "" { - req.ManagementUrl = iMngURL - } - - req.RosenpassPermissive = &s.sRosenpassPermissive.Checked - req.InterfaceName = &s.iInterfaceName.Text - req.WireguardPort = &port - if mtu > 0 { - req.Mtu = &mtu - } - - req.NetworkMonitor = &s.sNetworkMonitor.Checked - req.DisableDns = &s.sDisableDNS.Checked - req.DisableClientRoutes = &s.sDisableClientRoutes.Checked - req.DisableServerRoutes = &s.sDisableServerRoutes.Checked - req.DisableIpv6 = &s.sDisableIPv6.Checked - req.BlockLanAccess = &s.sBlockLANAccess.Checked - - req.EnableSSHRoot = &s.sEnableSSHRoot.Checked - req.EnableSSHSFTP = &s.sEnableSSHSFTP.Checked - req.EnableSSHLocalPortForwarding = &s.sEnableSSHLocalPortForward.Checked - req.EnableSSHRemotePortForwarding = &s.sEnableSSHRemotePortForward.Checked - req.DisableSSHAuth = &s.sDisableSSHAuth.Checked - - sshJWTCacheTTLText := strings.TrimSpace(s.iSSHJWTCacheTTL.Text) - if sshJWTCacheTTLText != "" { - sshJWTCacheTTL, err := strconv.ParseInt(sshJWTCacheTTLText, 10, 32) - if err != nil { - return nil, errors.New("invalid SSH JWT Cache TTL value") - } - if sshJWTCacheTTL < 0 || sshJWTCacheTTL > maxSSHJWTCacheTTL { - return nil, fmt.Errorf("SSH JWT Cache TTL must be between 0 and %d seconds", maxSSHJWTCacheTTL) - } - sshJWTCacheTTL32 := int32(sshJWTCacheTTL) - req.SshJWTCacheTTL = &sshJWTCacheTTL32 - } - - // Only attach the PSK when the user actually typed something: - // - "" means the field was left untouched (we deliberately render - // an empty Text + placeholder hint to avoid leaking the daemon's - // "**********" redaction through the password reveal toggle); - // sending an empty pointer would tell the daemon to clear / overwrite - // the on-disk or MDM-enforced PSK, which then trips the MDM - // conflict gate when PSK is policy-managed. - // - "**********" is the redacted echo (legacy non-MDM path); also a no-op. - if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey { - req.OptionalPreSharedKey = &s.iPreSharedKey.Text - } - - return req, nil -} - -func (s *serviceClient) sendConfigUpdate(req *proto.SetConfigRequest) error { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return fmt.Errorf("get client: %w", err) - } - - _, err = conn.SetConfig(s.ctx, req) - if err != nil { - return fmt.Errorf("set config: %w", err) - } - - // Reconnect if connected to apply the new settings. - // Use a background context so the reconnect outlives the settings window. - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - status, err := conn.Status(ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("failed to get service status: %v", err) - return - } - if status.Status == string(internal.StatusConnected) { - if _, err = conn.Down(ctx, &proto.DownRequest{}); err != nil { - log.Errorf("failed to stop service: %v", err) - } - // TODO: wait for the service to be idle before calling Up, or use a fresh connection - if _, err = conn.Up(ctx, &proto.UpRequest{}); err != nil { - log.Errorf("failed to start service: %v", err) - } - } - }() - - return nil -} - -func (s *serviceClient) getSettingsForm() fyne.CanvasObject { - connectionForm := s.getConnectionForm() - networkForm := s.getNetworkForm() - sshForm := s.getSSHForm() - tabs := container.NewAppTabs( - container.NewTabItem("Connection", connectionForm), - container.NewTabItem("Network", networkForm), - container.NewTabItem("SSH", sshForm), - ) - saveButton := widget.NewButtonWithIcon("Save", theme.ConfirmIcon(), s.saveSettings) - saveButton.Importance = widget.HighImportance - cancelButton := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() { - s.wSettings.Close() - }) - buttonContainer := container.NewHBox( - layout.NewSpacer(), - cancelButton, - saveButton, - ) - return container.NewBorder(nil, buttonContainer, nil, nil, tabs) -} - -func (s *serviceClient) getNetworkForm() *widget.Form { - return &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Network Monitor", Widget: s.sNetworkMonitor}, - {Text: "Disable DNS", Widget: s.sDisableDNS}, - {Text: "Disable Client Routes", Widget: s.sDisableClientRoutes}, - {Text: "Disable Server Routes", Widget: s.sDisableServerRoutes}, - {Text: "Disable IPv6", Widget: s.sDisableIPv6}, - {Text: "Disable LAN Access", Widget: s.sBlockLANAccess}, - }, - } -} - -func (s *serviceClient) getSSHForm() *widget.Form { - return &widget.Form{ - Items: []*widget.FormItem{ - {Text: "Enable SSH Root Login", Widget: s.sEnableSSHRoot}, - {Text: "Enable SSH SFTP", Widget: s.sEnableSSHSFTP}, - {Text: "Enable SSH Local Port Forwarding", Widget: s.sEnableSSHLocalPortForward}, - {Text: "Enable SSH Remote Port Forwarding", Widget: s.sEnableSSHRemotePortForward}, - {Text: "Disable SSH Authentication", Widget: s.sDisableSSHAuth}, - {Text: "JWT Cache TTL (seconds, 0=disabled)", Widget: s.iSSHJWTCacheTTL}, - }, - } -} - -func (s *serviceClient) hasSSHChanges() bool { - currentSSHJWTCacheTTL := s.sshJWTCacheTTL - if text := strings.TrimSpace(s.iSSHJWTCacheTTL.Text); text != "" { - val, err := strconv.Atoi(text) - if err != nil { - return true - } - currentSSHJWTCacheTTL = val - } - - return s.enableSSHRoot != s.sEnableSSHRoot.Checked || - s.enableSSHSFTP != s.sEnableSSHSFTP.Checked || - s.enableSSHLocalPortForward != s.sEnableSSHLocalPortForward.Checked || - s.enableSSHRemotePortForward != s.sEnableSSHRemotePortForward.Checked || - s.disableSSHAuth != s.sDisableSSHAuth.Checked || - s.sshJWTCacheTTL != currentSSHJWTCacheTTL -} - -func (s *serviceClient) login(ctx context.Context, openURL bool) (*proto.LoginResponse, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf("get daemon client: %w", err) - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - return nil, fmt.Errorf("get active profile: %w", err) - } - - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - - handle := activeProf.ID.String() - - loginReq := &proto.LoginRequest{ - IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd", - ProfileName: &handle, - Username: &currUser.Username, - } - - 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 != "" { - loginReq.Hint = &profileState.Email - } - - loginResp, err := conn.Login(ctx, loginReq) - if err != nil { - return nil, fmt.Errorf("login to management: %w", err) - } - - if loginResp.NeedsSSOLogin && openURL { - if err = s.handleSSOLogin(ctx, loginResp, conn); err != nil { - return nil, fmt.Errorf("SSO login: %w", err) - } - } - - return loginResp, nil -} - -func (s *serviceClient) handleSSOLogin(ctx context.Context, loginResp *proto.LoginResponse, conn proto.DaemonServiceClient) error { - if err := openURL(loginResp.VerificationURIComplete); err != nil { - return fmt.Errorf("open browser: %w", err) - } - - resp, err := conn.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode}) - if err != nil { - return fmt.Errorf("wait for SSO login: %w", err) - } - - if resp.Email != "" { - if err := s.profileManager.SetActiveProfileState(&profilemanager.ProfileState{ - Email: resp.Email, - }); err != nil { - log.Debugf("failed to set profile state: %v", err) - } else { - s.mProfile.refresh() - } - } - - return nil -} - -func (s *serviceClient) menuUpClick(ctx context.Context) error { - systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - systray.SetTemplateIcon(iconErrorMacOS, s.icError) - return fmt.Errorf("get daemon client: %w", err) - } - - _, err = s.login(ctx, true) - if err != nil { - return fmt.Errorf("login: %w", err) - } - - status, err := conn.Status(ctx, &proto.StatusRequest{}) - if err != nil { - return fmt.Errorf("get status: %w", err) - } - - if status.Status == string(internal.StatusConnected) { - return nil - } - - if _, err := s.conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - return fmt.Errorf("start connection: %w", err) - } - - return nil -} - -func (s *serviceClient) menuDownClick() error { - systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf("get daemon client: %w", err) - } - - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - return fmt.Errorf("get status: %w", err) - } - - if status.Status != string(internal.StatusConnected) && status.Status != string(internal.StatusConnecting) { - return nil - } - - if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { - return fmt.Errorf("stop connection: %w", err) - } - - return nil -} - -func (s *serviceClient) updateStatus() error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return err - } - err = backoff.Retry(func() error { - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("get service status: %v", err) - if s.connected { - s.notifier.Send("Error", "Connection to service lost") - } - s.setDisconnectedStatus() - return err - } - - s.updateIndicationLock.Lock() - defer s.updateIndicationLock.Unlock() - - // notify the user when the session has expired - if status.Status == string(internal.StatusSessionExpired) { - s.onSessionExpire() - } - - var systrayIconState bool - - switch { - case status.Status == string(internal.StatusConnected) && !s.connected: - s.connected = true - s.sendNotification = true - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) - } else { - systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) - } - systray.SetTooltip("NetBird (Connected)") - s.mStatus.SetTitle("Connected") - s.mStatus.SetIcon(s.icConnectedDot) - s.mUp.Disable() - s.mDown.Enable() - if s.networksEnabled { - s.mNetworks.Enable() - s.mExitNode.Enable() - } - s.startExitNodeRefresh() - systrayIconState = true - case status.Status == string(internal.StatusConnecting): - s.setConnectingStatus() - case status.Status != string(internal.StatusConnected) && s.mUp.Disabled(): - s.setDisconnectedStatus() - systrayIconState = false - } - - // if the daemon version changed (e.g. after a successful update), reset the update indication - if s.daemonVersion != status.DaemonVersion { - if s.daemonVersion != "" { - s.mUpdate.Hide() - s.isUpdateIconActive = false - } - s.daemonVersion = status.DaemonVersion - if !s.isUpdateIconActive { - if systrayIconState { - systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected) - } else { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - } - } - - daemonVersionTitle := normalizedVersion(s.daemonVersion) - s.mVersionDaemon.SetTitle(fmt.Sprintf("Daemon: %s", daemonVersionTitle)) - s.mVersionDaemon.SetTooltip(fmt.Sprintf("Daemon version: %s", daemonVersionTitle)) - s.mVersionDaemon.Show() - } - - return nil - }, &backoff.ExponentialBackOff{ - InitialInterval: time.Second, - RandomizationFactor: backoff.DefaultRandomizationFactor, - Multiplier: backoff.DefaultMultiplier, - MaxInterval: 300 * time.Millisecond, - MaxElapsedTime: 2 * time.Second, - Stop: backoff.Stop, - Clock: backoff.SystemClock, - }) - if err != nil { - return err - } - - return nil -} - -func (s *serviceClient) setDisconnectedStatus() { - s.connected = false - if s.isUpdateIconActive { - systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) - } else { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - } - systray.SetTooltip("NetBird (Disconnected)") - s.mStatus.SetTitle("Disconnected") - s.mStatus.SetIcon(s.icDisconnectedDot) - s.mDown.Disable() - s.mUp.Enable() - s.mNetworks.Disable() - s.mExitNode.Disable() - s.cancelExitNodeRetry() - go s.updateExitNodes() -} - -func (s *serviceClient) setConnectingStatus() { - s.connected = false - systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting) - systray.SetTooltip("NetBird (Connecting)") - s.mStatus.SetTitle("Connecting") - s.mUp.Disable() - s.mDown.Enable() - s.mNetworks.Disable() - s.mExitNode.Disable() -} - -func (s *serviceClient) onTrayReady() { - systray.SetTemplateIcon(iconDisconnectedMacOS, s.icDisconnected) - systray.SetTooltip("NetBird") - - // setup systray menu items - s.mStatus = systray.AddMenuItem("Disconnected", "Disconnected") - s.mStatus.SetIcon(s.icDisconnectedDot) - s.mStatus.Disable() - - profileMenuItem := systray.AddMenuItem("", "") - emailMenuItem := systray.AddMenuItem("", "") - - newProfileMenuArgs := &newProfileMenuArgs{ - ctx: s.ctx, - serviceClient: s, - profileManager: s.profileManager, - eventHandler: s.eventHandler, - profileMenuItem: profileMenuItem, - emailMenuItem: emailMenuItem, - downClickCallback: s.menuDownClick, - upClickCallback: s.menuUpClick, - getSrvClientCallback: s.getSrvClient, - loadSettingsCallback: s.loadSettings, - app: s.app, - } - - s.mProfile = newProfileMenu(*newProfileMenuArgs) - // Seed the transition cache to match the actual default menu - // state (visible / enabled). Without this, the first - // checkAndUpdateFeatures tick that observes DisableProfiles=true - // is a no-op (cache zero-value == desired-false) and the menu - // never gets hidden — symptom: MDM enforces the kill switch but - // the profile menu stays clickable. - s.profilesEnabled = true - - systray.AddSeparator() - s.mUp = systray.AddMenuItem("Connect", "Connect") - s.mDown = systray.AddMenuItem("Disconnect", "Disconnect") - s.mDown.Disable() - systray.AddSeparator() - - s.mSettings = systray.AddMenuItem("Settings", disabledMenuDescr) - s.mAllowSSH = s.mSettings.AddSubMenuItemCheckbox("Allow SSH", allowSSHMenuDescr, false) - s.mAutoConnect = s.mSettings.AddSubMenuItemCheckbox("Connect on Startup", autoConnectMenuDescr, false) - s.mEnableRosenpass = s.mSettings.AddSubMenuItemCheckbox("Enable Quantum-Resistance", quantumResistanceMenuDescr, false) - s.mLazyConnEnabled = s.mSettings.AddSubMenuItemCheckbox("Enable Lazy Connections", lazyConnMenuDescr, false) - s.mBlockInbound = s.mSettings.AddSubMenuItemCheckbox("Block Inbound Connections", blockInboundMenuDescr, false) - s.mNotifications = s.mSettings.AddSubMenuItemCheckbox("Notifications", notificationsMenuDescr, false) - s.mSettings.AddSeparator() - s.mAdvancedSettings = s.mSettings.AddSubMenuItem("Advanced Settings", advancedSettingsMenuDescr) - s.mCreateDebugBundle = s.mSettings.AddSubMenuItem("Create Debug Bundle", debugBundleMenuDescr) - s.loadSettings() - - // Disable profile menu if profiles are disabled by daemon. - // DisableUpdateSettings is enforced at the daemon's SetConfig / - // Login gates, not by hiding the UI — so the Settings menu (and - // its Advanced Settings submenu, which has its own kill switch) - // stays visible and the user can still inspect current values. - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - // Continue with default behavior if features can't be retrieved - } else if features != nil && features.DisableProfiles { - s.mProfile.setEnabled(false) - s.profilesEnabled = false - } - - s.exitNodeMu.Lock() - s.mExitNode = systray.AddMenuItem("Exit Node", disabledMenuDescr) - s.mExitNode.Disable() - s.exitNodeMu.Unlock() - - s.mNetworks = systray.AddMenuItem("Networks", networksMenuDescr) - s.mNetworks.Disable() - systray.AddSeparator() - - s.mAbout = systray.AddMenuItem("About", "About") - s.mAbout.SetIcon(s.icAbout) - - s.mGitHub = s.mAbout.AddSubMenuItem("GitHub", "GitHub") - - versionString := normalizedVersion(version.NetbirdVersion()) - s.mVersionUI = s.mAbout.AddSubMenuItem(fmt.Sprintf("GUI: %s", versionString), fmt.Sprintf("GUI Version: %s", versionString)) - s.mVersionUI.Disable() - - s.mVersionDaemon = s.mAbout.AddSubMenuItem("", "") - s.mVersionDaemon.Disable() - s.mVersionDaemon.Hide() - - s.mUpdate = s.mAbout.AddSubMenuItem("Download latest version", latestVersionMenuDescr) - s.mUpdate.Hide() - - systray.AddSeparator() - s.mQuit = systray.AddMenuItem("Quit", quitMenuDescr) - - // update exit node menu in case service is already connected - go s.updateExitNodes() - - // Features (DisableProfiles, DisableUpdateSettings, DisableNetworks, - // ...) only change in two ways: at service install time (CLI flag, - // static) and at MDM ticker diff time. The daemon already publishes - // a SystemEvent{type=config_changed} on every MDM-driven engine - // restart, so the UI no longer needs to poll GetFeatures every 2 s. - // A single fetch at startup covers the static CLI-flag case; the - // event handler below covers MDM transitions. updateStatus stays in - // the 2 s loop because connection / peer state genuinely change - // continuously and have no event yet. - s.checkAndUpdateFeatures() - go func() { - s.getSrvConfig() - time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon - for { - err := s.updateStatus() - if err != nil { - log.Errorf("error while updating status: %v", err) - } - - time.Sleep(2 * time.Second) - } - }() - - s.eventManager = event.NewManager(s.notifier, s.addr) - s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) - s.eventManager.AddHandler(func(event *proto.SystemEvent) { - if event.Category == proto.SystemEvent_SYSTEM { - s.updateExitNodes() - } - }) - s.eventManager.AddHandler(func(event *proto.SystemEvent) { - // todo use new Category - if windowAction, ok := event.Metadata["progress_window"]; ok { - targetVersion, ok := event.Metadata["version"] - if !ok { - targetVersion = "unknown" - } - log.Debugf("window action: %v", windowAction) - if windowAction == "show" { - if s.updateContextCancel != nil { - s.updateContextCancel() - s.updateContextCancel = nil - } - - subCtx, cancel := context.WithCancel(s.ctx) - go s.eventHandler.runSelfCommand(subCtx, "update", "--update-version", targetVersion) - s.updateContextCancel = cancel - } - } - }) - s.eventManager.AddHandler(func(event *proto.SystemEvent) { - if newVersion, ok := event.Metadata["new_version_available"]; ok { - _, enforced := event.Metadata["enforced"] - log.Infof("received new_version_available event: version=%s enforced=%v", newVersion, enforced) - s.onUpdateAvailable(newVersion, enforced) - } - }) - s.eventManager.AddHandler(func(event *proto.SystemEvent) { - // Daemon emits a config_changed event after every engine spawn - // (Server.Start, Server.Up, MDM ticker restart). Re-sync the - // tray submenu checkboxes from the fresh daemon-side config so - // the user does not have to restart the tray to see CLI- or - // MDM-driven changes. - if event.Category == proto.SystemEvent_SYSTEM && event.Metadata["type"] == "config_changed" { - log.Infof("config_changed event received (source=%s); refreshing settings + features", event.Metadata["source"]) - s.loadSettings() - // MDM-driven feature kill switches (DisableProfiles / - // DisableUpdateSettings / DisableNetworks) ride the same - // config_changed signal because the daemon re-applies its - // MDM policy on every engine spawn. Pull them in here so - // the UI is up to date without a periodic GetFeatures poll. - s.checkAndUpdateFeatures() - } - }) - - go s.eventManager.Start(s.ctx) - go s.eventHandler.listen(s.ctx) -} - -func (s *serviceClient) attachOutput(cmd *exec.Cmd) *os.File { - if s.logFile == "" { - // attach child's streams to parent's streams - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return nil - } - - out, err := os.OpenFile(s.logFile, os.O_WRONLY|os.O_APPEND, 0) - if err != nil { - log.Errorf("Failed to open log file %s: %v", s.logFile, err) - return nil - } - cmd.Stdout = out - cmd.Stderr = out - return out -} - -func normalizedVersion(version string) string { - versionString := version - if unicode.IsDigit(rune(versionString[0])) { - versionString = fmt.Sprintf("v%s", versionString) - } - return versionString -} - -// onTrayExit is called when the tray icon is closed. -func (s *serviceClient) onTrayExit() { - s.cancel() -} - -// getSrvClient connection to the service. -func (s *serviceClient) getSrvClient(timeout time.Duration) (proto.DaemonServiceClient, error) { - s.connLock.Lock() - defer s.connLock.Unlock() - if s.conn != nil { - return s.conn, nil - } - - ctx, cancel := context.WithTimeout(s.ctx, timeout) - defer cancel() - - conn, err := grpc.DialContext( - ctx, - strings.TrimPrefix(s.addr, "tcp://"), - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithBlock(), - grpc.WithUserAgent(desktop.GetUIUserAgent()), - ) - if err != nil { - return nil, fmt.Errorf("dial service: %w", err) - } - - s.conn = proto.NewDaemonServiceClient(conn) - return s.conn, nil -} - -// checkAndUpdateFeatures checks the current features and updates the UI accordingly -func (s *serviceClient) checkAndUpdateFeatures() { - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - return - } - - s.updateIndicationLock.Lock() - defer s.updateIndicationLock.Unlock() - - // DisableUpdateSettings is enforced server-side by the daemon gates - // on SetConfig + Login: any attempt to mutate config from UI or - // CLI is rejected at that layer. The UI deliberately keeps the - // Settings menu visible so the user can still inspect current - // values — read-only by virtue of the daemon refusing edits. - - // Update profile menu based on current features - if s.mProfile != nil { - profilesEnabled := features == nil || !features.DisableProfiles - if s.profilesEnabled != profilesEnabled { - s.profilesEnabled = profilesEnabled - s.mProfile.setEnabled(profilesEnabled) - } - } - - // Update networks and exit node menus based on current features. - // `networksEnabled` is the bare feature flag (read elsewhere, e.g. at - // connection-status transitions). `networksMenuEnabled` is the - // transition-cached state actually applied to the menu items — - // it folds in the connection state so a Connected client with the - // kill switch off shows the menus active, and only flips on diff. - s.networksEnabled = features == nil || !features.DisableNetworks - desiredNetworksMenu := s.networksEnabled && s.connected - if desiredNetworksMenu != s.networksMenuEnabled { - s.networksMenuEnabled = desiredNetworksMenu - if desiredNetworksMenu { - s.mNetworks.Enable() - s.mExitNode.Enable() - } else { - s.mNetworks.Disable() - s.mExitNode.Disable() - } - } -} - -// getFeatures from the daemon to determine which features are enabled/disabled. -func (s *serviceClient) getFeatures() (*proto.GetFeaturesResponse, error) { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return nil, fmt.Errorf("get client for features: %w", err) - } - - features, err := conn.GetFeatures(s.ctx, &proto.GetFeaturesRequest{}) - if err != nil { - return nil, fmt.Errorf("get features from daemon: %w", err) - } - - return features, nil -} - -// getSrvConfig from the service to show it in the settings window. -func (s *serviceClient) getSrvConfig() { - s.managementURL = profilemanager.DefaultManagementURL - - _, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return - } - - var cfg *profilemanager.Config - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user: %v", err) - return - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return - } - - srvCfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.ID.String(), - Username: currUser.Username, - }) - if err != nil { - log.Errorf("get config settings from server: %v", err) - return - } - - cfg = protoConfigToConfig(srvCfg) - - if cfg.ManagementURL.String() != "" { - s.managementURL = cfg.ManagementURL.String() - } - s.preSharedKey = cfg.PreSharedKey - s.RosenpassPermissive = cfg.RosenpassPermissive - s.interfaceName = cfg.WgIface - s.interfacePort = cfg.WgPort - s.mtu = cfg.MTU - - s.networkMonitor = *cfg.NetworkMonitor - s.disableDNS = cfg.DisableDNS - s.disableClientRoutes = cfg.DisableClientRoutes - s.disableServerRoutes = cfg.DisableServerRoutes - s.disableIPv6 = cfg.DisableIPv6 - s.blockLANAccess = cfg.BlockLANAccess - - if cfg.EnableSSHRoot != nil { - s.enableSSHRoot = *cfg.EnableSSHRoot - } - if cfg.EnableSSHSFTP != nil { - s.enableSSHSFTP = *cfg.EnableSSHSFTP - } - if cfg.EnableSSHLocalPortForwarding != nil { - s.enableSSHLocalPortForward = *cfg.EnableSSHLocalPortForwarding - } - if cfg.EnableSSHRemotePortForwarding != nil { - s.enableSSHRemotePortForward = *cfg.EnableSSHRemotePortForwarding - } - if cfg.DisableSSHAuth != nil { - s.disableSSHAuth = *cfg.DisableSSHAuth - } - if cfg.SSHJWTCacheTTL != nil { - s.sshJWTCacheTTL = *cfg.SSHJWTCacheTTL - } - - if s.showAdvancedSettings { - s.iMngURL.SetText(s.managementURL) - // PSK is rendered with an empty Text and a hint via the - // placeholder so the eye toggle never reveals literal asterisks - // (the daemon returns the "**********" sentinel — writing that - // into a PasswordEntry would surface the literal sentinel when - // the user unmasks the field). The placeholder communicates the - // configured / MDM-managed state without exposing any value. - s.iPreSharedKey.SetText("") - s.iPreSharedKey.SetPlaceHolder(preSharedKeyPlaceholder(srvCfg)) - s.iInterfaceName.SetText(cfg.WgIface) - s.iInterfacePort.SetText(strconv.Itoa(cfg.WgPort)) - if cfg.MTU != 0 { - s.iMTU.SetText(strconv.Itoa(int(cfg.MTU))) - } else { - s.iMTU.SetText("") - s.iMTU.SetPlaceHolder(strconv.Itoa(int(iface.DefaultMTU))) - } - s.sRosenpassPermissive.SetChecked(cfg.RosenpassPermissive) - // Re-baseline the enabled state on every refresh: when Rosenpass - // is on the checkbox is editable, when it's off the field is - // inert. Without an explicit Enable() here the control stays - // stuck disabled after a previous refresh (or an MDM unlock) had - // turned it off — applyMDMLocksToSettingsForm below adds the - // MDM lock on top of this baseline. - if cfg.RosenpassEnabled { - s.sRosenpassPermissive.Enable() - } else { - s.sRosenpassPermissive.Disable() - } - s.sNetworkMonitor.SetChecked(*cfg.NetworkMonitor) - s.sDisableDNS.SetChecked(cfg.DisableDNS) - s.sDisableClientRoutes.SetChecked(cfg.DisableClientRoutes) - s.sDisableServerRoutes.SetChecked(cfg.DisableServerRoutes) - s.sDisableIPv6.SetChecked(cfg.DisableIPv6) - s.sBlockLANAccess.SetChecked(cfg.BlockLANAccess) - if cfg.EnableSSHRoot != nil { - s.sEnableSSHRoot.SetChecked(*cfg.EnableSSHRoot) - } - if cfg.EnableSSHSFTP != nil { - s.sEnableSSHSFTP.SetChecked(*cfg.EnableSSHSFTP) - } - if cfg.EnableSSHLocalPortForwarding != nil { - s.sEnableSSHLocalPortForward.SetChecked(*cfg.EnableSSHLocalPortForwarding) - } - if cfg.EnableSSHRemotePortForwarding != nil { - s.sEnableSSHRemotePortForward.SetChecked(*cfg.EnableSSHRemotePortForwarding) - } - if cfg.DisableSSHAuth != nil { - s.sDisableSSHAuth.SetChecked(*cfg.DisableSSHAuth) - } - if cfg.SSHJWTCacheTTL != nil { - s.iSSHJWTCacheTTL.SetText(strconv.Itoa(*cfg.SSHJWTCacheTTL)) - } - } - - // MDM locks must run before the mNotifications-nil early return: - // the Settings window is rendered by a separate UI process launched - // with --settings (see handleAdvancedSettingsClick), and that child - // process does NOT run onReady — so its mNotifications is nil and - // the early return below skipped the lock pass entirely. - s.applyMDMLocks(srvCfg.MDMManagedFields) - - if s.mNotifications == nil { - return - } - if cfg.DisableNotifications != nil && *cfg.DisableNotifications { - s.mNotifications.Uncheck() - } else { - s.mNotifications.Check() - } - if s.eventManager != nil { - s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) - } -} - -func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config { - - var config profilemanager.Config - - if cfg.ManagementUrl != "" { - parsed, err := url.Parse(cfg.ManagementUrl) - if err != nil { - log.Errorf("parse management URL: %v", err) - } else { - config.ManagementURL = parsed - } - } - - if cfg.PreSharedKey != "" { - if cfg.PreSharedKey != censoredPreSharedKey { - config.PreSharedKey = cfg.PreSharedKey - } else { - config.PreSharedKey = "" - } - } - if cfg.AdminURL != "" { - parsed, err := url.Parse(cfg.AdminURL) - if err != nil { - log.Errorf("parse admin URL: %v", err) - } else { - config.AdminURL = parsed - } - } - - config.WgIface = cfg.InterfaceName - if cfg.WireguardPort >= 0 && cfg.WireguardPort <= 65535 { - config.WgPort = int(cfg.WireguardPort) - } else { - config.WgPort = iface.DefaultWgPort - } - - if cfg.Mtu != 0 { - config.MTU = uint16(cfg.Mtu) - } else { - config.MTU = iface.DefaultMTU - } - - config.DisableAutoConnect = cfg.DisableAutoConnect - config.ServerSSHAllowed = &cfg.ServerSSHAllowed - config.RosenpassEnabled = cfg.RosenpassEnabled - config.RosenpassPermissive = cfg.RosenpassPermissive - config.DisableNotifications = &cfg.DisableNotifications - config.LazyConnectionEnabled = cfg.LazyConnectionEnabled - config.BlockInbound = cfg.BlockInbound - config.NetworkMonitor = &cfg.NetworkMonitor - config.DisableDNS = cfg.DisableDns - config.DisableClientRoutes = cfg.DisableClientRoutes - config.DisableServerRoutes = cfg.DisableServerRoutes - config.DisableIPv6 = cfg.DisableIpv6 - config.BlockLANAccess = cfg.BlockLanAccess - - config.EnableSSHRoot = &cfg.EnableSSHRoot - config.EnableSSHSFTP = &cfg.EnableSSHSFTP - config.EnableSSHLocalPortForwarding = &cfg.EnableSSHLocalPortForwarding - config.EnableSSHRemotePortForwarding = &cfg.EnableSSHRemotePortForwarding - config.DisableSSHAuth = &cfg.DisableSSHAuth - - ttl := int(cfg.SshJWTCacheTTL) - config.SSHJWTCacheTTL = &ttl - - return &config -} - -func (s *serviceClient) onUpdateAvailable(newVersion string, enforced bool) { - s.updateIndicationLock.Lock() - defer s.updateIndicationLock.Unlock() - - s.isEnforcedUpdate = enforced - if enforced { - s.mUpdate.SetTitle("Install version " + newVersion) - } else { - s.lastNotifiedVersion = "" - s.mUpdate.SetTitle("Download latest version") - } - - s.mUpdate.Show() - s.isUpdateIconActive = true - - if s.connected { - systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) - } else { - systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) - } - - if enforced && s.lastNotifiedVersion != newVersion { - s.lastNotifiedVersion = newVersion - s.notifier.Send("Update available", "A new version "+newVersion+" is ready to install") - } -} - -// onSessionExpire sends a notification to the user when the session expires. -func (s *serviceClient) onSessionExpire() { - s.sendNotification = true - if s.sendNotification { - go s.eventHandler.runSelfCommand(s.ctx, "login-url", "true") - s.sendNotification = false - } -} - -// loadSettings loads the settings from the config file and updates the UI elements accordingly. -func (s *serviceClient) loadSettings() { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user: %v", err) - return - } - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return - } - - cfg, err := conn.GetConfig(s.ctx, &proto.GetConfigRequest{ - ProfileName: activeProf.ID.String(), - Username: currUser.Username, - }) - if err != nil { - log.Errorf("get config settings from server: %v", err) - return - } - - if cfg.ServerSSHAllowed { - s.mAllowSSH.Check() - } else { - s.mAllowSSH.Uncheck() - } - - if cfg.DisableAutoConnect { - s.mAutoConnect.Uncheck() - } else { - s.mAutoConnect.Check() - } - - if cfg.RosenpassEnabled { - s.mEnableRosenpass.Check() - } else { - s.mEnableRosenpass.Uncheck() - } - - if cfg.LazyConnectionEnabled { - s.mLazyConnEnabled.Check() - } else { - s.mLazyConnEnabled.Uncheck() - } - - if cfg.BlockInbound { - s.mBlockInbound.Check() - } else { - s.mBlockInbound.Uncheck() - } - - if cfg.DisableNotifications { - s.mNotifications.Uncheck() - } else { - s.mNotifications.Check() - } - if s.eventManager != nil { - s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) - } - s.applyMDMLocks(cfg.MDMManagedFields) -} - -// applyMDMLocks disables and badges any tray submenu item or settings- -// form widget whose underlying field is enforced by the active MDM -// policy. Called from loadSettings (submenu refresh) and from -// getSrvConfig (settings-window refresh). Locked items keep their value -// already set by the surrounding refresh code — this routine only -// flips the enabled state and the title suffix, never the value. -func (s *serviceClient) applyMDMLocks(managed []string) { - set := make(map[string]bool, len(managed)) - for _, k := range managed { - set[k] = true - } - s.mdmManagedFields = set - if len(managed) > 0 { - log.Infof("MDM-managed UI fields: %v", managed) - } - - type submenuTarget struct { - item *systray.MenuItem - title string - key string - } - for _, t := range []submenuTarget{ - {s.mAllowSSH, "Allow SSH", mdm.KeyAllowServerSSH}, - {s.mAutoConnect, "Connect on Startup", mdm.KeyDisableAutoConnect}, - {s.mEnableRosenpass, "Enable Quantum-Resistance", mdm.KeyRosenpassEnabled}, - {s.mBlockInbound, "Block Inbound Connections", mdm.KeyBlockInbound}, - } { - if t.item == nil { - continue - } - if set[t.key] { - t.item.SetTitle(t.title + " (MDM)") - t.item.Disable() - } else { - t.item.SetTitle(t.title) - t.item.Enable() - } - } - - s.applyMDMLocksToSettingsForm(set) -} - -// preSharedKeyPlaceholder returns the hint string shown in the PSK -// Entry's placeholder slot. The placeholder is the only signal the -// user gets that a PSK is configured, because the entry's Text is -// forced to empty to keep the password reveal toggle from leaking -// the daemon-returned "**********" redaction sentinel. Returns "" if -// no PSK is present, "MDM-managed" if the key is enforced by MDM, -// and "configured" otherwise. -func preSharedKeyPlaceholder(cfg *proto.GetConfigResponse) string { - if cfg == nil || cfg.PreSharedKey == "" { - return "" - } - for _, k := range cfg.MDMManagedFields { - if k == mdm.KeyPreSharedKey { - return "MDM-managed" - } - } - return "configured" -} - -// applyMDMLocksToSettingsForm disables the per-field input widgets in -// the advanced Settings window when the corresponding MDM key is set. -// For plain-text entries (Management URL, Interface Port) the visible -// value is suffixed with " (MDM)" so the user sees the lock indicator -// inline; for the password entry the suffix is skipped (a password -// widget renders every char as a dot and the indicator would not be -// readable). The widgets are created lazily by showSettingsUI, so -// guard each ref against nil. -func (s *serviceClient) applyMDMLocksToSettingsForm(set map[string]bool) { - type entryTarget struct { - entry *widget.Entry - key string - inlineTag bool - } - for _, t := range []entryTarget{ - {s.iMngURL, mdm.KeyManagementURL, true}, - {s.iPreSharedKey, mdm.KeyPreSharedKey, false}, - {s.iInterfacePort, mdm.KeyWireguardPort, true}, - } { - if t.entry == nil { - continue - } - if set[t.key] { - if t.inlineTag && t.entry.Text != "" && !strings.HasSuffix(t.entry.Text, mdmFieldSuffix) { - t.entry.SetText(t.entry.Text + mdmFieldSuffix) - } - t.entry.Disable() - } else { - if t.inlineTag { - t.entry.SetText(strings.TrimSuffix(t.entry.Text, mdmFieldSuffix)) - } - t.entry.Enable() - } - } - type checkTarget struct { - check *widget.Check - key string - } - for _, t := range []checkTarget{ - {s.sDisableClientRoutes, mdm.KeyDisableClientRoutes}, - {s.sDisableServerRoutes, mdm.KeyDisableServerRoutes}, - } { - if t.check == nil { - continue - } - if set[t.key] { - t.check.Disable() - } else { - t.check.Enable() - } - } - if s.sRosenpassPermissive != nil && set[mdm.KeyRosenpassPermissive] { - // MDM lock layered on top of the Rosenpass-on/off baseline - // applied by getSrvConfig. No Enable() branch here: when the - // MDM key is removed, the next getSrvConfig refresh re-baselines - // the control on cfg.RosenpassEnabled and brings it back if - // Rosenpass is on. - s.sRosenpassPermissive.Disable() - } -} - -// updateConfig updates the configuration parameters -// based on the values selected in the settings window. -func (s *serviceClient) updateConfig() error { - disableAutoStart := !s.mAutoConnect.Checked() - sshAllowed := s.mAllowSSH.Checked() - rosenpassEnabled := s.mEnableRosenpass.Checked() - lazyConnectionEnabled := s.mLazyConnEnabled.Checked() - blockInbound := s.mBlockInbound.Checked() - notificationsDisabled := !s.mNotifications.Checked() - - activeProf, err := s.profileManager.GetActiveProfile() - if err != nil { - log.Errorf("get active profile: %v", err) - return err - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user: %v", err) - return err - } - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return err - } - - req := proto.SetConfigRequest{ - ProfileName: activeProf.ID.String(), - Username: currUser.Username, - DisableAutoConnect: &disableAutoStart, - ServerSSHAllowed: &sshAllowed, - RosenpassEnabled: &rosenpassEnabled, - LazyConnectionEnabled: &lazyConnectionEnabled, - BlockInbound: &blockInbound, - DisableNotifications: ¬ificationsDisabled, - } - - if _, err := conn.SetConfig(s.ctx, &req); err != nil { - log.Errorf("set config settings on server: %v", err) - return err - } - - return nil -} - -// showLoginURL creates a borderless window styled like a pop-up in the top-right corner using s.wLoginURL. -// It also starts a background goroutine that periodically checks if the client is already connected -// and closes the window if so. The goroutine can be cancelled by the returned CancelFunc, and it is -// also cancelled when the window is closed. -func (s *serviceClient) showLoginURL() context.CancelFunc { - - // create a cancellable context for the background check goroutine - ctx, cancel := context.WithCancel(s.ctx) - - resIcon := fyne.NewStaticResource("netbird.png", iconAbout) - - if s.wLoginURL == nil { - s.wLoginURL = s.app.NewWindow("NetBird Session Expired") - s.wLoginURL.Resize(fyne.NewSize(400, 200)) - s.wLoginURL.SetIcon(resIcon) - } - // ensure goroutine is cancelled when the window is closed - s.wLoginURL.SetOnClosed(func() { cancel() }) - // add a description label - label := widget.NewLabel("Your NetBird session has expired.\nPlease re-authenticate to continue using NetBird.") - - btn := widget.NewButtonWithIcon("Re-authenticate", theme.ViewRefreshIcon(), func() { - - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return - } - - resp, err := s.login(ctx, false) - if err != nil { - log.Errorf("failed to fetch login URL: %v", err) - return - } - verificationURL := resp.VerificationURIComplete - if verificationURL == "" { - verificationURL = resp.VerificationURI - } - - if verificationURL == "" { - log.Error("no verification URL provided in the login response") - return - } - - if err := openURL(verificationURL); err != nil { - log.Errorf("failed to open login URL: %v", err) - return - } - - _, err = conn.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{UserCode: resp.UserCode}) - if err != nil { - log.Errorf("Waiting sso login failed with: %v", err) - label.SetText("Waiting login failed, please create \na debug bundle in the settings and contact support.") - return - } - - label.SetText("Re-authentication successful.\nReconnecting") - status, err := conn.Status(ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("get service status: %v", err) - return - } - - if status.Status == string(internal.StatusConnected) { - label.SetText("Already connected.\nClosing this window.") - time.Sleep(2 * time.Second) - s.wLoginURL.Close() - return - } - - _, err = conn.Up(ctx, &proto.UpRequest{}) - if err != nil { - label.SetText("Reconnecting failed, please create \na debug bundle in the settings and contact support.") - log.Errorf("Reconnecting failed with: %v", err) - return - } - - label.SetText("Connection successful.\nClosing this window.") - time.Sleep(time.Second) - - s.wLoginURL.Close() - }) - - img := canvas.NewImageFromResource(resIcon) - img.FillMode = canvas.ImageFillContain - img.SetMinSize(fyne.NewSize(64, 64)) - img.Resize(fyne.NewSize(64, 64)) - - // center the content vertically - content := container.NewVBox( - layout.NewSpacer(), - img, - label, - btn, - layout.NewSpacer(), - ) - s.wLoginURL.SetContent(container.NewCenter(content)) - - // start a goroutine to check connection status and close the window if connected - go func() { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return - } - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - continue - } - if status.Status == string(internal.StatusConnected) { - if s.wLoginURL != nil { - s.wLoginURL.Close() - } - return - } - } - } - }() - - s.wLoginURL.Show() - - // return cancel func so callers can stop the background goroutine if desired - return cancel -} - -func openURL(url string) error { - if browser := os.Getenv("BROWSER"); browser != "" { - return exec.Command(browser, url).Start() - } - - var err error - switch runtime.GOOS { - case "windows": - err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - case "darwin": - err = exec.Command("open", url).Start() - case "linux", "freebsd": - err = exec.Command("xdg-open", url).Start() - default: - err = fmt.Errorf("unsupported platform") - } - return err -} diff --git a/client/ui/const.go b/client/ui/const.go deleted file mode 100644 index 48619be75..000000000 --- a/client/ui/const.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -const ( - allowSSHMenuDescr = "Allow SSH connections" - autoConnectMenuDescr = "Connect automatically when the service starts" - quantumResistanceMenuDescr = "Enable post-quantum security via Rosenpass" - lazyConnMenuDescr = "[Experimental] Enable lazy connections" - blockInboundMenuDescr = "Block inbound connections to the local machine and routed networks" - notificationsMenuDescr = "Enable notifications" - advancedSettingsMenuDescr = "Advanced settings of the application" - debugBundleMenuDescr = "Create and open debug information bundle" - disabledMenuDescr = "" - networksMenuDescr = "Open the networks management window" - latestVersionMenuDescr = "Download latest version" - quitMenuDescr = "Quit the client app" -) diff --git a/client/ui/debug.go b/client/ui/debug.go deleted file mode 100644 index d3d4fa4f8..000000000 --- a/client/ui/debug.go +++ /dev/null @@ -1,730 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "fmt" - "path/filepath" - "strconv" - "sync" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/widget" - log "github.com/sirupsen/logrus" - "github.com/skratchdot/open-golang/open" - "google.golang.org/protobuf/types/known/durationpb" - - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/proto" - uptypes "github.com/netbirdio/netbird/upload-server/types" - "github.com/netbirdio/netbird/version" -) - -// Initial state for the debug collection -type debugInitialState struct { - wasDown bool - needsRestoreUp bool - logLevel proto.LogLevel - isLevelTrace bool -} - -// Debug collection parameters -type debugCollectionParams struct { - duration time.Duration - anonymize bool - systemInfo bool - upload bool - uploadURL string - enablePersistence bool - capture bool -} - -// UI components for progress tracking -type progressUI struct { - statusLabel *widget.Label - progressBar *widget.ProgressBar - uiControls []fyne.Disableable - window fyne.Window -} - -func (s *serviceClient) showDebugUI() { - w := s.app.NewWindow("NetBird Debug") - w.SetOnClosed(s.cancel) - w.Resize(fyne.NewSize(600, 500)) - w.SetFixedSize(true) - - anonymizeCheck := widget.NewCheck("Anonymize sensitive information (public IPs, domains, ...)", nil) - systemInfoCheck := widget.NewCheck("Include system information (routes, interfaces, ...)", nil) - systemInfoCheck.SetChecked(true) - captureCheck := widget.NewCheck("Include packet capture", nil) - uploadCheck := widget.NewCheck("Upload bundle automatically after creation", nil) - uploadCheck.SetChecked(true) - - uploadURLContainer, uploadURL := s.buildUploadSection(uploadCheck) - - debugModeContainer, runForDurationCheck, durationInput, noteLabel := s.buildDurationSection() - - statusLabel := widget.NewLabel("") - statusLabel.Hide() - progressBar := widget.NewProgressBar() - progressBar.Hide() - createButton := widget.NewButton("Create Debug Bundle", nil) - - uiControls := []fyne.Disableable{ - anonymizeCheck, systemInfoCheck, captureCheck, - uploadCheck, uploadURL, runForDurationCheck, durationInput, createButton, - } - - createButton.OnTapped = s.getCreateHandler( - statusLabel, progressBar, uploadCheck, uploadURL, - anonymizeCheck, systemInfoCheck, captureCheck, - runForDurationCheck, durationInput, uiControls, w, - ) - - content := container.NewVBox( - widget.NewLabel("Create a debug bundle to help troubleshoot issues with NetBird"), - widget.NewLabel(""), - anonymizeCheck, systemInfoCheck, captureCheck, - uploadCheck, uploadURLContainer, - widget.NewLabel(""), - debugModeContainer, noteLabel, - widget.NewLabel(""), - statusLabel, progressBar, createButton, - ) - - w.SetContent(container.NewPadded(content)) - w.Show() -} - -func (s *serviceClient) buildUploadSection(uploadCheck *widget.Check) (*fyne.Container, *widget.Entry) { - uploadURL := widget.NewEntry() - uploadURL.SetText(uptypes.DefaultBundleURL) - uploadURL.SetPlaceHolder("Enter upload URL") - - uploadURLContainer := container.NewVBox(widget.NewLabel("Debug upload URL:"), uploadURL) - - uploadCheck.OnChanged = func(checked bool) { - if checked { - uploadURLContainer.Show() - } else { - uploadURLContainer.Hide() - } - } - return uploadURLContainer, uploadURL -} - -func (s *serviceClient) buildDurationSection() (*fyne.Container, *widget.Check, *widget.Entry, *widget.Label) { - runForDurationCheck := widget.NewCheck("Run with trace logs before creating bundle", nil) - runForDurationCheck.SetChecked(true) - - forLabel := widget.NewLabel("for") - durationInput := widget.NewEntry() - durationInput.SetText("1") - minutesLabel := widget.NewLabel("minute") - durationInput.Validator = func(s string) error { - return validateMinute(s, minutesLabel) - } - - noteLabel := widget.NewLabel("Note: NetBird will be brought up and down during collection") - - runForDurationCheck.OnChanged = func(checked bool) { - if checked { - forLabel.Show() - durationInput.Show() - minutesLabel.Show() - noteLabel.Show() - } else { - forLabel.Hide() - durationInput.Hide() - minutesLabel.Hide() - noteLabel.Hide() - } - } - - modeContainer := container.NewHBox(runForDurationCheck, forLabel, durationInput, minutesLabel) - return modeContainer, runForDurationCheck, durationInput, noteLabel -} - -func validateMinute(s string, minutesLabel *widget.Label) error { - if val, err := strconv.Atoi(s); err != nil || val < 1 { - return fmt.Errorf("must be a number ≥ 1") - } - if s == "1" { - minutesLabel.SetText("minute") - } else { - minutesLabel.SetText("minutes") - } - return nil -} - -// disableUIControls disables the provided UI controls -func disableUIControls(controls []fyne.Disableable) { - for _, control := range controls { - control.Disable() - } -} - -// enableUIControls enables the provided UI controls -func enableUIControls(controls []fyne.Disableable) { - for _, control := range controls { - control.Enable() - } -} - -func (s *serviceClient) getCreateHandler( - statusLabel *widget.Label, - progressBar *widget.ProgressBar, - uploadCheck *widget.Check, - uploadURL *widget.Entry, - anonymizeCheck *widget.Check, - systemInfoCheck *widget.Check, - captureCheck *widget.Check, - runForDurationCheck *widget.Check, - duration *widget.Entry, - uiControls []fyne.Disableable, - w fyne.Window, -) func() { - return func() { - disableUIControls(uiControls) - statusLabel.Show() - - var url string - if uploadCheck.Checked { - url = uploadURL.Text - if url == "" { - statusLabel.SetText("Error: Upload URL is required when upload is enabled") - enableUIControls(uiControls) - return - } - } - - params := &debugCollectionParams{ - anonymize: anonymizeCheck.Checked, - systemInfo: systemInfoCheck.Checked, - capture: captureCheck.Checked, - upload: uploadCheck.Checked, - uploadURL: url, - enablePersistence: true, - } - - runForDuration := runForDurationCheck.Checked - if runForDuration { - minutes, err := time.ParseDuration(duration.Text + "m") - if err != nil { - statusLabel.SetText(fmt.Sprintf("Error: Invalid duration: %v", err)) - enableUIControls(uiControls) - return - } - params.duration = minutes - - statusLabel.SetText(fmt.Sprintf("Running in debug mode for %d minutes...", int(minutes.Minutes()))) - progressBar.Show() - progressBar.SetValue(0) - - go s.handleRunForDuration( - statusLabel, - progressBar, - uiControls, - w, - params, - ) - return - } - - statusLabel.SetText("Creating debug bundle...") - go s.handleDebugCreation( - params, - statusLabel, - uiControls, - w, - ) - } -} - -func (s *serviceClient) handleRunForDuration( - statusLabel *widget.Label, - progressBar *widget.ProgressBar, - uiControls []fyne.Disableable, - w fyne.Window, - params *debugCollectionParams, -) { - progressUI := &progressUI{ - statusLabel: statusLabel, - progressBar: progressBar, - uiControls: uiControls, - window: w, - } - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - handleError(progressUI, fmt.Sprintf("Failed to get client for debug: %v", err)) - return - } - - initialState, err := s.getInitialState(conn) - if err != nil { - handleError(progressUI, err.Error()) - return - } - - defer s.restoreServiceState(conn, initialState) - - if err := s.collectDebugData(conn, initialState, params, progressUI); err != nil { - handleError(progressUI, err.Error()) - return - } - - if err := s.createDebugBundleFromCollection(conn, params, progressUI); err != nil { - handleError(progressUI, err.Error()) - return - } - - progressUI.statusLabel.SetText("Bundle created successfully") -} - -// Get initial state of the service -func (s *serviceClient) getInitialState(conn proto.DaemonServiceClient) (*debugInitialState, error) { - statusResp, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - return nil, fmt.Errorf(" get status: %v", err) - } - - logLevelResp, err := conn.GetLogLevel(s.ctx, &proto.GetLogLevelRequest{}) - if err != nil { - return nil, fmt.Errorf("get log level: %v", err) - } - - wasDown := statusResp.Status != string(internal.StatusConnected) && - statusResp.Status != string(internal.StatusConnecting) - - initialLogLevel := logLevelResp.GetLevel() - initialLevelTrace := initialLogLevel >= proto.LogLevel_TRACE - - return &debugInitialState{ - wasDown: wasDown, - logLevel: initialLogLevel, - isLevelTrace: initialLevelTrace, - }, nil -} - -// Handle progress tracking during collection -func startProgressTracker(ctx context.Context, wg *sync.WaitGroup, duration time.Duration, progress *progressUI) { - progress.progressBar.Show() - progress.progressBar.SetValue(0) - - startTime := time.Now() - endTime := startTime.Add(duration) - wg.Add(1) - - go func() { - defer wg.Done() - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - remaining := time.Until(endTime) - if remaining <= 0 { - remaining = 0 - } - - elapsed := time.Since(startTime) - progressVal := float64(elapsed) / float64(duration) - if progressVal > 1.0 { - progressVal = 1.0 - } - - progress.progressBar.SetValue(progressVal) - progress.statusLabel.SetText(fmt.Sprintf("Running with trace logs... %s remaining", formatDuration(remaining))) - } - } - }() - -} - -func (s *serviceClient) configureServiceForDebug( - conn proto.DaemonServiceClient, - state *debugInitialState, - params *debugCollectionParams, -) { - if state.wasDown { - if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - log.Warnf("failed to bring service up: %v", err) - } else { - log.Info("Service brought up for debug") - time.Sleep(time.Second * 10) - } - } - - if !state.isLevelTrace { - if _, err := conn.SetLogLevel(s.ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel_TRACE}); err != nil { - log.Warnf("failed to set log level to TRACE: %v", err) - } else { - log.Info("Log level set to TRACE for debug") - } - } - - if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { - log.Warnf("failed to bring service down: %v", err) - } else { - state.needsRestoreUp = !state.wasDown - time.Sleep(time.Second) - } - - if params.enablePersistence { - if _, err := conn.SetSyncResponsePersistence(s.ctx, &proto.SetSyncResponsePersistenceRequest{ - Enabled: true, - }); err != nil { - log.Warnf("failed to enable sync response persistence: %v", err) - } else { - log.Info("Sync response persistence enabled for debug") - } - } - - if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - log.Warnf("failed to bring service back up: %v", err) - } else { - state.needsRestoreUp = false - time.Sleep(time.Second * 3) - } - - if _, err := conn.StartCPUProfile(s.ctx, &proto.StartCPUProfileRequest{}); err != nil { - log.Warnf("failed to start CPU profiling: %v", err) - } - - s.startBundleCaptureIfEnabled(conn, params) -} - -func (s *serviceClient) startBundleCaptureIfEnabled(conn proto.DaemonServiceClient, params *debugCollectionParams) { - if !params.capture { - return - } - - const maxCapture = 10 * time.Minute - timeout := params.duration + 30*time.Second - if timeout > maxCapture { - timeout = maxCapture - log.Warnf("packet capture clamped to %s (server maximum)", maxCapture) - } - if _, err := conn.StartBundleCapture(s.ctx, &proto.StartBundleCaptureRequest{ - Timeout: durationpb.New(timeout), - }); err != nil { - log.Warnf("failed to start bundle capture: %v", err) - } -} - -func (s *serviceClient) collectDebugData( - conn proto.DaemonServiceClient, - state *debugInitialState, - params *debugCollectionParams, - progress *progressUI, -) error { - ctx, cancel := context.WithTimeout(s.ctx, params.duration) - defer cancel() - var wg sync.WaitGroup - startProgressTracker(ctx, &wg, params.duration, progress) - - s.configureServiceForDebug(conn, state, params) - - wg.Wait() - progress.progressBar.Hide() - progress.statusLabel.SetText("Collecting debug data...") - - if _, err := conn.StopCPUProfile(s.ctx, &proto.StopCPUProfileRequest{}); err != nil { - log.Warnf("failed to stop CPU profiling: %v", err) - } - - if params.capture { - stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if _, err := conn.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil { - log.Warnf("failed to stop bundle capture: %v", err) - } - } - - return nil -} - -// Create the debug bundle with collected data -func (s *serviceClient) createDebugBundleFromCollection( - conn proto.DaemonServiceClient, - params *debugCollectionParams, - progress *progressUI, -) error { - progress.statusLabel.SetText("Creating debug bundle with collected logs...") - - request := &proto.DebugBundleRequest{ - Anonymize: params.anonymize, - SystemInfo: params.systemInfo, - CliVersion: version.NetbirdVersion(), - } - - if params.upload { - request.UploadURL = params.uploadURL - } - - resp, err := conn.DebugBundle(s.ctx, request) - if err != nil { - return fmt.Errorf("create debug bundle: %v", err) - } - - // Show appropriate dialog based on upload status - localPath := resp.GetPath() - uploadFailureReason := resp.GetUploadFailureReason() - uploadedKey := resp.GetUploadedKey() - - if params.upload { - if uploadFailureReason != "" { - showUploadFailedDialog(progress.window, localPath, uploadFailureReason) - } else { - showUploadSuccessDialog(s.app, progress.window, localPath, uploadedKey) - } - } else { - showBundleCreatedDialog(progress.window, localPath) - } - - enableUIControls(progress.uiControls) - return nil -} - -// Restore service to original state -func (s *serviceClient) restoreServiceState(conn proto.DaemonServiceClient, state *debugInitialState) { - if state.needsRestoreUp { - if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - log.Warnf("failed to restore up state: %v", err) - } else { - log.Info("Service state restored to up") - } - } - - if state.wasDown { - if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { - log.Warnf("failed to restore down state: %v", err) - } else { - log.Info("Service state restored to down") - } - } - - if !state.isLevelTrace { - if _, err := conn.SetLogLevel(s.ctx, &proto.SetLogLevelRequest{Level: state.logLevel}); err != nil { - log.Warnf("failed to restore log level: %v", err) - } else { - log.Info("Log level restored to original setting") - } - } -} - -// Handle errors during debug collection -func handleError(progress *progressUI, errMsg string) { - log.Errorf("%s", errMsg) - progress.statusLabel.SetText(errMsg) - progress.progressBar.Hide() - enableUIControls(progress.uiControls) -} - -func (s *serviceClient) handleDebugCreation( - params *debugCollectionParams, - statusLabel *widget.Label, - uiControls []fyne.Disableable, - w fyne.Window, -) { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("Failed to get client for debug: %v", err) - statusLabel.SetText(fmt.Sprintf("Error: %v", err)) - enableUIControls(uiControls) - return - } - - if params.capture { - if _, err := conn.StartBundleCapture(s.ctx, &proto.StartBundleCaptureRequest{ - Timeout: durationpb.New(30 * time.Second), - }); err != nil { - log.Warnf("failed to start bundle capture: %v", err) - } else { - defer func() { - stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if _, err := conn.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil { - log.Warnf("failed to stop bundle capture: %v", err) - } - }() - time.Sleep(2 * time.Second) - } - } - - resp, err := s.createDebugBundle(params.anonymize, params.systemInfo, params.uploadURL) - if err != nil { - log.Errorf("Failed to create debug bundle: %v", err) - statusLabel.SetText(fmt.Sprintf("Error creating bundle: %v", err)) - enableUIControls(uiControls) - return - } - - localPath := resp.GetPath() - uploadFailureReason := resp.GetUploadFailureReason() - uploadedKey := resp.GetUploadedKey() - - if params.upload { - if uploadFailureReason != "" { - showUploadFailedDialog(w, localPath, uploadFailureReason) - } else { - showUploadSuccessDialog(s.app, w, localPath, uploadedKey) - } - } else { - showBundleCreatedDialog(w, localPath) - } - - enableUIControls(uiControls) - statusLabel.SetText("Bundle created successfully") -} - -func (s *serviceClient) createDebugBundle(anonymize bool, systemInfo bool, uploadURL string) (*proto.DebugBundleResponse, error) { - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - return nil, fmt.Errorf("get client: %v", err) - } - - request := &proto.DebugBundleRequest{ - Anonymize: anonymize, - SystemInfo: systemInfo, - CliVersion: version.NetbirdVersion(), - } - - if uploadURL != "" { - request.UploadURL = uploadURL - } - - resp, err := conn.DebugBundle(s.ctx, request) - if err != nil { - return nil, fmt.Errorf("failed to create debug bundle via daemon: %v", err) - } - - return resp, nil -} - -// formatDuration formats a duration in HH:MM:SS format -func formatDuration(d time.Duration) string { - d = d.Round(time.Second) - h := d / time.Hour - d %= time.Hour - m := d / time.Minute - d %= time.Minute - s := d / time.Second - return fmt.Sprintf("%02d:%02d:%02d", h, m, s) -} - -// createButtonWithAction creates a button with the given label and action -func createButtonWithAction(label string, action func()) *widget.Button { - button := widget.NewButton(label, action) - return button -} - -// showUploadFailedDialog displays a dialog when upload fails -func showUploadFailedDialog(w fyne.Window, localPath, failureReason string) { - content := container.NewVBox( - widget.NewLabel(fmt.Sprintf("Bundle upload failed:\n%s\n\n"+ - "A local copy was saved at:\n%s", failureReason, localPath)), - ) - - customDialog := dialog.NewCustom("Upload Failed", "Cancel", content, w) - - buttonBox := container.NewHBox( - createButtonWithAction("Open file", func() { - log.Infof("Attempting to open local file: %s", localPath) - if openErr := open.Start(localPath); openErr != nil { - log.Errorf("Failed to open local file '%s': %v", localPath, openErr) - dialog.ShowError(fmt.Errorf("open the local file:\n%s\n\nError: %v", localPath, openErr), w) - } - }), - createButtonWithAction("Open folder", func() { - folderPath := filepath.Dir(localPath) - log.Infof("Attempting to open local folder: %s", folderPath) - if openErr := open.Start(folderPath); openErr != nil { - log.Errorf("Failed to open local folder '%s': %v", folderPath, openErr) - dialog.ShowError(fmt.Errorf("open the local folder:\n%s\n\nError: %v", folderPath, openErr), w) - } - }), - ) - - content.Add(buttonBox) - customDialog.Show() -} - -// showUploadSuccessDialog displays a dialog when upload succeeds -func showUploadSuccessDialog(a fyne.App, w fyne.Window, localPath, uploadedKey string) { - log.Infof("Upload key: %s", uploadedKey) - keyEntry := widget.NewEntry() - keyEntry.SetText(uploadedKey) - keyEntry.Disable() - - content := container.NewVBox( - widget.NewLabel("Bundle uploaded successfully!"), - widget.NewLabel(""), - widget.NewLabel("Upload key:"), - keyEntry, - widget.NewLabel(""), - widget.NewLabel(fmt.Sprintf("Local copy saved at:\n%s", localPath)), - ) - - customDialog := dialog.NewCustom("Upload Successful", "OK", content, w) - - copyBtn := createButtonWithAction("Copy key", func() { - a.Clipboard().SetContent(uploadedKey) - log.Info("Upload key copied to clipboard") - }) - - buttonBox := createButtonBox(localPath, w, copyBtn) - content.Add(buttonBox) - customDialog.Show() -} - -// showBundleCreatedDialog displays a dialog when bundle is created without upload -func showBundleCreatedDialog(w fyne.Window, localPath string) { - content := container.NewVBox( - widget.NewLabel(fmt.Sprintf("Bundle created locally at:\n%s\n\n"+ - "Administrator privileges may be required to access the file.", localPath)), - ) - - customDialog := dialog.NewCustom("Debug Bundle Created", "Cancel", content, w) - - buttonBox := createButtonBox(localPath, w, nil) - content.Add(buttonBox) - customDialog.Show() -} - -func createButtonBox(localPath string, w fyne.Window, elems ...fyne.Widget) *fyne.Container { - box := container.NewHBox() - for _, elem := range elems { - box.Add(elem) - } - - fileBtn := createButtonWithAction("Open file", func() { - log.Infof("Attempting to open local file: %s", localPath) - if openErr := open.Start(localPath); openErr != nil { - log.Errorf("Failed to open local file '%s': %v", localPath, openErr) - dialog.ShowError(fmt.Errorf("open the local file:\n%s\n\nError: %v", localPath, openErr), w) - } - }) - - folderBtn := createButtonWithAction("Open folder", func() { - folderPath := filepath.Dir(localPath) - log.Infof("Attempting to open local folder: %s", folderPath) - if openErr := open.Start(folderPath); openErr != nil { - log.Errorf("Failed to open local folder '%s': %v", folderPath, openErr) - dialog.ShowError(fmt.Errorf("open the local folder:\n%s\n\nError: %v", folderPath, openErr), w) - } - }) - - box.Add(fileBtn) - box.Add(folderBtn) - - return box -} diff --git a/client/ui/dock_darwin.go b/client/ui/dock_darwin.go new file mode 100644 index 000000000..dd8c60073 --- /dev/null +++ b/client/ui/dock_darwin.go @@ -0,0 +1,70 @@ +//go:build darwin + +package main + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Cocoa +#import + +static int lastDockState = -1; + +static void refreshDockPolicy(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + Class cls = NSClassFromString(@"WebviewWindow"); + if (cls == nil) { + return; + } + int visible = 0; + for (NSWindow *w in [NSApp windows]) { + if ([w isKindOfClass:cls] && [w isVisible]) { + visible = 1; + break; + } + } + if (visible == lastDockState) { + return; + } + lastDockState = visible; + + // Set application to "Regular" and show dock icon (when visible) or to "Accessory" (when hidden) + if (visible) { + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; + [NSApp activateIgnoringOtherApps:YES]; + } else { + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; + } + }); +} + +static int dockObserverInstalled = 0; + +static void initDockObserver(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (dockObserverInstalled) { + return; + } + dockObserverInstalled = 1; + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + void (^trigger)(NSNotification *) = ^(NSNotification *_) { + refreshDockPolicy(); + }; + + [nc addObserverForName:NSWindowDidChangeOcclusionStateNotification + object:nil + queue:nil + usingBlock:trigger]; + [nc addObserverForName:NSWindowWillCloseNotification + object:nil + queue:nil + usingBlock:trigger]; + + refreshDockPolicy(); + }); +} +*/ +import "C" + +func initDockObserver() { + C.initDockObserver() +} diff --git a/client/ui/dock_other.go b/client/ui/dock_other.go new file mode 100644 index 000000000..0ace89552 --- /dev/null +++ b/client/ui/dock_other.go @@ -0,0 +1,7 @@ +//go:build !darwin && !android && !ios && !freebsd && !js + +package main + +func initDockObserver() { + // macOS-only; Linux and Windows taskbar entries already gate on window visibility natively. +} diff --git a/client/ui/event/event.go b/client/ui/event/event.go deleted file mode 100644 index 3b43fdc7f..000000000 --- a/client/ui/event/event.go +++ /dev/null @@ -1,184 +0,0 @@ -package event - -import ( - "context" - "fmt" - "slices" - "strings" - "sync" - "time" - - "github.com/cenkalti/backoff/v4" - log "github.com/sirupsen/logrus" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - - "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/client/ui/desktop" -) - -// Notifier sends desktop notifications. Defined here so the event package -// does not depend on fyne or the platform-specific notifier implementation. -type Notifier interface { - Send(title, body string) -} - -type Handler func(*proto.SystemEvent) - -type Manager struct { - notifier Notifier - addr string - - mu sync.Mutex - ctx context.Context - cancel context.CancelFunc - enabled bool - handlers []Handler -} - -func NewManager(notifier Notifier, addr string) *Manager { - return &Manager{ - notifier: notifier, - addr: addr, - } -} - -func (e *Manager) Start(ctx context.Context) { - e.mu.Lock() - e.ctx, e.cancel = context.WithCancel(ctx) - e.mu.Unlock() - - expBackOff := backoff.WithContext(&backoff.ExponentialBackOff{ - InitialInterval: time.Second, - RandomizationFactor: backoff.DefaultRandomizationFactor, - Multiplier: backoff.DefaultMultiplier, - MaxInterval: 10 * time.Second, - MaxElapsedTime: 0, - Stop: backoff.Stop, - Clock: backoff.SystemClock, - }, ctx) - - if err := backoff.Retry(e.streamEvents, expBackOff); err != nil { - log.Errorf("event stream ended: %v", err) - } -} - -func (e *Manager) streamEvents() error { - e.mu.Lock() - ctx := e.ctx - e.mu.Unlock() - - client, err := getClient(e.addr) - if err != nil { - return fmt.Errorf("create client: %w", err) - } - - stream, err := client.SubscribeEvents(ctx, &proto.SubscribeRequest{}) - if err != nil { - return fmt.Errorf("failed to subscribe to events: %w", err) - } - - log.Info("subscribed to daemon events") - defer func() { - log.Info("unsubscribed from daemon events") - }() - - for { - event, err := stream.Recv() - if err != nil { - return fmt.Errorf("error receiving event: %w", err) - } - e.handleEvent(event) - } -} - -func (e *Manager) Stop() { - e.mu.Lock() - defer e.mu.Unlock() - if e.cancel != nil { - e.cancel() - } -} - -func (e *Manager) SetNotificationsEnabled(enabled bool) { - e.mu.Lock() - defer e.mu.Unlock() - e.enabled = enabled -} - -func (e *Manager) handleEvent(event *proto.SystemEvent) { - e.mu.Lock() - enabled := e.enabled - handlers := slices.Clone(e.handlers) - e.mu.Unlock() - - if event.UserMessage != "" && (enabled || event.Severity == proto.SystemEvent_CRITICAL) && !isV6DefaultRoutePartner(event) { - title := e.getEventTitle(event) - body := event.UserMessage - id := event.Metadata["id"] - if id != "" { - body += fmt.Sprintf(" ID: %s", id) - } - e.notifier.Send(title, body) - } - - for _, handler := range handlers { - go handler(event) - } -} - -func (e *Manager) AddHandler(handler Handler) { - e.mu.Lock() - defer e.mu.Unlock() - e.handlers = append(e.handlers, handler) -} - -// isV6DefaultRoutePartner reports whether the event is the IPv6 half of a -// paired v4/v6 default-route event. Management always pairs ::/0 with 0.0.0.0/0 -// for exit nodes, so the v4 partner already drives the user-facing toast and -// the v6 one is suppressed to avoid a duplicate notification. -func isV6DefaultRoutePartner(event *proto.SystemEvent) bool { - return event.Category == proto.SystemEvent_NETWORK && event.Metadata["network"] == "::/0" -} - -func (e *Manager) getEventTitle(event *proto.SystemEvent) string { - var prefix string - switch event.Severity { - case proto.SystemEvent_CRITICAL: - prefix = "Critical" - case proto.SystemEvent_ERROR: - prefix = "Error" - case proto.SystemEvent_WARNING: - prefix = "Warning" - default: - prefix = "Info" - } - - var category string - switch event.Category { - case proto.SystemEvent_DNS: - category = "DNS" - case proto.SystemEvent_NETWORK: - category = "Network" - case proto.SystemEvent_AUTHENTICATION: - category = "Authentication" - case proto.SystemEvent_CONNECTIVITY: - category = "Connectivity" - default: - category = "System" - } - - return fmt.Sprintf("%s: %s", prefix, category) -} - -func getClient(addr string) (proto.DaemonServiceClient, error) { - conn, err := grpc.NewClient( - strings.TrimPrefix(addr, "tcp://"), - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithUserAgent(desktop.GetUIUserAgent()), - ) - if err != nil { - return nil, err - } - return proto.NewDaemonServiceClient(conn), nil -} diff --git a/client/ui/event_handler.go b/client/ui/event_handler.go deleted file mode 100644 index 876fcef5f..000000000 --- a/client/ui/event_handler.go +++ /dev/null @@ -1,326 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - - "fyne.io/systray" - log "github.com/sirupsen/logrus" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/version" -) - -type eventHandler struct { - client *serviceClient -} - -func newEventHandler(client *serviceClient) *eventHandler { - return &eventHandler{ - client: client, - } -} - -func (h *eventHandler) listen(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - case <-h.client.mUp.ClickedCh: - h.handleConnectClick() - case <-h.client.mDown.ClickedCh: - h.handleDisconnectClick() - case <-h.client.mAllowSSH.ClickedCh: - h.handleAllowSSHClick() - case <-h.client.mAutoConnect.ClickedCh: - h.handleAutoConnectClick() - case <-h.client.mEnableRosenpass.ClickedCh: - h.handleRosenpassClick() - case <-h.client.mLazyConnEnabled.ClickedCh: - h.handleLazyConnectionClick() - case <-h.client.mBlockInbound.ClickedCh: - h.handleBlockInboundClick() - case <-h.client.mAdvancedSettings.ClickedCh: - h.handleAdvancedSettingsClick() - case <-h.client.mCreateDebugBundle.ClickedCh: - h.handleCreateDebugBundleClick() - case <-h.client.mQuit.ClickedCh: - h.handleQuitClick() - return - case <-h.client.mGitHub.ClickedCh: - h.handleGitHubClick() - case <-h.client.mUpdate.ClickedCh: - h.handleUpdateClick() - case <-h.client.mNetworks.ClickedCh: - h.handleNetworksClick() - case <-h.client.mNotifications.ClickedCh: - h.handleNotificationsClick() - case <-systray.TrayOpenedCh: - h.client.updateExitNodes() - } - } -} - -func (h *eventHandler) handleConnectClick() { - h.client.mUp.Disable() - - if h.client.connectCancel != nil { - h.client.connectCancel() - } - - connectCtx, connectCancel := context.WithCancel(h.client.ctx) - h.client.connectCancel = connectCancel - - go func() { - defer connectCancel() - - if err := h.client.menuUpClick(connectCtx); err != nil { - st, ok := status.FromError(err) - if errors.Is(err, context.Canceled) || (ok && st.Code() == codes.Canceled) { - log.Debugf("connect operation cancelled by user") - } else { - h.client.notifier.Send("Error", "Failed to connect") - log.Errorf("connect failed: %v", err) - } - } - - if err := h.client.updateStatus(); err != nil { - log.Debugf("failed to update status after connect: %v", err) - } - }() -} - -func (h *eventHandler) handleDisconnectClick() { - h.client.mDown.Disable() - h.client.cancelExitNodeRetry() - - if h.client.connectCancel != nil { - log.Debugf("cancelling ongoing connect operation") - h.client.connectCancel() - h.client.connectCancel = nil - } - - go func() { - if err := h.client.menuDownClick(); err != nil { - st, ok := status.FromError(err) - if !errors.Is(err, context.Canceled) && !(ok && st.Code() == codes.Canceled) { - h.client.notifier.Send("Error", "Failed to disconnect") - log.Errorf("disconnect failed: %v", err) - } else { - log.Debugf("disconnect cancelled or already disconnecting") - } - } - - if err := h.client.updateStatus(); err != nil { - log.Debugf("failed to update status after disconnect: %v", err) - } - }() -} - -func (h *eventHandler) handleAllowSSHClick() { - h.toggleCheckbox(h.client.mAllowSSH) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mAllowSSH) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update SSH settings") - } - -} - -func (h *eventHandler) handleAutoConnectClick() { - h.toggleCheckbox(h.client.mAutoConnect) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mAutoConnect) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update auto-connect settings") - } -} - -func (h *eventHandler) handleRosenpassClick() { - h.toggleCheckbox(h.client.mEnableRosenpass) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mEnableRosenpass) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update Rosenpass settings") - } -} - -func (h *eventHandler) handleLazyConnectionClick() { - h.toggleCheckbox(h.client.mLazyConnEnabled) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mLazyConnEnabled) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update lazy connection settings") - } -} - -func (h *eventHandler) handleBlockInboundClick() { - h.toggleCheckbox(h.client.mBlockInbound) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mBlockInbound) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update block inbound settings") - } -} - -func (h *eventHandler) handleNotificationsClick() { - h.toggleCheckbox(h.client.mNotifications) - if err := h.updateConfigWithErr(); err != nil { - h.toggleCheckbox(h.client.mNotifications) // revert checkbox state on error - log.Errorf("failed to update config: %v", err) - h.client.notifier.Send("Error", "Failed to update notifications settings") - } else if h.client.eventManager != nil { - h.client.eventManager.SetNotificationsEnabled(h.client.mNotifications.Checked()) - } - -} - -func (h *eventHandler) handleAdvancedSettingsClick() { - h.client.mAdvancedSettings.Disable() - go func() { - defer h.client.mAdvancedSettings.Enable() - defer h.client.getSrvConfig() - h.runSelfCommand(h.client.ctx, "settings") - }() -} - -func (h *eventHandler) handleCreateDebugBundleClick() { - h.client.mCreateDebugBundle.Disable() - go func() { - defer h.client.mCreateDebugBundle.Enable() - h.runSelfCommand(h.client.ctx, "debug") - }() -} - -func (h *eventHandler) handleQuitClick() { - systray.Quit() -} - -func (h *eventHandler) handleGitHubClick() { - if err := openURL("https://github.com/netbirdio/netbird"); err != nil { - log.Errorf("failed to open GitHub URL: %v", err) - } -} - -func (h *eventHandler) handleUpdateClick() { - h.client.updateIndicationLock.Lock() - enforced := h.client.isEnforcedUpdate - h.client.updateIndicationLock.Unlock() - - if !enforced { - if err := openURL(version.DownloadUrl()); err != nil { - log.Errorf("failed to open download URL: %v", err) - } - return - } - - // prevent blocking against a busy server - h.client.mUpdate.Disable() - go func() { - defer h.client.mUpdate.Enable() - conn, err := h.client.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get service client for update: %v", err) - _ = openURL(version.DownloadUrl()) - return - } - - resp, err := conn.TriggerUpdate(h.client.ctx, &proto.TriggerUpdateRequest{}) - if err != nil { - log.Errorf("TriggerUpdate failed: %v", err) - _ = openURL(version.DownloadUrl()) - return - } - if !resp.Success { - log.Errorf("TriggerUpdate failed: %s", resp.ErrorMsg) - _ = openURL(version.DownloadUrl()) - return - } - - log.Infof("update triggered via daemon") - }() -} - -func (h *eventHandler) handleNetworksClick() { - h.client.mNetworks.Disable() - go func() { - defer h.client.mNetworks.Enable() - h.runSelfCommand(h.client.ctx, "networks") - }() -} - -func (h *eventHandler) toggleCheckbox(item *systray.MenuItem) { - if item.Checked() { - item.Uncheck() - } else { - item.Check() - } -} - -func (h *eventHandler) updateConfigWithErr() error { - if err := h.client.updateConfig(); err != nil { - return err - } - - return nil -} - -func (h *eventHandler) runSelfCommand(ctx context.Context, command string, args ...string) { - proc, err := os.Executable() - if err != nil { - log.Errorf("error getting executable path: %v", err) - return - } - - // Build the full command arguments - cmdArgs := []string{ - fmt.Sprintf("--%s=true", command), - fmt.Sprintf("--daemon-addr=%s", h.client.addr), - } - cmdArgs = append(cmdArgs, args...) - - cmd := exec.CommandContext(ctx, proc, cmdArgs...) - - if out := h.client.attachOutput(cmd); out != nil { - defer func() { - if err := out.Close(); err != nil { - log.Errorf("error closing log file %s: %v", h.client.logFile, err) - } - }() - } - - log.Printf("running command: %s", cmd.String()) - - if err := cmd.Run(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - log.Printf("command '%s' failed with exit code %d", cmd.String(), exitErr.ExitCode()) - } - return - } - - log.Printf("command '%s' completed successfully", cmd.String()) -} - -func (h *eventHandler) logout(ctx context.Context) error { - client, err := h.client.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf("failed to get service client: %w", err) - } - - _, err = client.Logout(ctx, &proto.LogoutRequest{}) - if err != nil { - return fmt.Errorf("logout failed: %w", err) - } - - h.client.getSrvConfig() - - return nil -} diff --git a/client/ui/font_bsd.go b/client/ui/font_bsd.go deleted file mode 100644 index 139f38f40..000000000 --- a/client/ui/font_bsd.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build freebsd || openbsd || netbsd || dragonfly - -package main - -import ( - "os" - "runtime" - - log "github.com/sirupsen/logrus" -) - -func (s *serviceClient) setDefaultFonts() { - paths := []string{ - "/usr/local/share/fonts/TTF/DejaVuSans.ttf", - "/usr/local/share/fonts/dejavu/DejaVuSans.ttf", - "/usr/local/share/noto/NotoSans-Regular.ttf", - "/usr/local/share/fonts/noto/NotoSans-Regular.ttf", - "/usr/local/share/fonts/liberation-fonts-ttf/LiberationSans-Regular.ttf", - } - - for _, fontPath := range paths { - if _, err := os.Stat(fontPath); err == nil { - os.Setenv("FYNE_FONT", fontPath) - log.Debugf("Using font: %s", fontPath) - return - } - } - - log.Errorf("Failed to find any suitable font files for %s", runtime.GOOS) -} diff --git a/client/ui/font_darwin.go b/client/ui/font_darwin.go deleted file mode 100644 index cafb72f59..000000000 --- a/client/ui/font_darwin.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "os" - - log "github.com/sirupsen/logrus" -) - -const defaultFontPath = "/Library/Fonts/Arial Unicode.ttf" - -func (s *serviceClient) setDefaultFonts() { - if _, err := os.Stat(defaultFontPath); err != nil { - log.Errorf("Failed to find default font file: %v", err) - return - } - - os.Setenv("FYNE_FONT", defaultFontPath) -} diff --git a/client/ui/font_linux.go b/client/ui/font_linux.go deleted file mode 100644 index 4aa92494a..000000000 --- a/client/ui/font_linux.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !386 - -package main - -func (s *serviceClient) setDefaultFonts() { - //TODO: Linux Multiple Language Support -} diff --git a/client/ui/font_windows.go b/client/ui/font_windows.go deleted file mode 100644 index 6346a9fb9..000000000 --- a/client/ui/font_windows.go +++ /dev/null @@ -1,90 +0,0 @@ -package main - -import ( - "os" - "path" - "unsafe" - - log "github.com/sirupsen/logrus" - "golang.org/x/sys/windows" -) - -func (s *serviceClient) setDefaultFonts() { - defaultFontPath := s.getWindowsFontFilePath() - - if _, err := os.Stat(defaultFontPath); err != nil { - log.Errorf("Failed to find default font file: %v", err) - return - } - - os.Setenv("FYNE_FONT", defaultFontPath) -} - -func (s *serviceClient) getWindowsFontFilePath() string { - var ( - fontFolder = "C:/Windows/Fonts" - fontMapping = map[string]string{ - "default": "Segoeui.ttf", - "zh-CN": "Segoeui.ttf", - "am-ET": "Ebrima.ttf", - "nirmala": "Nirmala.ttf", - "chr-CHER-US": "Gadugi.ttf", - "zh-HK": "Segoeui.ttf", - "zh-TW": "Segoeui.ttf", - "km-KH": "Leelawui.ttf", - "ko-KR": "Malgun.ttf", - "th-TH": "Leelawui.ttf", - "ti-ET": "Ebrima.ttf", - } - nirMalaLang = []string{ - "as-IN", - "bn-BD", - "bn-IN", - "gu-IN", - "hi-IN", - "kn-IN", - "kok-IN", - "ml-IN", - "mr-IN", - "ne-NP", - "or-IN", - "pa-IN", - "si-LK", - "ta-IN", - "te-IN", - } - ) - - // getUserDefaultLocaleName.Call() panics if the func is not found - defer func() { - if r := recover(); r != nil { - log.Errorf("Recovered from panic: %v", r) - } - }() - - kernel32 := windows.NewLazySystemDLL("kernel32.dll") - getUserDefaultLocaleName := kernel32.NewProc("GetUserDefaultLocaleName") - - buf := make([]uint16, 85) // LOCALE_NAME_MAX_LENGTH is usually 85 - r, _, err := getUserDefaultLocaleName.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) - // returns 0 on failure, err is always non-nil - // https://learn.microsoft.com/en-us/windows/win32/api/winnls/nf-winnls-getuserdefaultlocalename - if r == 0 { - log.Errorf("GetUserDefaultLocaleName call failed: %v", err) - return path.Join(fontFolder, fontMapping["default"]) - } - - defaultLanguage := windows.UTF16ToString(buf) - - for _, lang := range nirMalaLang { - if defaultLanguage == lang { - return path.Join(fontFolder, fontMapping["nirmala"]) - } - } - - if font, ok := fontMapping[defaultLanguage]; ok { - return path.Join(fontFolder, font) - } - - return path.Join(fontFolder, fontMapping["default"]) -} diff --git a/client/ui/frontend/.prettierignore b/client/ui/frontend/.prettierignore new file mode 100644 index 000000000..c78cb7cc3 --- /dev/null +++ b/client/ui/frontend/.prettierignore @@ -0,0 +1,7 @@ +dist +build +node_modules +pnpm-lock.yaml +wailsjs +*.min.js +*.min.css diff --git a/client/ui/frontend/.prettierrc b/client/ui/frontend/.prettierrc new file mode 100644 index 000000000..e47a94f56 --- /dev/null +++ b/client/ui/frontend/.prettierrc @@ -0,0 +1,12 @@ +{ + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "arrowParens": "always", + "endOfLine": "lf", + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindFunctions": ["cn", "clsx", "cva", "tw"] +} diff --git a/client/ui/frontend/WAILS-API.md b/client/ui/frontend/WAILS-API.md new file mode 100644 index 000000000..494812d35 --- /dev/null +++ b/client/ui/frontend/WAILS-API.md @@ -0,0 +1,296 @@ +# Wails Go API reference (frontend) + +Reference for every binding method and model shape exposed to the frontend. Generated from `client/ui/services/*.go` via `wails3 generate bindings -clean=true -ts` — regenerate after any Go-side change. Authoritative source is always `bindings/github.com/netbirdio/netbird/client/ui/services/*.ts`. + +Every method returns `$CancellablePromise` (a Wails3 wrapper around `Promise`). Call `.cancel()` to abort the underlying gRPC call; in practice we just `await` and let it run. + +## Imports + +```ts +// Services +import { + Connection, Peers, ProfileSwitcher, Profiles, + Settings, Networks, Forwarding, Debug, Update, WindowManager, + I18n, Preferences, +} from "@bindings/services"; + +// Models (types-only) +import type { + Status, PeerStatus, PeerLink, LocalPeer, SystemEvent, + Profile, ProfileRef, ActiveProfile, + Config, ConfigParams, SetConfigParams, Features, + Network, SelectNetworksParams, + ForwardingRule, PortInfo, PortRange, + LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams, + DebugBundleParams, DebugBundleResult, LogLevel, + UpdateResult, UpdateAvailable, UpdateProgress, +} from "@bindings/services/models.js"; + +// i18n / preferences models live in sibling packages, not services/models +import { LanguageCode, type Language } from "@bindings/i18n/models.js"; +import type { UIPreferences } from "@bindings/preferences/models.js"; +``` + +## Push events + +Subscribe with `Events.On(name, handler)` from `@wailsio/runtime`. Handlers receive `{ data: }`. + +| Event | Payload | Fires on | +|---|---|---| +| `netbird:status` | `Status` | Daemon SubscribeStatus snapshot — connection-state change, peer-list change, address change, mgmt/signal flip. Synthetic `StatusDaemonUnavailable` is emitted when the gRPC socket is unreachable, and a synthetic `Connecting` is emitted at the start of an active profile switch. | +| `netbird:event` | `SystemEvent` | One push per daemon SubscribeEvents item (DNS / network / authentication / connectivity / system). Used by the tray for OS toasts; the TS side reads events through `Status.events` instead. | +| `netbird:update:available` | `UpdateAvailable` | Daemon detected a new version (fan-out of the `new_version_available` metadata key). | +| `netbird:preferences:changed` | `{ language: string }` | Fires after every successful `Preferences.SetLanguage` (including the caller's own window). `src/lib/i18n.ts` subscribes and calls `i18next.changeLanguage`. | +| `netbird:update:progress` | `UpdateProgress` | Daemon enforced-update install progress (`action: "show"` etc.). | +| `browser-login:cancel` | (none) | Either the user closed the `BrowserLogin` window (Go-emitted) or the page's Cancel button (frontend-emitted). | +| `trigger-login` | (none) | Reserved by the tray for asking the frontend to start an SSO flow. `layouts/ConnectionStatusSwitch.tsx` subscribes and runs `startLogin()`; no Go-side emitter today. | + +The two stream loops behind `netbird:status` and `netbird:event` start automatically — `main.go` calls `peers.Watch(context.Background())` at boot. `Peers.Watch` is still exported but the frontend doesn't need to invoke it. + +## `Connection` + +```ts +Connection.Login(p: LoginParams): Promise +Connection.WaitSSOLogin(p: WaitSSOParams): Promise // returns email +Connection.Up(p: UpParams): Promise // async on the daemon +Connection.Down(): Promise +Connection.Logout(p: LogoutParams): Promise +Connection.OpenURL(url: string): Promise // honors $BROWSER +``` + +`Login` Down-resets the daemon first to dislodge a stale `WaitSSOLogin` (so a previously abandoned SSO flow doesn't fail the next attempt). `Up` always uses async mode — status flows back through `netbird:status`. **Do not call `Up` on an `Idle` / `NeedsLogin` daemon** — the daemon's internal 50s `waitForUp` will block and return `DeadlineExceeded`. + +Full SSO sequence: `Login` → if `result.needsSsoLogin`, open `result.verificationUriComplete` via `OpenURL` + `WindowManager.OpenBrowserLogin(uri)` → `WaitSSOLogin({ userCode })` → `Up({})`. The canonical implementation is `startLogin()` in `layouts/ConnectionStatusSwitch.tsx`. + +## `Peers` + +```ts +Peers.Get(): Promise // one-shot snapshot +Peers.Watch(): Promise // already invoked from main.go +Peers.BeginProfileSwitch(): Promise +Peers.CancelProfileSwitch(): Promise +``` + +`BeginProfileSwitch` and `CancelProfileSwitch` are normally driven by `ProfileSwitcher` / the tray, not the frontend. + +## `ProfileSwitcher` + +```ts +ProfileSwitcher.SwitchActive(p: ProfileRef): Promise +``` + +The single entry point both tray and frontend should use for profile flips. Applies the reconnect policy below, mirrors the switch into the user-side `profilemanager` (so the CLI's `netbird up` reads a consistent active profile), and drives the optimistic-Connecting paint via `Peers.BeginProfileSwitch`. + +Reconnect policy (driven by `prevStatus` captured at entry): + +| Previous status | Action | Optimistic UI | Suppressed events until new flow | +|---|---|---|---| +| Connected | Switch + Down + Up | Connecting (synthetic) | Connected, Idle | +| Connecting | Switch + Down + Up | Connecting (unchanged) | Connected, Idle | +| NeedsLogin / LoginFailed / SessionExpired | Switch + Down | (no change) | — | +| Idle | Switch only | (no change) | — | + +## `Profiles` + +```ts +Profiles.Username(): Promise // current OS username +Profiles.List(username: string): Promise +Profiles.GetActive(): Promise +Profiles.Switch(p: ProfileRef): Promise // raw daemon RPC; prefer ProfileSwitcher.SwitchActive +Profiles.Add(p: ProfileRef): Promise +Profiles.Remove(p: ProfileRef): Promise +``` + +`Profile.email` is populated by the **UI process** reading the per-profile state file (`~/Library/Application Support/netbird/.state.json` on macOS), not by the daemon — the daemon runs as root and can't read user-owned files. + +## `Settings` + +```ts +Settings.GetConfig(p: ConfigParams): Promise +Settings.SetConfig(p: SetConfigParams): Promise // partial update +Settings.GetFeatures(): Promise // operator-disabled UI sections +``` + +`SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` and `adminUrl` are always-string for historical reasons. + +**PSK mask quirk:** `GetConfig` returns existing pre-shared keys as `"**********"`. If you send the mask back, `wgtypes.ParseKey` fails on the next connect. `SettingsContext.save` drops the field when it equals `"**********"`. See `modules/settings/SettingsContext.tsx`. + +`SetConfigParams` carries one field that `Config` does not: `disableFirewall`. There's no current GET path for it. + +## `Networks` + +```ts +Networks.List(): Promise +Networks.Select(p: SelectNetworksParams): Promise +Networks.Deselect(p: SelectNetworksParams): Promise +``` + +`SelectNetworksParams.append=true` merges into the existing selection; `false` replaces. `all=true` ignores `networkIds` and targets every network (Select-All / Deselect-All). + +Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side. + +## `Forwarding` + +```ts +Forwarding.List(): Promise +``` + +`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`). + +## `Debug` + +```ts +Debug.GetLogLevel(): Promise +Debug.SetLogLevel(lvl: LogLevel): Promise +Debug.Bundle(p: DebugBundleParams): Promise +Debug.RevealFile(path: string): Promise // OS file-manager focus +``` + +**Log level case sensitivity bug:** `proto.LogLevel_value` is keyed on uppercase enum names (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`, `"UNKNOWN"`). `Debug.SetLogLevel` calls `proto.LogLevel_value[lvl.Level]` and falls back to `INFO` on miss. `useDebugBundle` currently passes `"trace"` (lowercase), which silently maps to `INFO` — the trace-capture flow doesn't actually raise the log level today. To raise to trace, pass `{ level: "TRACE" }`. Fix on the cleanup list. + +`Debug.Bundle` uploads when `uploadUrl != ""`. Result fields: `path` (local copy), `uploadedKey` (set on success), `uploadFailureReason` (set on upload failure — the local copy is still saved). + +## `Update` + +```ts +Update.Trigger(): Promise // start the install +Update.GetInstallerResult(): Promise // poll the outcome (long-running) +Update.Quit(): Promise // 100ms later, app.Quit() +``` + +Typical enforced-update flow on the `/update` route: call `Trigger` once, then poll `GetInstallerResult` every 2s with a 15-minute total timeout. On `success: true` call `Quit`. On `success: false` show `errorMsg`. If the gRPC poll itself starts failing for `DAEMON_DOWN_GRACE_MS` (5s), treat that as success and quit too — the installer commonly takes the daemon offline mid-upgrade. See `pages/Update.tsx` for the canonical implementation. + +## `WindowManager` + +```ts +WindowManager.OpenSettings(): Promise +WindowManager.OpenBrowserLogin(uri: string): Promise // uri appended as ?uri=… +WindowManager.CloseBrowserLogin(): Promise +WindowManager.OpenError(title: string, message: string): Promise // custom branded error window; both query-escaped as ?title=…&message=… +WindowManager.CloseError(): Promise +``` + +Prefer `errorDialog({Title, Message})` from `lib/dialogs.ts` over calling `OpenError` directly — it's the app's single error surface (the old native MessageBox wrapper now routes here). Both strings must be pre-localised. + +Both auxiliary windows are created on first open and destroyed on close (mutex-guarded singleton). The BrowserLogin window's red-X close fires the `browser-login:cancel` event so `startLogin()` can tear down the pending daemon `WaitSSOLogin`. + +## `I18n` + +```ts +I18n.Languages(): Promise // from _index.json +I18n.Bundle(code: LanguageCode): Promise> // full key→text map +``` + +Source of truth is `client/ui/i18n/locales/` (shared with the Go tray). The frontend's i18next bootstrap doesn't need `I18n.Bundle` at runtime (bundles are statically imported by Vite via the glob in `src/lib/i18n.ts`), but the language picker reads `I18n.Languages()` so the list matches `_index.json` without duplicating it in TS. + +## `Preferences` + +```ts +Preferences.Get(): Promise // { language: string } +Preferences.SetLanguage(code: LanguageCode): Promise // rejects on unknown code +``` + +`SetLanguage` validates against the loaded `i18n.Bundle`, persists to `os.UserConfigDir()/netbird/ui-preferences.json`, and emits `netbird:preferences:changed`. The frontend's `src/lib/i18n.ts` listens to that event and calls `i18next.changeLanguage` so a flip in any window paints in all of them. Missing preferences file → defaults to `en`, written on first read. + +## Daemon `Status.status` values + +Mirror `internal.Status*` in `client/internal/state.go` plus the synthetic UI label: + +| Value | Meaning | +|---|---| +| `"Idle"` | Tunnel down (Up never invoked or Down completed) | +| `"Connecting"` | Up in progress | +| `"Connected"` | Tunnel up | +| `"NeedsLogin"` | Fresh install or token cleared; needs Login → SSO → Up | +| `"LoginFailed"` | Previous Login attempt errored | +| `"SessionExpired"` | SSO token expired; needs re-Login | +| `"DaemonUnavailable"` | **Synthetic** — UI side, emitted when the daemon gRPC socket is unreachable. Not a real daemon enum. | + +The tray also reads a tray-only synthetic `"Error"` for icon purposes; the frontend doesn't see that. + +## Model field reference + +`Status`: +```ts +{ status, daemonVersion: string; + management: PeerLink; signal: PeerLink; + local: LocalPeer; + peers: PeerStatus[]; + events: SystemEvent[]; } +``` + +`PeerLink`: `{ url: string; connected: boolean; error?: string }`. + +`LocalPeer`: `{ ip, pubKey, fqdn: string; networks: string[] }`. + +`PeerStatus`: +```ts +{ ip, pubKey, fqdn, connStatus: string; + connStatusUpdateUnix: number; + relayed: boolean; + localIceCandidateType, remoteIceCandidateType: string; // pion: "host"|"srflx"|"prflx"|"relay"|"" + localIceCandidateEndpoint, remoteIceCandidateEndpoint: string; + bytesRx, bytesTx, latencyMs, lastHandshakeUnix: number; + relayAddress: string; // set when relayed=true + rosenpassEnabled: boolean; + networks: string[]; } +``` + +`SystemEvent`: +```ts +{ id: string; + severity: string; // "info"|"warning"|"error"|"critical" (lowercased proto enum, "SystemEvent_" prefix stripped) + category: string; // "network"|"dns"|"authentication"|"connectivity"|"system" (same casing rules) + message: string; // technical / log line + userMessage: string; // human-friendly — render this + timestamp: number; // unix seconds + metadata: Record; } // keys: "new_version_available", "enforced", "id", "network", "version", "progress_window", … +``` + +`Profile`: `{ name: string; isActive: boolean; email: string }`. + +`Config` (read-only mirror, all required): +```ts +{ managementUrl, adminUrl, configFile, logFile, preSharedKey, interfaceName: string; + wireguardPort, mtu, sshJwtCacheTtl: number; + disableAutoConnect, serverSshAllowed, + rosenpassEnabled, rosenpassPermissive, + disableNotifications, lazyConnectionEnabled, blockInbound, + networkMonitor, disableClientRoutes, disableServerRoutes, + disableDns, disableIpv6, blockLanAccess, + enableSshRoot, enableSshSftp, + enableSshLocalPortForwarding, enableSshRemotePortForwarding, + disableSshAuth: boolean; } +``` + +`SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` / `adminUrl` as required strings. + +`Features`: `{ disableProfiles, disableUpdateSettings, disableNetworks: boolean }`. + +`Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record }`. + +`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`. + +`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated). + +`PortRange`: `{ start, end: number }` (inclusive). + +`LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`. + +`LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`. + +`WaitSSOParams`: `{ userCode, hostname: string }`. Resolves to the user's email. + +`UpParams` / `LogoutParams` / `ProfileRef` / `ConfigParams` / `ActiveProfile`: all `{ profileName, username: string }` (different names but same shape — kept distinct by Wails for clarity). + +`DebugBundleParams`: `{ anonymize, systemInfo: boolean; uploadUrl: string; logFileCount: number }`. + +`DebugBundleResult`: `{ path, uploadedKey, uploadFailureReason: string }`. + +`LogLevel`: `{ level: string }` — **uppercase** proto enum name (`"TRACE"`, `"DEBUG"`, `"INFO"`, `"WARN"`, `"ERROR"`, `"PANIC"`, `"FATAL"`). + +`UpdateResult`: `{ success: boolean; errorMsg: string }`. + +`UpdateAvailable`: `{ version: string; enforced: boolean }`. + +`UpdateProgress`: `{ action: string; version: string }`. diff --git a/client/ui/frontend/eslint.config.js b/client/ui/frontend/eslint.config.js new file mode 100644 index 000000000..f00623b68 --- /dev/null +++ b/client/ui/frontend/eslint.config.js @@ -0,0 +1,75 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import jsxA11y from "eslint-plugin-jsx-a11y"; +import globals from "globals"; + +export default tseslint.config( + { + ignores: ["dist/**", "node_modules/**", "bindings/**", "sonar/**"], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["src/**/*.{ts,tsx}"], + plugins: { + react, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + "jsx-a11y": jsxA11y, + }, + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { ...globals.browser }, + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, + settings: { + react: { version: "detect" }, + }, + rules: { + // ----- a11y / semantic HTML (jsx-a11y recommended) ----- + ...jsxA11y.configs.recommended.rules, + "jsx-a11y/no-autofocus": ["warn", { ignoreNonDOM: true }], + + // ----- React ----- + ...react.configs.recommended.rules, + ...react.configs["jsx-runtime"].rules, + "react/prop-types": "off", + "react/jsx-no-target-blank": ["error", { allowReferrer: true }], + "react/self-closing-comp": "warn", + + // ----- React hooks ----- + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + + // ----- Vite / HMR (Fast Refresh) ----- + "react-refresh/only-export-components": "off", + + // ----- TypeScript ----- + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/consistent-type-imports": [ + "warn", + { prefer: "type-imports", fixStyle: "inline-type-imports" }, + ], + "@typescript-eslint/no-explicit-any": "warn", + + // ----- General correctness ----- + eqeqeq: ["error", "smart"], + "no-console": ["warn", { allow: ["warn", "error", "info"] }], + "no-debugger": "error", + "prefer-const": "warn", + }, + }, +); diff --git a/client/ui/frontend/index.html b/client/ui/frontend/index.html new file mode 100644 index 000000000..e62139956 --- /dev/null +++ b/client/ui/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + NetBird + + + +
+ + + diff --git a/client/ui/frontend/package.json b/client/ui/frontend/package.json new file mode 100644 index 000000000..dcef99ad3 --- /dev/null +++ b/client/ui/frontend/package.json @@ -0,0 +1,70 @@ +{ + "name": "netbird-ui", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build:dev": "tsc && vite build --minify false --mode development", + "build": "tsc && vite build --mode production", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "bindings": "cd .. && wails3 generate bindings -clean=true -ts", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"", + "lint": "eslint \"src/**/*.{ts,tsx}\"", + "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", + "check": "pnpm lint && pnpm typecheck && pnpm format:check", + "check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck", + "i18n:check": "node ../i18n/check-translations.mjs" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@radix-ui/react-visually-hidden": "^1.2.4", + "@wailsio/runtime": "latest", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "framer-motion": "^12.38.0", + "i18next": "^26.2.0", + "lucide-react": "^0.566.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-i18next": "^17.0.8", + "react-loading-skeleton": "^3.5.0", + "react-router-dom": "^7.1.3", + "react-virtuoso": "^4.12.5", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^25.6.0", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.39.4", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.6.0", + "postcss": "^8.5.1", + "prettier": "^3.8.3", + "prettier-plugin-tailwindcss": "^0.8.0", + "tailwindcss": "^3.4.17", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.3", + "typescript-eslint": "^8.61.1", + "vite": "^6.0.7" + }, + "packageManager": "pnpm@11.4.0+sha512.f0febc7e37552ab485494a914241b338e0b3580b93d54ce31f00933015880863129038a1b4ae4e414a0ee63ac35bf21197e990172c4a68256450b5636310968f" +} diff --git a/client/ui/frontend/pnpm-lock.yaml b/client/ui/frontend/pnpm-lock.yaml new file mode 100644 index 000000000..b6b3dd336 --- /dev/null +++ b/client/ui/frontend/pnpm-lock.yaml @@ -0,0 +1,5240 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-label': + specifier: ^2.1.8 + version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-radio-group': + specifier: ^1.3.8 + version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-scroll-area': + specifier: ^1.2.10 + version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-switch': + specifier: ^1.2.6 + version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': + specifier: ^1.2.4 + version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wailsio/runtime': + specifier: latest + version: 3.0.0-alpha.79 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + framer-motion: + specifier: ^12.38.0 + version: 12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + i18next: + specifier: ^26.2.0 + version: 26.3.0(typescript@5.9.3) + lucide-react: + specifier: ^0.566.0 + version: 0.566.0(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.0(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + react-loading-skeleton: + specifier: ^3.5.0 + version: 3.5.0(react@18.3.1) + react-router-dom: + specifier: ^7.1.3 + version: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-virtuoso: + specifier: ^4.12.5 + version: 4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.1 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@9.39.4(jiti@1.21.7)) + '@types/node': + specifier: ^25.6.0 + version: 25.9.1 + '@types/react': + specifier: ^18.3.18 + version: 18.3.29 + '@types/react-dom': + specifier: ^18.3.5 + version: 18.3.7(@types/react@18.3.29) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7)) + autoprefixer: + specifier: ^10.4.20 + version: 10.5.0(postcss@8.5.15) + eslint: + specifier: ^9.39.4 + version: 9.39.4(jiti@1.21.7) + eslint-plugin-jsx-a11y: + specifier: ^6.10.2 + version: 6.10.2(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-react: + specifier: ^7.37.5 + version: 7.37.5(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-react-refresh: + specifier: ^0.5.3 + version: 0.5.3(eslint@9.39.4(jiti@1.21.7)) + globals: + specifier: ^17.6.0 + version: 17.6.0 + postcss: + specifier: ^8.5.1 + version: 8.5.15 + prettier: + specifier: ^3.8.3 + version: 3.8.3 + prettier-plugin-tailwindcss: + specifier: ^0.8.0 + version: 0.8.0(prettier@3.8.3) + tailwindcss: + specifier: ^3.4.17 + version: 3.4.19 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.19) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.61.1 + version: 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + vite: + specifier: ^6.0.7 + version: 6.4.2(@types/node@25.9.1)(jiti@1.21.7) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.8': + resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-visually-hidden@1.2.4': + resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.29': + resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} + + '@typescript-eslint/eslint-plugin@8.61.1': + resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.61.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.61.1': + resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.61.1': + resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.61.1': + resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.61.1': + resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.61.1': + resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.61.1': + resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.61.1': + resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.61.1': + resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@wailsio/runtime@3.0.0-alpha.79': + resolution: {integrity: sha512-NITzxKmJsMEruc39L166lbPJVECxzcbdqpHVqOOF7Cu/7Zqk/e3B/gNpkUjhNyo5rVb3V1wpS8oEgLUmpu1cwA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.12.1: + resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.32: + resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.362: + resolution: {integrity: sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.3: + resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.1: + resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} + engines: {node: '>= 0.4'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.3: + resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + framer-motion@12.40.0: + resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + i18next@26.3.0: + resolution: {integrity: sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.566.0: + resolution: {integrity: sha512-b18qC/JAh1X9rVKlF5EtSIyumdIYuh78b0JShynZnHbcaWR4AW4oZyi8Ms/aQYVSnLPlAnMhug2hSr19BgVZAw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + motion-dom@12.40.0: + resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-tailwindcss@0.8.0: + resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-i18next@17.0.8: + resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-loading-skeleton@3.5.0: + resolution: {integrity: sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==} + peerDependencies: + react: '>=16.8.0' + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-dom@7.15.1: + resolution: {integrity: sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.15.1: + resolution: {integrity: sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-virtuoso@4.18.7: + resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==} + peerDependencies: + react: '>=16 || >=17 || >= 18 || >= 19' + react-dom: '>=16 || >=17 || >= 18 || >=19' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@2.6.1: + resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.61.1: + resolution: {integrity: sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@10.0.1(eslint@9.39.4(jiti@1.21.7))': + optionalDependencies: + eslint: 9.39.4(jiti@1.21.7) + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/utils@0.2.11': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-context@1.1.2(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-direction@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-id@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-slot@1.2.3(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-slot@1.2.4(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-use-size@1.1.1(@types/react@18.3.29)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.29 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + '@types/react-dom': 18.3.7(@types/react@18.3.29) + + '@radix-ui/rect@1.1.1': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@25.9.1': + dependencies: + undici-types: 7.24.6 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.29)': + dependencies: + '@types/react': 18.3.29 + + '@types/react@18.3.29': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/type-utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.1 + eslint: 9.39.4(jiti@1.21.7) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.1 + debug: 4.4.3 + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.61.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.61.1': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 + + '@typescript-eslint/tsconfig-utils@8.61.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@1.21.7) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.61.1': {} + + '@typescript-eslint/typescript-estree@8.61.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.61.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.61.1': + dependencies: + '@typescript-eslint/types': 8.61.1 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.2(@types/node@25.9.1)(jiti@1.21.7) + transitivePeerDependencies: + - supports-color + + '@wailsio/runtime@3.0.0-alpha.79': {} + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + autoprefixer@10.5.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001793 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.12.1: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.32: {} + + binary-extensions@2.3.0: {} + + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.362 + node-releases: 2.0.46 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001793: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + detect-node-es@1.1.0: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.362: {} + + emoji-regex@9.2.2: {} + + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.1 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.22 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.3: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.3 + + es-to-primitive@1.3.1: + dependencies: + es-abstract-get: 1.0.0 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@1.21.7)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.12.1 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.4(jiti@1.21.7) + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@1.21.7)): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 9.39.4(jiti@1.21.7) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.3(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.3 + eslint: 9.39.4(jiti@1.21.7) + estraverse: 5.3.0 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.7 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + fraction.js@5.3.4: {} + + framer-motion@12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + motion-dom: 12.40.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@17.6.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + i18next@26.3.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.3 + side-channel: 1.1.1 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.566.0(react@18.3.1): + dependencies: + react: 18.3.1 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + motion-dom@12.40.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-releases@2.0.46: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + possible-typed-array-names@1.1.0: {} + + postcss-import@15.1.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.15): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.15 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.15 + + postcss-nested@6.2.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-tailwindcss@0.8.0(prettier@3.8.3): + dependencies: + prettier: 3.8.3 + + prettier@3.8.3: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-i18next@17.0.8(i18next@26.3.0(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 26.3.0(typescript@5.9.3) + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + typescript: 5.9.3 + + react-is@16.13.1: {} + + react-loading-skeleton@3.5.0(react@18.3.1): + dependencies: + react: 18.3.1 + + react-refresh@0.17.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@18.3.29)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.3.29)(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.29 + + react-remove-scroll@2.7.2(@types/react@18.3.29)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.29)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.29)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.3.29)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.29)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.29 + + react-router-dom@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + + react-router@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + cookie: 1.1.1 + react: 18.3.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + + react-style-singleton@2.2.3(@types/react@18.3.29)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.29 + + react-virtuoso@4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + semver@7.8.4: {} + + set-cookie-parser@2.7.2: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + strip-json-comments@3.1.1: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@2.6.1: {} + + tailwindcss-animate@1.0.7(tailwindcss@3.4.19): + dependencies: + tailwindcss: 3.4.19 + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-import: 15.1.0(postcss@8.5.15) + postcss-js: 4.1.0(postcss@8.5.15) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15) + postcss-nested: 6.2.0(postcss@8.5.15) + postcss-selector-parser: 6.1.2 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@7.24.6: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@18.3.29)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.29 + + use-sidecar@1.1.3(@types/react@18.3.29)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.29 + + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + + util-deprecate@1.0.2: {} + + vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.9.1 + fsevents: 2.3.3 + jiti: 1.21.7 + + void-elements@3.1.0: {} + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.22 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/client/ui/frontend/pnpm-workspace.yaml b/client/ui/frontend/pnpm-workspace.yaml new file mode 100644 index 000000000..5ed0b5af0 --- /dev/null +++ b/client/ui/frontend/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/client/ui/frontend/postcss.config.js b/client/ui/frontend/postcss.config.js new file mode 100644 index 000000000..2aa7205d4 --- /dev/null +++ b/client/ui/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx new file mode 100644 index 000000000..7f1359510 --- /dev/null +++ b/client/ui/frontend/src/app.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import "./globals.css"; +import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; +import SessionExpirationDialog from "@/modules/session/SessionExpirationDialog.tsx"; +import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx"; +import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx"; +import ErrorDialog from "@/modules/error/ErrorDialog.tsx"; +import { AppLayout } from "@/layouts/AppLayout.tsx"; +import { MainPage } from "@/modules/main/MainPage.tsx"; +import { SettingsPage } from "@/modules/settings/SettingsPage.tsx"; +import { SkeletonTheme } from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { welcome } from "@/lib/welcome"; +import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx"; +import { initI18n } from "@/lib/i18n"; +import { initPlatform } from "@/lib/platform"; +import { initLogForwarding } from "@/lib/logs"; +import { initStallWatch } from "@/lib/stallwatch"; + +// Must run first so even init-time logs reach the Go log pipeline. +initLogForwarding(); + +initStallWatch(); + +welcome(); + +Promise.all([ + initI18n().catch((e) => { + console.error("i18n init failed:", e); + }), + initPlatform().catch((e) => { + console.error("platform init failed:", e); + }), +]).finally(() => { + ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + } + /> + } /> + } + /> + } /> + } /> + + }> + } /> + } /> + } /> + + + + + , + ); +}); diff --git a/client/ui/frontend/src/assets/fonts/inter-variable.ttf b/client/ui/frontend/src/assets/fonts/inter-variable.ttf new file mode 100644 index 000000000..4ab79e010 Binary files /dev/null and b/client/ui/frontend/src/assets/fonts/inter-variable.ttf differ diff --git a/client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf b/client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf new file mode 100644 index 000000000..b60e77f5d Binary files /dev/null and b/client/ui/frontend/src/assets/fonts/jetbrains-mono-variable.ttf differ diff --git a/client/ui/frontend/src/assets/img/tray-darwin.png b/client/ui/frontend/src/assets/img/tray-darwin.png new file mode 100644 index 000000000..75df803d8 Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-darwin.png differ diff --git a/client/ui/frontend/src/assets/img/tray-linux.png b/client/ui/frontend/src/assets/img/tray-linux.png new file mode 100644 index 000000000..08cea81af Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-linux.png differ diff --git a/client/ui/frontend/src/assets/img/tray-windows.png b/client/ui/frontend/src/assets/img/tray-windows.png new file mode 100644 index 000000000..cebbf6153 Binary files /dev/null and b/client/ui/frontend/src/assets/img/tray-windows.png differ diff --git a/client/ui/frontend/src/assets/logos/netbird-full.svg b/client/ui/frontend/src/assets/logos/netbird-full.svg new file mode 100644 index 000000000..f925d5761 --- /dev/null +++ b/client/ui/frontend/src/assets/logos/netbird-full.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/client/ui/frontend/src/assets/logos/netbird.svg b/client/ui/frontend/src/assets/logos/netbird.svg new file mode 100644 index 000000000..6254931c6 --- /dev/null +++ b/client/ui/frontend/src/assets/logos/netbird.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/ui/frontend/src/components/Badge.tsx b/client/ui/frontend/src/components/Badge.tsx new file mode 100644 index 000000000..c5e2b5f22 --- /dev/null +++ b/client/ui/frontend/src/components/Badge.tsx @@ -0,0 +1,43 @@ +import { forwardRef, type ComponentType, type HTMLAttributes } from "react"; +import type { LucideProps } from "lucide-react"; +import { cn } from "@/lib/cn"; + +export type BadgeVariant = "info" | "neutral" | "brand" | "success" | "warning" | "danger"; + +type Props = HTMLAttributes & { + variant?: BadgeVariant; + icon?: ComponentType; + iconSize?: number; +}; + +const VARIANT_CLASSES: Record = { + info: "bg-sky-900 border border-sky-700 text-sky-200", + neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200", + brand: "bg-netbird/15 border border-netbird/30 text-netbird", + success: "bg-green-900 border border-green-700 text-green-200", + warning: "bg-yellow-900 border border-yellow-700 text-yellow-200", + danger: "bg-red-900 border border-red-700 text-red-200", +}; + +export const Badge = forwardRef(function Badge( + { variant = "info", icon: Icon, iconSize = 10, className, children, ...rest }, + ref, +) { + return ( + + {Icon && } + {children} + + ); +}); + +export default Badge; diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx new file mode 100644 index 000000000..3cf681a1c --- /dev/null +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -0,0 +1,131 @@ +import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, Copy } from "lucide-react"; +import { cn } from "@/lib/cn"; + +const VARIANT_HOVER = { + default: "group-hover/copy:[&_*]:text-nb-gray-300", + bright: "group-hover/copy:[&_*]:text-nb-gray-200", +} as const; + +type CopyToClipboardVariant = keyof typeof VARIANT_HOVER; + +type CopyToClipboardProps = { + children: ReactNode; + message?: string; + size?: number; + iconAlignment?: "left" | "right"; + className?: string; + iconClassName?: string; + alwaysShowIcon?: boolean; + // wrap lets long content (a shell command, a path) break across lines + // instead of being truncated to one line. + wrap?: boolean; + variant?: CopyToClipboardVariant; + "aria-label"?: string; + tabIndex?: number; + onKeyDown?: (e: KeyboardEvent) => void; +}; + +export const CopyToClipboard = ({ + children, + message, + size = 10, + iconAlignment = "right", + className, + iconClassName, + alwaysShowIcon = false, + wrap = false, + variant = "default", + "aria-label": ariaLabel, + tabIndex = 0, + onKeyDown, +}: CopyToClipboardProps) => { + const { t } = useTranslation(); + const wrapperRef = useRef(null); + const [copied, setCopied] = useState(false); + const copyTimer = useRef | null>(null); + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); + + const handleClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + const text = message ?? wrapperRef.current?.innerText ?? ""; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 500); + } catch (e) { + console.warn("copy to clipboard failed", e); + } + }; + + const resolvedLabel = + ariaLabel ?? (message ? `${t("common.copy")} ${message}` : t("common.copy")); + + return ( + + ); +}; diff --git a/client/ui/frontend/src/components/DropdownMenu.tsx b/client/ui/frontend/src/components/DropdownMenu.tsx new file mode 100644 index 000000000..d43c37e1b --- /dev/null +++ b/client/ui/frontend/src/components/DropdownMenu.tsx @@ -0,0 +1,233 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { cva } from "class-variance-authority"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import * as React from "react"; +import { cn } from "@/lib/cn"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const menuItemVariants = cva("", { + variants: { + variant: { + default: + "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50", + danger: "text-red-500 hover:bg-red-900/20 hover:text-red-500 focus-visible:bg-red-900/20 focus-visible:text-red-500", + }, + }, + defaultVariants: { variant: "default" }, +}); + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + variant?: "default" | "danger"; + } +>(({ className, inset, children, variant, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + variant?: "default" | "danger"; + href?: string; + target?: string; + rel?: string; + } +>(({ className, inset, variant, onClick, href, target, rel, children, ...props }, ref) => ( + { + if (href) return; + e.preventDefault(); + e.stopPropagation(); + onClick?.(e); + }} + {...props} + > + {href ? ( + + {children} + + ) : ( + children + )} + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => ( + +); +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx new file mode 100644 index 000000000..7a30f8b33 --- /dev/null +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -0,0 +1,235 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Popover from "@radix-ui/react-popover"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Command } from "cmdk"; +import { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react"; +import { Preferences } from "@bindings/services"; +import { type LanguageCode, type Language } from "@bindings/i18n/models.js"; +import { HelpText } from "@/components/typography/HelpText"; +import { Label } from "@/components/typography/Label"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { loadLanguages } from "@/lib/i18n"; +import { cn } from "@/lib/cn"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/ + +const labelFor = (lang: Language): string => + lang.englishName && lang.englishName !== lang.displayName + ? `${lang.displayName} (${lang.englishName})` + : lang.displayName; + +export function LanguagePicker() { + const { t, i18n } = useTranslation(); + const [languages, setLanguages] = useState([]); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const isFocusVisible = useFocusVisible(); + + useEffect(() => { + let cancelled = false; + loadLanguages() + .then((list) => { + if (!cancelled) setLanguages(list); + }) + .catch((err: unknown) => console.error("load languages failed", err)); + return () => { + cancelled = true; + }; + }, []); + + const sorted = useMemo( + () => [...languages].sort((a, b) => a.displayName.localeCompare(b.displayName)), + [languages], + ); + + const current = useMemo( + () => + languages.find((l) => l.code === i18n.language) ?? + languages.find((l) => l.code === "en"), + [languages, i18n.language], + ); + + const handleTriggerKeyDown = (e: React.KeyboardEvent) => { + if (open) return; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setOpen(true); + } + }; + + const select = async (code: string) => { + setOpen(false); + if (busy || code === i18n.language) return; + setBusy(true); + try { + await Preferences.SetLanguage(code as LanguageCode); + } catch (e) { + await errorDialog({ + Title: t("settings.error.saveTitle"), + Message: formatErrorMessage(e), + }); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ + {t("settings.general.language.help")} +
+
+ + + + + + + + +
+
+ + +
+
+ + + + + +
+ {t("settings.general.language.empty")} +
+
+ + {sorted.map((lang) => { + const checked = lang.code === i18n.language; + return ( + void select(lang.code)} + className={cn( + "my-0.5 flex cursor-default items-center gap-2 rounded-md px-2 py-2 outline-none", + "text-xs font-semibold text-nb-gray-200", + "data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50", + )} + > + + {labelFor(lang)} + + + {checked && ( + + )} + + + ); + })} +
+
+ + + +
+
+
+
+
+
+
+ ); +} diff --git a/client/ui/frontend/src/components/ManagementServerSwitch.tsx b/client/ui/frontend/src/components/ManagementServerSwitch.tsx new file mode 100644 index 000000000..0083a767a --- /dev/null +++ b/client/ui/frontend/src/components/ManagementServerSwitch.tsx @@ -0,0 +1,38 @@ +import { useTranslation } from "react-i18next"; +import netbirdLogo from "@/assets/logos/netbird.svg"; +import { SwitchItem } from "@/components/switches/SwitchItem"; +import { SwitchItemGroup } from "@/components/switches/SwitchItemGroup"; +import { ManagementMode } from "@/hooks/useManagementUrl.ts"; + +type Props = { + value: ManagementMode; + onChange: (mode: ManagementMode) => void; + fullWidth?: boolean; +}; + +export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: Props) => { + const { t, i18n } = useTranslation(); + const itemClass = fullWidth ? "flex-1" : undefined; + return ( + onChange(v as ManagementMode)} + aria-label={t("settings.general.management.label")} + className={fullWidth ? "w-full" : undefined} + > + + {""} + {t("settings.general.management.cloud")} + + + {t("settings.general.management.selfHosted")} + + + ); +}; diff --git a/client/ui/frontend/src/components/SquareIcon.tsx b/client/ui/frontend/src/components/SquareIcon.tsx new file mode 100644 index 000000000..e904d2de5 --- /dev/null +++ b/client/ui/frontend/src/components/SquareIcon.tsx @@ -0,0 +1,37 @@ +import { type ComponentType } from "react"; +import { type LucideProps } from "lucide-react"; +import { cn } from "@/lib/cn"; + +export type SquareIconVariant = "default" | "info" | "warning" | "danger"; + +const variantClass: Record = { + default: "text-white", + info: "text-sky-400", + warning: "text-netbird", + danger: "text-red-500", +}; + +type SquareIconProps = { + icon: ComponentType; + iconSize?: number; + variant?: SquareIconVariant; + className?: string; +}; + +export const SquareIcon = ({ + icon: Icon, + iconSize = 18, + variant = "default", + className, +}: SquareIconProps) => ( +
+ +
+); diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx new file mode 100644 index 000000000..2c77ba139 --- /dev/null +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -0,0 +1,98 @@ +import { type ReactNode, useEffect, useRef, useState } from "react"; +import * as RTooltip from "@radix-ui/react-tooltip"; +import { cn } from "@/lib/cn"; + +type Props = { + content: ReactNode; + children: ReactNode; + side?: RTooltip.TooltipContentProps["side"]; + align?: RTooltip.TooltipContentProps["align"]; + delayDuration?: number; + sideOffset?: number; + alignOffset?: number; + interactive?: boolean; + keepOpenOnClick?: boolean; + contentClassName?: string; + closeDelay?: number; +}; + +export const Tooltip = ({ + content, + children, + side = "bottom", + align = "center", + delayDuration = 200, + sideOffset = 6, + alignOffset = 0, + interactive = false, + keepOpenOnClick = true, + contentClassName, + closeDelay = 0, +}: Props) => { + const [open, setOpen] = useState(false); + const hoveringRef = useRef(false); + const closeTimer = useRef | null>(null); + + const cancelClose = () => { + if (closeTimer.current) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + }; + const scheduleClose = () => { + cancelClose(); + if (closeDelay <= 0) { + setOpen(false); + return; + } + closeTimer.current = setTimeout(() => setOpen(false), closeDelay); + }; + useEffect(() => () => cancelClose(), []); + + const handleOpenChange = (next: boolean) => { + if (!next && keepOpenOnClick && hoveringRef.current) return; + if (next) cancelClose(); + setOpen(next); + }; + + return ( + + + { + hoveringRef.current = true; + cancelClose(); + }} + onPointerLeave={() => { + hoveringRef.current = false; + scheduleClose(); + }} + > + {children} + + + e.preventDefault()} + className={cn( + "z-50 select-none text-xs text-nb-gray-100 shadow-lg", + "data-[state=delayed-open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0", + !interactive && "pointer-events-none", + contentClassName ?? + "rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1", + )} + > + {content} + + + + + ); +}; diff --git a/client/ui/frontend/src/components/TruncatedText.tsx b/client/ui/frontend/src/components/TruncatedText.tsx new file mode 100644 index 000000000..5b2d2160c --- /dev/null +++ b/client/ui/frontend/src/components/TruncatedText.tsx @@ -0,0 +1,32 @@ +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { Tooltip } from "@/components/Tooltip"; + +type Props = { + text: string; + className?: string; + tooltipContent?: ReactNode; + delayDuration?: number; +}; + +export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [text]); + + const span = ( + + {text} + + ); + if (!overflowing) return span; + return ( + + {span} + + ); +}; diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx new file mode 100644 index 000000000..1aedf82a6 --- /dev/null +++ b/client/ui/frontend/src/components/VerticalTabs.tsx @@ -0,0 +1,98 @@ +import { type ComponentType, type ReactNode, forwardRef } from "react"; +import * as Tabs from "@radix-ui/react-tabs"; +import { type LucideProps } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; + +const Root = forwardRef>( + function VerticalTabsRoot({ className, ...props }, ref) { + return ( + + ); + }, +); + +const List = forwardRef(function VerticalTabsList( + { className, ...props }, + ref, +) { + return ( + + ); +}); + +type TriggerProps = Tabs.TabsTriggerProps & { + icon: ComponentType; + title: string; + iconSize?: number; + adornment?: ReactNode; +}; + +const Trigger = forwardRef(function VerticalTabsTrigger( + { icon: Icon, title, iconSize = 16, adornment, className, ...props }, + ref, +) { + const isFocusVisible = useFocusVisible(); + return ( + + + + {title} + + {adornment && ( +
+ {adornment} +
+ )} +
+ ); +}); + +const Content = forwardRef(function VerticalTabsContent( + { className, ...props }, + ref, +) { + return ( + + ); +}); + +export const VerticalTabs = Object.assign(Root, { List, Trigger, Content }); diff --git a/client/ui/frontend/src/components/buttons/Button.tsx b/client/ui/frontend/src/components/buttons/Button.tsx new file mode 100644 index 000000000..6b151c17b --- /dev/null +++ b/client/ui/frontend/src/components/buttons/Button.tsx @@ -0,0 +1,195 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Check, Copy, Loader2 } from "lucide-react"; +import { type ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react"; + +import { cn } from "@/lib/cn"; + +type ButtonVariants = VariantProps; + +interface ButtonProps extends ButtonHTMLAttributes, ButtonVariants { + disabled?: boolean; + stopPropagation?: boolean; + copy?: string; + loading?: boolean; +} + +const buttonVariants = cva( + [ + "relative", + "cursor-default select-none whitespace-nowrap text-sm font-medium shadow-sm focus:z-10 focus:outline-none focus:ring-2", + "inline-flex items-center justify-center gap-2 transition-colors focus:ring-offset-1", + "disabled:cursor-not-allowed disabled:opacity-40 dark:ring-offset-neutral-950/50 disabled:dark:text-nb-gray-300", + ], + { + variants: { + variant: { + default: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-white dark:focus:ring-zinc-800/50", + ], + primary: [ + "dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-white disabled:dark:bg-nb-gray-900", + "enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50", + ], + secondary: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:bg-nb-gray-910 dark:hover:text-white", + ], + secondaryLighter: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-white", + ], + subtle: [ + "border-nb-gray-200 bg-nb-gray-50 text-nb-gray-900 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60", + "dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40", + "dark:border-nb-gray-200 dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950", + ], + input: [ + "border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:text-gray-400 dark:hover:bg-nb-gray-900/80", + ], + dropdown: [ + "border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-nb-gray-900 dark:bg-nb-gray-900/40 dark:text-gray-400 dark:hover:bg-nb-gray-900/50", + ], + dotted: [ + "border-dashed border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-white", + ], + tertiary: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:border-gray-700/40 dark:bg-white dark:text-gray-800 dark:hover:bg-neutral-200 dark:focus:ring-zinc-800/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + ], + white: [ + "border-white bg-white text-gray-800 outline-none hover:bg-neutral-200 focus:ring-white/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + "disabled:dark:border-nb-gray-900 disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300", + ], + outline: [ + "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "dark:border-netbird dark:bg-transparent dark:text-netbird dark:hover:bg-nb-gray-900/30 dark:focus:ring-zinc-800/50", + ], + "danger-outline": [ + "dark:bg-transparent dark:text-red-500 enabled:dark:hover:border-red-800/50 enabled:hover:dark:bg-red-950/50 enabled:dark:focus:bg-red-950/40 enabled:dark:focus:ring-red-800/20", + ], + "danger-text": [ + "rounded-sm !px-0 !py-0 !shadow-none focus:ring-red-500/30 dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600", + ], + "default-outline": [ + "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", + "dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:border-nb-gray-800/50 dark:hover:bg-nb-gray-900/30 dark:hover:text-white", + "data-[state=open]:dark:border-nb-gray-800/50 data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:text-white", + ], + ghost: [ + "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", + "dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/30 dark:hover:text-white", + ], + danger: [ + "dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20", + ], + }, + size: { + xs: "px-3.5 py-2.5 text-xs", + xs2: "px-4 py-[1.1rem] text-[0.78rem] leading-[0]", + sm: "px-4 py-[9px] text-sm", + md: "px-4 py-[9px]", + lg: "px-4 py-[9px] text-lg", + }, + rounded: { + true: "rounded-md", + false: "", + }, + border: { + 0: "border", + 1: "border border-transparent", + 2: "border border-b-0 border-t-0", + }, + }, + }, +); + +export const Button = forwardRef(function Button( + { + variant = "default", + rounded = true, + border = 1, + size = "md", + stopPropagation = true, + type = "button", + children, + className, + onClick, + disabled, + copy, + loading = false, + ...props + }, + ref, +) { + const [copied, setCopied] = useState(false); + const copyTimer = useRef | null>(null); + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); + const iconSize = size === "xs" ? 12 : 14; + return ( + + ); +}); + +export default Button; diff --git a/client/ui/frontend/src/components/buttons/IconButton.tsx b/client/ui/frontend/src/components/buttons/IconButton.tsx new file mode 100644 index 000000000..3d36bc111 --- /dev/null +++ b/client/ui/frontend/src/components/buttons/IconButton.tsx @@ -0,0 +1,36 @@ +import { type ButtonHTMLAttributes, type ComponentType, forwardRef } from "react"; +import { type LucideProps } from "lucide-react"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { cn } from "@/lib/cn"; + +type Props = ButtonHTMLAttributes & { + icon: ComponentType; + iconSize?: number; + iconClassName?: string; +}; + +export const IconButton = forwardRef(function IconButton( + { icon: Icon, iconSize = 17, iconClassName, className, type = "button", disabled, ...props }, + ref, +) { + const isFocusVisible = useFocusVisible(); + return ( + + ); +}); diff --git a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx new file mode 100644 index 000000000..caf98c2c8 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx @@ -0,0 +1,35 @@ +import { type ReactNode, forwardRef } from "react"; +import { cn } from "@/lib/cn.ts"; +import { isMacOS } from "@/lib/platform.ts"; + +type ConfirmDialogProps = { + children: ReactNode; + "aria-label"?: string; + "aria-labelledby"?: string; +}; + +export const ConfirmDialog = forwardRef(function ConfirmDialog( + { children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy }, + ref, +) { + return ( + +
+ {children} +
+
+ ); +}); diff --git a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx new file mode 100644 index 000000000..241a69ec9 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx @@ -0,0 +1,84 @@ +import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import * as Dialog from "@/components/dialog/Dialog"; +import { Button } from "@/components/buttons/Button"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogActions } from "@/components/dialog/DialogActions"; + +type ConfirmModalProps = { + open: boolean; + title: ReactNode; + description: ReactNode; + confirmLabel: string; + cancelLabel?: string; + danger?: boolean; + busy?: boolean; + onConfirm: () => void; + onCancel: () => void; +}; + +export const ConfirmModal = ({ + open, + title, + description, + confirmLabel, + cancelLabel, + danger = false, + busy = false, + onConfirm, + onCancel, +}: ConfirmModalProps) => { + const { t } = useTranslation(); + const resolvedCancel = cancelLabel ?? t("common.cancel"); + + const srTitle = typeof title === "string" ? title : undefined; + const srDescription = typeof description === "string" ? description : undefined; + + return ( + { + if (!next && !busy) onCancel(); + }} + > + e.preventDefault()} + > +
+
+ {title} + + {description} + +
+ + + + + +
+
+
+ ); +}; diff --git a/client/ui/frontend/src/components/dialog/Dialog.tsx b/client/ui/frontend/src/components/dialog/Dialog.tsx new file mode 100644 index 000000000..fa8007d9f --- /dev/null +++ b/client/ui/frontend/src/components/dialog/Dialog.tsx @@ -0,0 +1,159 @@ +import { + forwardRef, + type ComponentPropsWithoutRef, + type ElementRef, + type HTMLAttributes, +} from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import { X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; + +export const Root = DialogPrimitive.Root; + +type OverlayProps = ComponentPropsWithoutRef & { + exitAnimation?: boolean; +}; + +const Overlay = forwardRef, OverlayProps>( + function DialogOverlay({ className, exitAnimation = false, ...props }, ref) { + return ( + + ); + }, +); + +type ContentProps = ComponentPropsWithoutRef & { + showClose?: boolean; + maxWidthClass?: string; + exitAnimation?: boolean; + srTitle?: string; + srDescription?: string; +}; + +export const Content = forwardRef, ContentProps>( + function DialogContent( + { + className, + children, + showClose = true, + maxWidthClass = "max-w-md", + exitAnimation = false, + srTitle, + srDescription, + ...props + }, + ref, + ) { + const { t } = useTranslation(); + return ( + + + e.stopPropagation()} + {...props} + > + + + {srTitle ?? t("common.netbird")} + + + {srDescription && ( + + + {srDescription} + + + )} + {children} + {showClose && ( + + + + )} + + + + ); + }, +); + +export const Title = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(function DialogTitle({ className, ...props }, ref) { + return ( + + ); +}); + +export const Description = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(function DialogDescription({ className, ...props }, ref) { + return ( + + ); +}); + +type FooterProps = HTMLAttributes & { + separator?: boolean; +}; + +export const Footer = ({ className, separator = true, ...props }: FooterProps) => ( +
+
*]:w-full sm:[&>*]:w-auto", + "px-8 pt-6", + className, + )} + {...props} + /> +
+); diff --git a/client/ui/frontend/src/components/dialog/DialogActions.tsx b/client/ui/frontend/src/components/dialog/DialogActions.tsx new file mode 100644 index 000000000..3aa3a1fe5 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/DialogActions.tsx @@ -0,0 +1,13 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +type DialogActionsProps = { + children: ReactNode; + className?: string; +}; + +export const DialogActions = ({ children, className }: DialogActionsProps) => ( +
+ {children} +
+); diff --git a/client/ui/frontend/src/components/dialog/DialogDescription.tsx b/client/ui/frontend/src/components/dialog/DialogDescription.tsx new file mode 100644 index 000000000..12c358043 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/DialogDescription.tsx @@ -0,0 +1,26 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +type DialogAlign = "left" | "center" | "right"; + +const alignClass: Record = { + left: "text-left", + center: "text-center", + right: "text-right", +}; + +type DialogDescriptionProps = { + children: ReactNode; + className?: string; + align?: DialogAlign; +}; + +export const DialogDescription = ({ + children, + className, + align = "center", +}: DialogDescriptionProps) => ( +

+ {children} +

+); diff --git a/client/ui/frontend/src/components/dialog/DialogHeading.tsx b/client/ui/frontend/src/components/dialog/DialogHeading.tsx new file mode 100644 index 000000000..b9dda72a9 --- /dev/null +++ b/client/ui/frontend/src/components/dialog/DialogHeading.tsx @@ -0,0 +1,35 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +type DialogAlign = "left" | "center" | "right"; + +const alignClass: Record = { + left: "text-left", + center: "text-center", + right: "text-right", +}; + +type DialogHeadingProps = { + children: ReactNode; + className?: string; + align?: DialogAlign; + id?: string; +}; + +export const DialogHeading = ({ + children, + className, + align = "center", + id, +}: DialogHeadingProps) => ( +

+ {children} +

+); diff --git a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx new file mode 100644 index 000000000..e8e7108eb --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AlertTriangleIcon, DownloadIcon } from "lucide-react"; +import { Browser } from "@wailsio/runtime"; +import { Version } from "@bindings/services"; +import { Button } from "@/components/buttons/Button"; +import { useStatus } from "@/contexts/StatusContext.tsx"; + +const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest"; +const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc"; + +function openUrl(url: string) { + Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank")); +} + +export const DaemonOutdatedOverlay = () => { + const { t } = useTranslation(); + const { status, isDaemonOutdated } = useStatus(); + + const [guiVersion, setGuiVersion] = useState("-"); + const clientVersion = status?.daemonVersion ?? "—"; + + const isRc = /-rc/i.test(guiVersion) || /-rc/i.test(clientVersion); + const downloadUrl = isRc ? RC_RELEASES_URL : RELEASES_URL; + + useEffect(() => { + if (!isDaemonOutdated) return; + let cancelled = false; + Version.GUI() + .then((v) => { + if (!cancelled) setGuiVersion(v); + }) + .catch((err) => console.error("[DaemonOutdatedOverlay] GUI version error", err)); + return () => { + cancelled = true; + }; + }, [isDaemonOutdated]); + + if (!isDaemonOutdated) return null; + + return ( +
+
+
+ +
+ +
+

+ {t("daemon.outdated.title")} +

+

{t("daemon.outdated.description")}

+
+ +
+

+ {clientVersion === "development" ? ( + + {t("settings.about.clientName")}{" "} + + {t("settings.about.development")} + + + ) : ( + t("settings.about.client", { version: clientVersion }) + )} +

+

+ {guiVersion === "development" ? ( + + {t("settings.about.guiName")}{" "} + + {t("settings.about.development")} + + + ) : ( + t("settings.about.gui", { version: guiVersion }) + )} +

+
+ +
+ +
+
+
+ ); +}; diff --git a/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx new file mode 100644 index 000000000..89c121e21 --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next"; +import { AlertCircleIcon, BookText } from "lucide-react"; +import { Browser } from "@wailsio/runtime"; +import { Button } from "@/components/buttons/Button"; +import { useStatus } from "@/contexts/StatusContext.tsx"; + +const DOCS_URL = "https://docs.netbird.io/how-to/installation"; + +function openUrl(url: string) { + Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank")); +} + +export const DaemonUnavailableOverlay = () => { + const { t } = useTranslation(); + const { isDaemonUnavailable } = useStatus(); + + if (!isDaemonUnavailable) return null; + + return ( +
+
+
+ +
+ +
+

+ {t("daemon.unavailable.title")} +

+

+ {t("daemon.unavailable.description")} +

+
+ +
+ +
+
+
+ ); +}; diff --git a/client/ui/frontend/src/components/empty-state/EmptyState.tsx b/client/ui/frontend/src/components/empty-state/EmptyState.tsx new file mode 100644 index 000000000..7d6890a98 --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/EmptyState.tsx @@ -0,0 +1,31 @@ +import { type ComponentType } from "react"; +import { type LucideProps } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { SquareIcon } from "@/components/SquareIcon"; +import { isMacOS } from "@/lib/platform"; + +// Knob to shift the centered main-window content up/down together. +export const contentVerticalOffset = (): string => (isMacOS() ? "0.6rem" : "-1.4rem"); +export const contentTop = (base: string) => `calc(${base} + ${contentVerticalOffset()})`; + +type Props = { + icon: ComponentType; + title: string; + description?: string; + className?: string; +}; + +export const EmptyState = ({ icon, title, description, className }: Props) => { + return ( +
+
+ +

{title}

+ {description &&

{description}

} +
+
+ ); +}; diff --git a/client/ui/frontend/src/components/empty-state/NoResults.tsx b/client/ui/frontend/src/components/empty-state/NoResults.tsx new file mode 100644 index 000000000..cf0995b37 --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/NoResults.tsx @@ -0,0 +1,22 @@ +import { type ComponentType } from "react"; +import { FunnelXIcon, type LucideProps } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { EmptyState } from "./EmptyState"; + +type Props = { + icon?: ComponentType; + title?: string; + description?: string; +}; + +export const NoResults = ({ icon = FunnelXIcon, title, description }: Props) => { + const { t } = useTranslation(); + return ( + + ); +}; diff --git a/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx b/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx new file mode 100644 index 000000000..2bcf70376 --- /dev/null +++ b/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx @@ -0,0 +1,16 @@ +import { GlobeOffIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { EmptyState } from "./EmptyState"; + +export const NotConnectedState = () => { + const { t } = useTranslation(); + return ( +
+ +
+ ); +}; diff --git a/client/ui/frontend/src/components/inputs/Input.tsx b/client/ui/frontend/src/components/inputs/Input.tsx new file mode 100644 index 000000000..2dad80d7a --- /dev/null +++ b/client/ui/frontend/src/components/inputs/Input.tsx @@ -0,0 +1,374 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react"; +import { + forwardRef, + type InputHTMLAttributes, + type ReactNode, + useEffect, + useId, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { Label } from "@/components/typography/Label"; + +type InputVariants = VariantProps; + +export interface InputProps extends InputHTMLAttributes, InputVariants { + label?: string; + customPrefix?: ReactNode; + customSuffix?: ReactNode; + maxWidthClass?: string; + icon?: ReactNode; + error?: string; + warning?: string; + prefixClassName?: string; + showPasswordToggle?: boolean; + copy?: boolean; +} + +const inputVariants = cva("", { + variants: { + variant: { + default: [ + "border-neutral-200 placeholder:text-neutral-500 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20", + ], + darker: [ + "border-neutral-300 placeholder:text-neutral-500 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70", + "ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20", + ], + error: [ + "border-neutral-200 text-red-500 placeholder:text-neutral-500 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "ring-offset-red-500/10 focus-visible:ring-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10", + ], + warning: [ + "border-neutral-200 text-orange-400 placeholder:text-neutral-500 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "ring-offset-orange-400/10 focus-visible:ring-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10", + ], + }, + prefixSuffixVariant: { + default: [ + "border-neutral-200 text-nb-gray-300 dark:border-nb-gray-700 dark:bg-nb-gray-900", + ], + error: ["border-red-500 text-nb-gray-300 text-red-500 dark:bg-nb-gray-900"], + }, + }, +}); + +function computeNextStepValue(el: HTMLInputElement, delta: 1 | -1): number { + const stepAttr = el.step === "" ? 1 : Number(el.step); + const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1; + const min = el.min === "" ? -Infinity : Number(el.min); + const max = el.max === "" ? Infinity : Number(el.max); + const current = el.value === "" ? 0 : Number(el.value); + let next = (Number.isFinite(current) ? current : 0) + delta * step; + if (next < min) next = min; + if (next > max) next = max; + return next; +} + +function buildInputClassName( + opts: Readonly<{ + variant: InputVariants["variant"]; + hasCustomPrefix: boolean; + hasSuffix: boolean; + hasIcon: boolean; + readOnly?: boolean; + showStepper: boolean; + className?: string; + }>, +): string { + return cn( + inputVariants({ variant: opts.variant }), + "flex h-[40px] w-full select-text rounded-md bg-white px-3 py-2 text-sm", + "file:border-0 file:bg-transparent file:text-sm file:font-medium", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", + "disabled:cursor-not-allowed disabled:opacity-40", + opts.hasCustomPrefix && "!rounded-l-none !border-l-0", + opts.hasSuffix && "!pr-9", + opts.hasIcon && "!pl-10", + "border", + opts.readOnly && "!border-nb-gray-800 !bg-nb-gray-910 text-nb-gray-350", + opts.showStepper && + "!rounded-r-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", + opts.className, + ); +} + +function InputAffix({ + content, + error, + disabled, + className, +}: Readonly<{ content: ReactNode; error?: string; disabled?: boolean; className?: string }>) { + return ( +
+ {content} +
+ ); +} + +function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?: boolean }>) { + return ( +
+ {icon} +
+ ); +} + +function InputSuffixSlot({ + suffix, + disabled, +}: Readonly<{ suffix: ReactNode; disabled?: boolean }>) { + return ( +
+ {suffix} +
+ ); +} + +function NumberStepper({ + error, + disabled, + onStep, +}: Readonly<{ error?: string; disabled?: boolean; onStep: (delta: 1 | -1) => void }>) { + const { t } = useTranslation(); + return ( +
+ + +
+ ); +} + +function FieldMessage({ + id, + error, + warning, +}: Readonly<{ id?: string; error?: string; warning?: string }>) { + if (!error && !warning) return null; + return ( + + {error ?? warning} + + ); +} + +export const Input = forwardRef(function Input( + { + className, + type, + label, + customSuffix, + customPrefix, + icon, + maxWidthClass = "", + error, + warning, + variant = "default", + prefixClassName, + showPasswordToggle = false, + copy = false, + id, + ...props + }, + ref, +) { + const { t } = useTranslation(); + const [showPassword, setShowPassword] = useState(false); + const [copied, setCopied] = useState(false); + const isPasswordType = type === "password"; + const inputType = isPasswordType && showPassword ? "text" : type; + const isNumber = type === "number"; + + const reactId = useId(); + const fallbackId = `input-${reactId}`; + const inputId = id ?? (label ? fallbackId : undefined); + const messageId = error || warning ? `${inputId ?? fallbackId}-message` : undefined; + + const copyTimer = useRef | null>(null); + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); + + const internalRef = useRef(null); + const setRefs = (el: HTMLInputElement | null) => { + internalRef.current = el; + if (typeof ref === "function") ref(el); + else if (ref) ref.current = el; + }; + + const stepBy = (delta: 1 | -1) => { + const el = internalRef.current; + if (!el || el.disabled || el.readOnly) return; + const setter = Object.getOwnPropertyDescriptor( + globalThis.HTMLInputElement.prototype, + "value", + )?.set; + const next = computeNextStepValue(el, delta); + setter?.call(el, String(next)); + el.dispatchEvent(new Event("input", { bubbles: true })); + }; + + const passwordToggle = + isPasswordType && showPasswordToggle ? ( + + ) : null; + + const onCopy = async () => { + const text = props.value == null ? (internalRef.current?.value ?? "") : String(props.value); + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 1500); + } catch (e) { + console.warn("copy to clipboard failed", e); + } + }; + + const copyToggle = copy ? ( + + ) : null; + + const suffix = passwordToggle || copyToggle || customSuffix; + const showStepper = isNumber; + const warningVariant = warning ? "warning" : variant; + const resolvedVariant = error ? "error" : warningVariant; + + const inputClassName = buildInputClassName({ + variant: resolvedVariant, + hasCustomPrefix: !!customPrefix, + hasSuffix: !!suffix, + hasIcon: !!icon, + readOnly: props.readOnly, + showStepper, + className, + }); + + return ( +
+ {label && } +
+ {customPrefix && ( + + )} + + {icon && } + +
+ + + {suffix && } +
+ + {showStepper && ( + + )} +
+ +
+ ); +}); + +export default Input; diff --git a/client/ui/frontend/src/components/inputs/SearchInput.tsx b/client/ui/frontend/src/components/inputs/SearchInput.tsx new file mode 100644 index 000000000..5f46e8fba --- /dev/null +++ b/client/ui/frontend/src/components/inputs/SearchInput.tsx @@ -0,0 +1,59 @@ +import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { SearchIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; + +type Props = InputHTMLAttributes & { + iconSize?: number; + shortcut?: ReactNode; +}; + +export const SearchInput = forwardRef(function SearchInput( + { iconSize = 16, className, disabled, shortcut, "aria-label": ariaLabel, ...props }, + ref, +) { + const { t } = useTranslation(); + return ( +
+ + + {shortcut && ( + + {shortcut} + + )} +
+ ); +}); diff --git a/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx new file mode 100644 index 000000000..45e3e333a --- /dev/null +++ b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { HelpText } from "@/components/typography/HelpText"; +import { Label } from "@/components/typography/Label"; +import { ToggleSwitch } from "@/components/switches/ToggleSwitch"; +import { cn } from "@/lib/cn"; + +interface Props { + value: boolean; + onChange: (value: boolean) => void; + helpText?: React.ReactNode; + label?: React.ReactNode; + children?: React.ReactNode; + disabled?: boolean; + loading?: boolean; + dataCy?: string; + className?: string; + labelClassName?: string; + textWrapperClassName?: string; +} + +export default function FancyToggleSwitch({ + value, + onChange, + helpText, + label, + children, + disabled = false, + loading = false, + dataCy, + className, + labelClassName, + textWrapperClassName = "max-w-lg", +}: Readonly) { + const switchId = React.useId(); + const descriptionId = React.useId(); + + if (loading) { + const shimmer = + "text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse"; + return ( +
+
+
+ + + + {helpText} + + +
+
+
+
+
+
+ ); + } + + return ( +
+
+
+ + + {helpText} + +
+
+ +
+
+ {children && value ?
{children}
: null} +
+ ); +} diff --git a/client/ui/frontend/src/components/switches/SwitchItem.tsx b/client/ui/frontend/src/components/switches/SwitchItem.tsx new file mode 100644 index 000000000..e23c73fe3 --- /dev/null +++ b/client/ui/frontend/src/components/switches/SwitchItem.tsx @@ -0,0 +1,42 @@ +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { motion } from "framer-motion"; +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; +import { useSwitchItemGroup } from "@/components/switches/SwitchItemGroup"; + +type Props = { + value: string; + children: ReactNode; + className?: string; +}; + +export const SwitchItem = ({ value, children, className }: Props) => { + const { value: activeValue, layoutId } = useSwitchItemGroup(); + const active = activeValue === value; + + return ( + + {active && ( + + )} + + {children} + + + ); +}; diff --git a/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx b/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx new file mode 100644 index 000000000..b4361d530 --- /dev/null +++ b/client/ui/frontend/src/components/switches/SwitchItemGroup.tsx @@ -0,0 +1,60 @@ +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { createContext, type ReactNode, useContext, useId, useMemo } from "react"; +import { cn } from "@/lib/cn"; + +type SwitchItemGroupContextValue = { + value: string; + layoutId: string; +}; + +const SwitchItemGroupContext = createContext(null); + +export const useSwitchItemGroup = () => { + const ctx = useContext(SwitchItemGroupContext); + if (!ctx) { + throw new Error("SwitchItem must be used inside a SwitchItemGroup"); + } + return ctx; +}; + +type Props = { + value: string; + onChange: (value: string) => void; + children: ReactNode; + className?: string; + disabled?: boolean; + "aria-label"?: string; + "aria-labelledby"?: string; +}; + +export const SwitchItemGroup = ({ + value, + onChange, + children, + className, + disabled = false, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, +}: Props) => { + const layoutId = useId(); + const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]); + + return ( + + + {children} + + + ); +}; diff --git a/client/ui/frontend/src/components/switches/ToggleSwitch.tsx b/client/ui/frontend/src/components/switches/ToggleSwitch.tsx new file mode 100644 index 000000000..2d9f597e6 --- /dev/null +++ b/client/ui/frontend/src/components/switches/ToggleSwitch.tsx @@ -0,0 +1,77 @@ +"use client"; + +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { cn } from "@/lib/cn"; + +type SwitchVariants = VariantProps; + +const switchVariants = cva("", { + variants: { + size: { + default: "h-[24px] w-[44px]", + small: "h-[18px] w-[36px]", + large: "h-[36px] w-[66px]", + }, + variant: { + default: [ + "dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-netbird-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", + "data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200", + "data-[state=checked]:hover:bg-neutral-800 data-[state=unchecked]:hover:bg-neutral-300", + ], + "red-green": [ + "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", + "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200", + "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300", + ], + red: [ + "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", + "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200", + "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300", + ], + }, + "thumb-size": { + default: + "h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0", + small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0", + large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px]", + }, + }, +}); + +const ToggleSwitch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + SwitchVariants & { dataCy?: string } +>(({ className, size = "default", variant = "default", dataCy, disabled, ...props }, ref) => ( + { + e.stopPropagation(); + props.onClick?.(e); + }} + ref={ref} + > + + +)); +ToggleSwitch.displayName = SwitchPrimitives.Root.displayName; + +export { ToggleSwitch }; diff --git a/client/ui/frontend/src/components/typography/HelpText.tsx b/client/ui/frontend/src/components/typography/HelpText.tsx new file mode 100644 index 000000000..8c52ff714 --- /dev/null +++ b/client/ui/frontend/src/components/typography/HelpText.tsx @@ -0,0 +1,24 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +type Props = { + children?: ReactNode; + margin?: boolean; + className?: string; + disabled?: boolean; +}; + +export const HelpText = ({ children, margin = true, className, disabled = false }: Props) => ( + + {children} + +); + +export default HelpText; diff --git a/client/ui/frontend/src/components/typography/Label.tsx b/client/ui/frontend/src/components/typography/Label.tsx new file mode 100644 index 000000000..a8e1a446f --- /dev/null +++ b/client/ui/frontend/src/components/typography/Label.tsx @@ -0,0 +1,42 @@ +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; +import { type ComponentPropsWithoutRef, forwardRef, type Ref } from "react"; +import { cn } from "@/lib/cn"; + +const labelVariants = cva( + "mb-1.5 inline-block flex items-center gap-2 text-sm font-medium leading-none tracking-wider peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-nb-gray-100", +); + +type LabelProps = ComponentPropsWithoutRef & + VariantProps & { + as?: "label" | "div"; + disabled?: boolean; + }; + +export const Label = forwardRef(function Label( + { className, as = "label", disabled = false, children, ...props }, + ref, +) { + const classes = cn( + labelVariants(), + className, + "select-none transition-all duration-300", + disabled && "pointer-events-none opacity-30", + ); + + if (as === "div") { + return ( +
} className={classes}> + {children} +
+ ); + } + + return ( + } className={classes} {...props}> + {children} + + ); +}); + +export default Label; diff --git a/client/ui/frontend/src/contexts/ClientVersionContext.tsx b/client/ui/frontend/src/contexts/ClientVersionContext.tsx new file mode 100644 index 000000000..c0699a9ee --- /dev/null +++ b/client/ui/frontend/src/contexts/ClientVersionContext.tsx @@ -0,0 +1,114 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; + +import { Update as UpdateSvc, WindowManager } from "@bindings/services"; +import type { State as UpdateState } from "@bindings/updater/models.js"; +import i18next from "@/lib/i18n"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +const isDaemonUnavailable = (e: unknown): boolean => { + const msg = e instanceof Error ? e.message : String(e); + return msg.includes("code = Unavailable"); +}; + +type ClientVersionContextValue = { + updateAvailable: boolean; + updateVersion: string | null; + enforced: boolean; + installing: boolean; + triggerUpdate: () => void; + updating: boolean; +}; + +const EVENT_UPDATE_STATE = "netbird:update:state"; + +const emptyState: UpdateState = { + available: false, + version: "", + enforced: false, + installing: false, +}; + +const ClientVersionContext = createContext(null); + +export const useClientVersion = () => { + const ctx = useContext(ClientVersionContext); + if (!ctx) { + throw new Error("useClientVersion must be used inside ClientVersionProvider"); + } + return ctx; +}; + +export const ClientVersionProvider = ({ children }: { children: ReactNode }) => { + const [state, setState] = useState(emptyState); + const [updating, setUpdating] = useState(false); + + useEffect(() => { + let cancelled = false; + UpdateSvc.GetState() + .then((s) => { + if (cancelled || !s) return; + setState(s); + }) + .catch((e) => { + if (cancelled || isDaemonUnavailable(e)) return; + void errorDialog({ + Title: i18next.t("update.error.loadStateTitle"), + Message: formatErrorMessage(e), + }); + }); + const off = Events.On(EVENT_UPDATE_STATE, (ev: { data: UpdateState }) => { + if (ev?.data) setState(ev.data); + }); + return () => { + cancelled = true; + off?.(); + }; + }, []); + + const prevInstallingRef = useRef(false); + useEffect(() => { + if (state.installing && !prevInstallingRef.current) { + WindowManager.OpenInstallProgress(state.version || "").catch(console.error); + } + prevInstallingRef.current = state.installing; + }, [state.installing, state.version]); + + const triggerUpdate = useCallback(() => { + setUpdating(true); + WindowManager.OpenInstallProgress(state.version || "").catch(console.error); + UpdateSvc.Trigger() + .catch(async (e) => { + if (isDaemonUnavailable(e)) return; + WindowManager.CloseInstallProgress().catch(console.error); + await errorDialog({ + Title: i18next.t("update.error.triggerTitle"), + Message: formatErrorMessage(e), + }); + }) + .finally(() => setUpdating(false)); + }, [state.version]); + + const value = useMemo( + () => ({ + updateAvailable: state.available, + updateVersion: state.version || null, + enforced: state.enforced, + installing: state.installing, + triggerUpdate, + updating, + }), + [state, triggerUpdate, updating], + ); + + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/DebugBundleContext.tsx b/client/ui/frontend/src/contexts/DebugBundleContext.tsx new file mode 100644 index 000000000..5f2ed9041 --- /dev/null +++ b/client/ui/frontend/src/contexts/DebugBundleContext.tsx @@ -0,0 +1,319 @@ +import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services"; +import type { DebugBundleResult } from "@bindings/services/models.js"; +import i18next from "@/lib/i18n"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; +import { startConnection } from "@/lib/connection.ts"; + +const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url"; +const TRACE_LOG_FILE_COUNT = 5; +const PLAIN_LOG_FILE_COUNT = 1; +const TRACE_LOG_LEVEL = "trace"; +const DEFAULT_LOG_LEVEL = "info"; + +export type DebugStage = + | { kind: "idle" } + | { kind: "preparing-trace" } + | { kind: "reconnecting" } + | { kind: "capturing"; remainingSec: number; totalSec: number } + | { kind: "restoring-level" } + | { kind: "bundling" } + | { kind: "uploading" } + | { kind: "cancelling" } + | { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean }; + +const sleep = (ms: number, signal: AbortSignal) => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException("aborted", "AbortError")); + return; + } + const onAbort = () => { + clearTimeout(id); + reject(new DOMException("aborted", "AbortError")); + }; + const id = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal.addEventListener("abort", onAbort); + }); + +const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError"; + +const throwIfAborted = (signal: AbortSignal) => { + if (signal.aborted) throw new DOMException("aborted", "AbortError"); +}; + +const setLogLevelBestEffort = async (level: string) => { + try { + await DebugSvc.SetLogLevel({ level }); + } catch (e) { + console.warn("[DebugBundle] best-effort set log level failed", e); + } +}; + +const stopCaptureBestEffort = async () => { + try { + await DebugSvc.StopBundleCapture(); + } catch (e) { + console.warn("[DebugBundle] best-effort stop packet capture failed", e); + } +}; + +type LevelState = { original: string; raised: boolean }; +type CaptureState = { started: boolean }; + +type BundleOptions = { + trace: boolean; + capture: boolean; + capturePackets: boolean; + hasWindow: boolean; + totalSec: number; + uploadUrl: string; + anonymizeLevel: AnonymizeLevel; + systemInfo: boolean; +}; + +export type AnonymizeLevel = "none" | "default" | "strict"; + +const startCaptureBestEffort = async (totalSec: number, pcap: CaptureState) => { + try { + // Mirror the CLI's safety margin: window + 30s, server caps at 10m. + await DebugSvc.StartBundleCapture(totalSec + 30); + pcap.started = true; + } catch (e) { + console.warn("[DebugBundle] start packet capture failed", e); + } +}; + +const cleanupBestEffort = async (pcap: CaptureState, level: LevelState, restoreLevel: boolean) => { + if (pcap.started) { + await stopCaptureBestEffort(); + pcap.started = false; + } + if (restoreLevel && level.raised) { + await setLogLevelBestEffort(level.original); + } +}; + +const raiseToTrace = async ( + signal: AbortSignal, + level: LevelState, + setStage: (s: DebugStage) => void, +) => { + setStage({ kind: "preparing-trace" }); + try { + const cur = await DebugSvc.GetLogLevel(); + if (cur?.level) level.original = cur.level; + } catch (e) { + console.warn("[DebugBundle] read current log level failed", e); + } + throwIfAborted(signal); + await DebugSvc.SetLogLevel({ level: TRACE_LOG_LEVEL }); + level.raised = true; +}; + +const cycleConnection = async (signal: AbortSignal, setStage: (s: DebugStage) => void) => { + throwIfAborted(signal); + setStage({ kind: "reconnecting" }); + try { + await ConnectionSvc.Down(); + } catch (e) { + console.warn("[DebugBundle] disconnect before capture failed", e); + } + throwIfAborted(signal); + await startConnection(undefined, signal); +}; + +const restoreLogLevel = async (level: LevelState, setStage: (s: DebugStage) => void) => { + setStage({ kind: "restoring-level" }); + try { + await DebugSvc.SetLogLevel({ level: level.original }); + level.raised = false; + } catch (e) { + console.warn("[DebugBundle] restore log level failed", e); + } +}; + +const waitCaptureWindow = async ( + signal: AbortSignal, + setStage: (s: DebugStage) => void, + totalSec: number, +) => { + for (let remaining = totalSec; remaining > 0; remaining--) { + setStage({ kind: "capturing", remainingSec: remaining, totalSec }); + await sleep(1000, signal); + } +}; + +const runBundleFlow = async ( + signal: AbortSignal, + opts: BundleOptions, + level: LevelState, + pcap: CaptureState, + setStage: (s: DebugStage) => void, + setLastBundlePath: (p: string) => void, +) => { + if (opts.trace) { + await raiseToTrace(signal, level, setStage); + } + throwIfAborted(signal); + + if (opts.capture) { + await cycleConnection(signal, setStage); + } + throwIfAborted(signal); + + if (opts.hasWindow && opts.capturePackets) { + await startCaptureBestEffort(opts.totalSec, pcap); + } + throwIfAborted(signal); + + if (opts.hasWindow) { + await waitCaptureWindow(signal, setStage, opts.totalSec); + } + + if (pcap.started) { + await stopCaptureBestEffort(); + pcap.started = false; + } + + if (level.raised) { + await restoreLogLevel(level, setStage); + } + + throwIfAborted(signal); + setStage({ kind: "bundling" }); + const logFileCount = opts.trace ? TRACE_LOG_FILE_COUNT : PLAIN_LOG_FILE_COUNT; + + if (opts.uploadUrl) setStage({ kind: "uploading" }); + const result = await DebugSvc.Bundle({ + anonymize: opts.anonymizeLevel !== "none", + // The daemon only knows "default" and "strict"; "none" is expressed + // through the anonymize flag being off. + anonymizeLevel: opts.anonymizeLevel === "strict" ? "strict" : "default", + systemInfo: opts.systemInfo, + uploadUrl: opts.uploadUrl, + logFileCount, + }); + throwIfAborted(signal); + if (result.path) setLastBundlePath(result.path); + setStage({ kind: "done", result, uploadAttempted: Boolean(opts.uploadUrl) }); +}; + +const useDebugBundle = () => { + const [anonymizeLevel, setAnonymizeLevel] = useState("none"); + const [systemInfo, setSystemInfo] = useState(true); + const [upload, setUpload] = useState(true); + const [trace, setTrace] = useState(true); + const [capture, setCapture] = useState(false); + const [traceMinutes, setTraceMinutes] = useState(1); + const [capturePackets, setCapturePackets] = useState(true); + const [stage, setStage] = useState({ kind: "idle" }); + const [lastBundlePath, setLastBundlePath] = useState(""); + const abortRef = useRef(null); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + const isRunning = stage.kind !== "idle" && stage.kind !== "done"; + + const reset = () => setStage({ kind: "idle" }); + + const cancel = () => { + if (!abortRef.current || abortRef.current.signal.aborted) return; + abortRef.current.abort(); + setStage({ kind: "cancelling" }); + }; + + const run = async () => { + const ctrl = new AbortController(); + abortRef.current = ctrl; + const signal = ctrl.signal; + + const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60; + const level: LevelState = { original: DEFAULT_LOG_LEVEL, raised: false }; + const pcap: CaptureState = { started: false }; + const opts: BundleOptions = { + trace, + capture, + capturePackets, + hasWindow: capture && totalSec > 0, + totalSec, + uploadUrl: upload ? NETBIRD_UPLOAD_URL : "", + anonymizeLevel, + systemInfo, + }; + + try { + await runBundleFlow(signal, opts, level, pcap, setStage, setLastBundlePath); + } catch (e) { + if (isAbort(e)) { + setStage({ kind: "cancelling" }); + await cleanupBestEffort(pcap, level, true); + setStage({ kind: "idle" }); + return; + } + await cleanupBestEffort(pcap, level, false); + setStage({ kind: "idle" }); + await errorDialog({ + Title: i18next.t("settings.error.debugBundleTitle"), + Message: formatErrorMessage(e), + }); + } finally { + if (abortRef.current === ctrl) abortRef.current = null; + } + }; + + const openBundleDir = () => { + if (!lastBundlePath) return; + DebugSvc.RevealFile(lastBundlePath).catch((err: unknown) => + console.error("[DebugBundleContext] reveal failed", err), + ); + }; + + return { + anonymizeLevel, + setAnonymizeLevel, + systemInfo, + setSystemInfo, + upload, + setUpload, + trace, + setTrace, + capture, + setCapture, + traceMinutes, + setTraceMinutes, + capturePackets, + setCapturePackets, + stage, + isRunning, + lastBundlePath, + run, + cancel, + reset, + openBundleDir, + }; +}; + +export type DebugBundleContextValue = ReturnType; + +const DebugBundleContext = createContext(null); + +export const DebugBundleProvider = ({ children }: { children: ReactNode }) => { + const value = useDebugBundle(); + return {children}; +}; + +export const useDebugBundleContext = () => { + const ctx = useContext(DebugBundleContext); + if (!ctx) { + throw new Error("useDebugBundleContext must be used inside DebugBundleProvider"); + } + return ctx; +}; diff --git a/client/ui/frontend/src/contexts/DialogContext.tsx b/client/ui/frontend/src/contexts/DialogContext.tsx new file mode 100644 index 000000000..8a52e0dd0 --- /dev/null +++ b/client/ui/frontend/src/contexts/DialogContext.tsx @@ -0,0 +1,68 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from "react"; +import { ConfirmModal } from "@/components/dialog/ConfirmModal"; + +export type ConfirmOptions = { + title: ReactNode; + description: ReactNode; + confirmLabel: string; + cancelLabel?: string; + danger?: boolean; +}; + +type DialogContextValue = { + confirm: (options: ConfirmOptions) => Promise; +}; + +const DialogContext = createContext(null); + +export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) { + const [open, setOpen] = useState(false); + const [options, setOptions] = useState(null); + const resolverRef = useRef<((result: boolean) => void) | null>(null); + + const confirm = useCallback((opts: ConfirmOptions) => { + setOptions(opts); + setOpen(true); + return new Promise((resolve) => { + resolverRef.current = resolve; + }); + }, []); + + const settle = (result: boolean) => { + resolverRef.current?.(result); + resolverRef.current = null; + setOpen(false); + }; + + const value = useMemo(() => ({ confirm }), [confirm]); + + return ( + + {children} + settle(true)} + onCancel={() => settle(false)} + /> + + ); +} + +export const useConfirm = () => { + const ctx = useContext(DialogContext); + if (!ctx) throw new Error("useConfirm must be used within a DialogProvider"); + return ctx.confirm; +}; diff --git a/client/ui/frontend/src/contexts/NavSectionContext.tsx b/client/ui/frontend/src/contexts/NavSectionContext.tsx new file mode 100644 index 000000000..c08a3c824 --- /dev/null +++ b/client/ui/frontend/src/contexts/NavSectionContext.tsx @@ -0,0 +1,24 @@ +import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; + +export type NavSection = "peers" | "networks"; + +type NavSectionContextValue = { + section: NavSection; + setSection: (s: NavSection) => void; +}; + +const NavSectionContext = createContext(null); + +export const useNavSection = (): NavSectionContextValue => { + const ctx = useContext(NavSectionContext); + if (!ctx) { + throw new Error("useNavSection must be used inside NavSectionProvider"); + } + return ctx; +}; + +export const NavSectionProvider = ({ children }: { children: ReactNode }) => { + const [section, setSection] = useState("peers"); + const value = useMemo(() => ({ section, setSection }), [section]); + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/NetworksContext.tsx b/client/ui/frontend/src/contexts/NetworksContext.tsx new file mode 100644 index 000000000..ef7231700 --- /dev/null +++ b/client/ui/frontend/src/contexts/NetworksContext.tsx @@ -0,0 +1,222 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Networks as NetworksSvc } from "@bindings/services"; +import type { Network } from "@bindings/services/models.js"; +import { useStatus } from "@/contexts/StatusContext"; + +// A route that covers all traffic (0.0.0.0/0 or ::/0) is an exit node. +// The daemon may merge a v4+v6 pair into a single comma-joined range string. +export const isExitNode = (range: string): boolean => + range.split(",").some((part) => { + const trimmed = part.trim(); + return trimmed === "0.0.0.0/0" || trimmed === "::/0"; + }); + +type NetworksContextValue = { + routes: Network[]; + networkRoutes: Network[]; + exitNodes: Network[]; + activeExitNode: Network | null; + refresh: () => Promise; + toggleNetwork: (id: string, selected: boolean) => Promise; + toggleExitNode: (id: string, selected: boolean) => Promise; + setNetworksSelected: (ids: string[], selected: boolean) => Promise; +}; + +const NetworksContext = createContext(null); + +export const useNetworks = () => { + const ctx = useContext(NetworksContext); + if (!ctx) { + throw new Error("useNetworks must be used inside NetworksProvider"); + } + return ctx; +}; + +export const NetworksProvider = ({ children }: { children: ReactNode }) => { + const { status } = useStatus(); + const [routes, setRoutes] = useState([]); + const [pending, setPending] = useState>(new Map()); + const pendingRef = useRef(pending); + useEffect(() => { + pendingRef.current = pending; + }, [pending]); + + // Safety timer: if a prediction diverges from the daemon, the override would mask the true value forever. + const STUCK_OVERRIDE_MS = 4000; + const timersRef = useRef>>(new Map()); + + const clearTimer = useCallback((id: string) => { + const tid = timersRef.current.get(id); + if (tid !== undefined) { + clearTimeout(tid); + timersRef.current.delete(id); + } + }, []); + + const clearPendingFor = useCallback( + (ids: string[]) => { + for (const id of ids) clearTimer(id); + setPending((prev) => { + if (ids.every((id) => !prev.has(id))) return prev; + const next = new Map(prev); + for (const id of ids) next.delete(id); + return next; + }); + }, + [clearTimer], + ); + + const setPendingFor = useCallback( + (updates: Array<[string, boolean]>) => { + setPending((prev) => { + const next = new Map(prev); + for (const [id, sel] of updates) next.set(id, sel); + return next; + }); + for (const [id] of updates) { + clearTimer(id); + timersRef.current.set( + id, + setTimeout(() => clearPendingFor([id]), STUCK_OVERRIDE_MS), + ); + } + }, + [clearTimer, clearPendingFor], + ); + + useEffect(() => { + const timers = timersRef.current; + return () => { + for (const tid of timers.values()) clearTimeout(tid); + timers.clear(); + }; + }, []); + + const refresh = useCallback(async () => { + try { + const list = await NetworksSvc.List(); + setRoutes(list); + } catch (e) { + console.error("[NetworksContext] refresh failed", e); + } + }, []); + + const networksRevision = status?.networksRevision; + useEffect(() => { + refresh().catch((err: unknown) => console.error("[NetworksContext] refresh failed", err)); + }, [refresh, networksRevision]); + + useEffect(() => { + if (pendingRef.current.size === 0) return; + const confirmed: string[] = []; + for (const r of routes) { + const expected = pendingRef.current.get(r.id); + if (expected !== undefined && r.selected === expected) { + confirmed.push(r.id); + } + } + if (confirmed.length > 0) clearPendingFor(confirmed); + }, [routes, clearPendingFor]); + + const mutate = useCallback( + async (ids: string[], selected: boolean, rollback: Array<[string, boolean]>) => { + try { + if (selected) { + await NetworksSvc.Select({ networkIds: ids, append: true, all: false }); + } else { + await NetworksSvc.Deselect({ networkIds: ids, append: false, all: false }); + } + // Don't clear pending here — let the snapshot-match effect confirm, else a refresh racing the RPC return flashes back. + await refresh(); + } catch (e) { + console.error(e); + setPending((prev) => { + const next = new Map(prev); + for (const [id] of rollback) next.delete(id); + return next; + }); + throw e; + } + }, + [refresh], + ); + + const toggleNetwork = useCallback( + async (id: string, selected: boolean) => { + const target = !selected; + setPendingFor([[id, target]]); + await mutate([id], target, [[id, selected]]).catch(() => {}); + }, + [mutate, setPendingFor], + ); + + const setNetworksSelected = useCallback( + async (ids: string[], selected: boolean) => { + if (ids.length === 0) return; + const prevById = new Map(routes.map((r) => [r.id, r.selected])); + const rollback: Array<[string, boolean]> = ids.map((id) => [ + id, + prevById.get(id) ?? !selected, + ]); + setPendingFor(ids.map((id) => [id, selected])); + await mutate(ids, selected, rollback).catch(() => {}); + }, + [mutate, setPendingFor, routes], + ); + + // Daemon enforces exit-node mutual exclusion; mirror it locally so the optimistic paint matches. + const toggleExitNode = useCallback( + async (id: string, selected: boolean) => { + const target = !selected; + const updates: Array<[string, boolean]> = [[id, target]]; + const rollback: Array<[string, boolean]> = [[id, selected]]; + if (target) { + for (const r of routes) { + if (r.id !== id && isExitNode(r.range) && r.selected) { + updates.push([r.id, false]); + rollback.push([r.id, true]); + } + } + } + setPendingFor(updates); + await mutate([id], target, rollback).catch(() => {}); + }, + [mutate, setPendingFor, routes], + ); + + const value = useMemo(() => { + const effective = + pending.size === 0 + ? routes + : routes.map((r) => { + const override = pending.get(r.id); + return override === undefined || override === r.selected + ? r + : { ...r, selected: override }; + }); + const networkRoutes = effective.filter((r) => !isExitNode(r.range)); + const exitNodes = effective.filter((r) => isExitNode(r.range)); + const activeExitNode = exitNodes.find((r) => r.selected) ?? null; + return { + routes: effective, + networkRoutes, + exitNodes, + activeExitNode, + refresh, + toggleNetwork, + toggleExitNode, + setNetworksSelected, + }; + }, [routes, pending, refresh, toggleNetwork, toggleExitNode, setNetworksSelected]); + + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/PeerDetailContext.tsx b/client/ui/frontend/src/contexts/PeerDetailContext.tsx new file mode 100644 index 000000000..3ab20891f --- /dev/null +++ b/client/ui/frontend/src/contexts/PeerDetailContext.tsx @@ -0,0 +1,50 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import type { PeerStatus } from "@bindings/services/models.js"; + +type PeerDetailContextValue = { + selected: PeerStatus | null; + setSelected: (p: PeerStatus | null) => void; +}; + +const PeerDetailContext = createContext(null); + +export const usePeerDetail = (): PeerDetailContextValue => { + const ctx = useContext(PeerDetailContext); + if (!ctx) { + throw new Error("usePeerDetail must be used inside PeerDetailProvider"); + } + return ctx; +}; + +export const PeerDetailProvider = ({ children }: { children: ReactNode }) => { + const [selected, setSelected] = useState(null); + const openerRef = useRef(null); + + const select = useCallback((p: PeerStatus | null) => { + if (p) { + const active = document.activeElement; + openerRef.current = active instanceof HTMLElement ? active : null; + } else { + const opener = openerRef.current; + openerRef.current = null; + if (opener?.isConnected) { + queueMicrotask(() => opener.focus()); + } + } + setSelected(p); + }, []); + + const value = useMemo( + () => ({ selected, setSelected: select }), + [selected, select], + ); + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/ProfileContext.tsx b/client/ui/frontend/src/contexts/ProfileContext.tsx new file mode 100644 index 000000000..62377f1bc --- /dev/null +++ b/client/ui/frontend/src/contexts/ProfileContext.tsx @@ -0,0 +1,195 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; +import { Connection, ProfileSwitcher, Profiles as ProfilesSvc } from "@bindings/services"; +import type { Profile } from "@bindings/services/models.js"; +import i18next from "@/lib/i18n"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +const EVENT_PROFILE_CHANGED = "netbird:profile:changed"; + +type ProfileContextValue = { + username: string; + // activeProfile is the display NAME of the active profile (for rendering + // and the "default" check). activeProfileId is its stable on-disk ID, used + // as the handle for daemon requests and for active-profile comparisons, + // since display names can collide. + activeProfile: string; + activeProfileId: string; + profiles: Profile[]; + loaded: boolean; + refresh: () => Promise; + switchProfile: (id: string) => Promise; + switchProfileNoConnect: (id: string) => Promise; + addProfile: (name: string) => Promise; + removeProfile: (id: string) => Promise; + renameProfile: (id: string, newName: string) => Promise; + logoutProfile: (id: string) => Promise; +}; + +const ProfileContext = createContext(null); + +export const useProfile = () => { + const ctx = useContext(ProfileContext); + if (!ctx) { + throw new Error("useProfile must be used inside ProfileProvider"); + } + return ctx; +}; + +export const ProfileProvider = ({ children }: { children: ReactNode }) => { + const [username, setUsername] = useState(""); + const [activeProfile, setActiveProfile] = useState(""); + const [activeProfileId, setActiveProfileId] = useState(""); + const [profiles, setProfiles] = useState([]); + const [loaded, setLoaded] = useState(false); + const retryRef = useRef | null>(null); + + const refresh = useCallback(async () => { + if (retryRef.current) { + clearTimeout(retryRef.current); + retryRef.current = null; + } + try { + const u = await ProfilesSvc.Username(); + const [active, list] = await Promise.all([ + ProfilesSvc.GetActive(), + ProfilesSvc.List(u), + ]); + setUsername(u); + setActiveProfile(active.profileName || "default"); + setActiveProfileId(active.id || "default"); + setProfiles(list); + setLoaded(true); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("code = Unavailable")) { + retryRef.current = setTimeout(() => { + void refresh(); + }, 1000); + return; + } + setLoaded(true); + await errorDialog({ + Title: i18next.t("profile.error.loadTitle"), + Message: formatErrorMessage(e), + }); + } + }, []); + + useEffect(() => { + refresh().catch((err: unknown) => console.error("[ProfileContext] refresh failed", err)); + return () => { + if (retryRef.current) clearTimeout(retryRef.current); + }; + }, [refresh]); + + useEffect(() => { + const off = Events.On(EVENT_PROFILE_CHANGED, () => { + refresh().catch((err: unknown) => + console.error("[ProfileContext] refresh failed", err), + ); + }); + return () => { + off(); + }; + }, [refresh]); + + // id is a handle: the daemon resolves an exact ID, ID prefix, or unique + // display name. The UI passes the profile's ID for precision. + const switchProfile = useCallback( + async (id: string) => { + await ProfileSwitcher.SwitchActive({ profileName: id, username }); + await refresh(); + }, + [username, refresh], + ); + + // Manage-profiles variant: switches without connecting, so the user can + // still adjust the management URL before bringing the connection up. + const switchProfileNoConnect = useCallback( + async (id: string) => { + await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username }); + await refresh(); + }, + [username, refresh], + ); + + // addProfile creates a profile by display name and returns the + // daemon-generated ID, so the caller can immediately address it by ID. + const addProfile = useCallback( + async (name: string) => { + const id = await ProfilesSvc.Add({ profileName: name, username }); + await refresh(); + return id; + }, + [username, refresh], + ); + + const removeProfile = useCallback( + async (id: string) => { + await ProfilesSvc.Remove({ profileName: id, username }); + await refresh(); + }, + [username, refresh], + ); + + // The daemon resolves the handle (exact ID, ID prefix, or unique display + // name) — passing the ID is precise and avoids collisions on rename. + const renameProfile = useCallback( + async (id: string, newName: string) => { + await ProfilesSvc.Rename({ handle: id, newName, username }); + await refresh(); + }, + [username, refresh], + ); + + const logoutProfile = useCallback( + async (id: string) => { + await Connection.Logout({ profileName: id, username }); + await refresh(); + }, + [username, refresh], + ); + + const value = useMemo( + () => ({ + username, + activeProfile, + activeProfileId, + profiles, + loaded, + refresh, + switchProfile, + switchProfileNoConnect, + addProfile, + removeProfile, + renameProfile, + logoutProfile, + }), + [ + username, + activeProfile, + activeProfileId, + profiles, + loaded, + refresh, + switchProfile, + switchProfileNoConnect, + addProfile, + removeProfile, + renameProfile, + logoutProfile, + ], + ); + + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/RestrictionsContext.tsx b/client/ui/frontend/src/contexts/RestrictionsContext.tsx new file mode 100644 index 000000000..572bf17ec --- /dev/null +++ b/client/ui/frontend/src/contexts/RestrictionsContext.tsx @@ -0,0 +1,65 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; +import { Settings as SettingsSvc } from "@bindings/services"; +import { Restrictions } from "@bindings/services/models.js"; +import { useStatus } from "@/contexts/StatusContext.tsx"; + +const EVENT_SYSTEM = "netbird:event"; +const EMPTY = new Restrictions(); + +const RestrictionsContext = createContext(EMPTY); + +export const useRestrictions = () => useContext(RestrictionsContext); + +export const RestrictionsProvider = ({ children }: { children: ReactNode }) => { + const [restrictions, setRestrictions] = useState(EMPTY); + const mounted = useRef(true); + const { status } = useStatus(); + + const refresh = useCallback(async () => { + try { + const r = await SettingsSvc.GetRestrictions(); + if (mounted.current) setRestrictions(r); + } catch (e) { + console.error("[RestrictionsContext] refresh failed", e); + } + }, []); + + useEffect(() => { + mounted.current = true; + + const off = Events.On( + EVENT_SYSTEM, + (e: { data?: { metadata?: { [k: string]: string | undefined } } }) => { + if (e.data?.metadata?.type === "config_changed") refresh(); + }, + ); + + const onVisible = () => { + if (document.visibilityState === "visible") refresh(); + }; + document.addEventListener("visibilitychange", onVisible); + + return () => { + mounted.current = false; + off(); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [refresh]); + + useEffect(() => { + if (status?.status) refresh(); + }, [status?.status, refresh]); + + return ( + {children} + ); +}; diff --git a/client/ui/frontend/src/contexts/SettingsContext.tsx b/client/ui/frontend/src/contexts/SettingsContext.tsx new file mode 100644 index 000000000..3f4b2d0d2 --- /dev/null +++ b/client/ui/frontend/src/contexts/SettingsContext.tsx @@ -0,0 +1,289 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; +import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services"; +import type { Config } from "@bindings/services/models.js"; +import i18next from "@/lib/i18n"; +import { useProfile } from "@/contexts/ProfileContext.tsx"; +import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx"; +import { errorCommand, errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts"; + +const SAVE_DEBOUNCE_MS = 400; + +const logSaveError = (err: unknown) => console.error("[SettingsContext] save failed", err); + +export type AutostartState = { supported: boolean; enabled: boolean }; + +type SettingsContextValue = { + config: Config; + guiVersion: string; + setField: (k: K, v: Config[K]) => void; + saveField: (k: K, v: Config[K]) => Promise; + saveFields: (partial: Partial, opts?: { preSharedKey?: string }) => Promise; + saveNow: () => Promise; +}; + +type AutostartContextValue = { + autostart: AutostartState | null; + setAutostartEnabled: (enabled: boolean) => Promise; +}; + +const SettingsContext = createContext(null); +const AutostartContext = createContext(null); + +export const useSettings = () => { + const ctx = useContext(SettingsContext); + if (!ctx) { + throw new Error("useSettings must be used inside SettingsProvider"); + } + return ctx; +}; + +export const useAutostartSetting = () => { + const ctx = useContext(AutostartContext); + if (!ctx) { + throw new Error("useAutostartSetting must be used inside AutostartSettingsProvider"); + } + return ctx; +}; + +type LoadedConfig = { profileName: string; data: Config }; + +const useSettingsState = () => { + const { username, activeProfileId, loaded: profileLoaded } = useProfile(); + const [loaded, setLoaded] = useState(null); + const [guiVersion, setGuiVersion] = useState("—"); + const saveTimer = useRef | null>(null); + const loadedRef = useRef(null); + + useEffect(() => { + loadedRef.current = loaded; + }, [loaded]); + + // reload re-reads the daemon's config, which is authoritative. Used on + // mount, on the daemon's config_changed event, and to undo an optimistic + // update the daemon then rejected. + const reload = useCallback( + async (profileName: string) => { + try { + const data = await SettingsSvc.GetConfig({ profileName, username }); + setLoaded({ profileName, data }); + } catch (e) { + console.warn("[SettingsContext] reload after rejected save failed", e); + } + }, + [username], + ); + + useEffect(() => { + if (!profileLoaded || !activeProfileId) return; + let cancelled = false; + + const load = async (showError: boolean) => { + try { + const data = await SettingsSvc.GetConfig({ + profileName: activeProfileId, + username, + }); + if (cancelled) return; + if (saveTimer.current) return; + setLoaded({ profileName: activeProfileId, data }); + } catch (e) { + if (cancelled || !showError) return; + await errorDialog({ + Title: i18next.t("settings.error.loadTitle"), + Message: errorMessage(e), + }); + } + }; + + load(true); + + const off = Events.On( + "netbird:event", + (e: { data?: { metadata?: { [k: string]: string | undefined } } }) => { + if (e.data?.metadata?.type === "config_changed") load(false); + }, + ); + + return () => { + cancelled = true; + off(); + }; + }, [profileLoaded, activeProfileId, username]); + + useEffect(() => { + let cancelled = false; + Version.GUI().then((v) => { + if (!cancelled) setGuiVersion(v); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect( + () => () => { + if (saveTimer.current) clearTimeout(saveTimer.current); + }, + [], + ); + + const save = useCallback( + async (profileName: string, next: Config, preSharedKey?: string) => { + const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey }; + try { + await SettingsSvc.SetConfig({ + ...next, + ...preSharedKeyWrite, + profileName, + username, + }); + } catch (e) { + // The optimistic update is wrong now: the daemon refused it + // (a change that needs elevated privileges, an MDM-managed + // field, ...). Snap the controls back to what it actually + // holds before reporting, so the UI never shows a value the + // daemon does not have. + await reload(profileName); + await errorDialog({ + Title: i18next.t("settings.error.saveTitle"), + Message: errorMessage(e), + Command: errorCommand(e), + }); + } + }, + [username, reload], + ); + + const setField = useCallback( + (k: K, v: Config[K]) => { + const cur = loadedRef.current; + if (!cur) return; + const next: LoadedConfig = { + profileName: cur.profileName, + data: { ...cur.data, [k]: v }, + }; + loadedRef.current = next; + setLoaded(next); + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(() => { + saveTimer.current = null; + save(next.profileName, next.data).catch(logSaveError); + }, SAVE_DEBOUNCE_MS); + }, + [save], + ); + + const saveNow = useCallback(async () => { + if (!loaded) return; + if (saveTimer.current) { + clearTimeout(saveTimer.current); + saveTimer.current = null; + } + await save(loaded.profileName, loaded.data); + }, [loaded, save]); + + const saveField = useCallback( + async (k: K, v: Config[K]) => { + if (!loaded) return; + if (saveTimer.current) { + clearTimeout(saveTimer.current); + saveTimer.current = null; + } + const next = { ...loaded.data, [k]: v }; + setLoaded({ profileName: loaded.profileName, data: next }); + await save(loaded.profileName, next); + }, + [loaded, save], + ); + + const saveFields = useCallback( + async (partial: Partial, opts?: { preSharedKey?: string }) => { + if (!loaded) return; + if (saveTimer.current) { + clearTimeout(saveTimer.current); + saveTimer.current = null; + } + + const merged: Config = { ...loaded.data, ...partial }; + const next: Config = + opts?.preSharedKey === undefined + ? merged + : { ...merged, preSharedKeySet: opts.preSharedKey !== "" }; + setLoaded({ profileName: loaded.profileName, data: next }); + await save(loaded.profileName, next, opts?.preSharedKey); + }, + [loaded, save], + ); + + return { config: loaded?.data ?? null, guiVersion, setField, saveField, saveFields, saveNow }; +}; + +export const SettingsProvider = ({ children }: { children: ReactNode }) => { + const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState(); + + const value = useMemo( + () => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null), + [config, guiVersion, setField, saveField, saveFields, saveNow], + ); + + if (!value) { + return ( +
+ +
+ ); + } + + return {children}; +}; + +export const AutostartSettingsProvider = ({ children }: { children: ReactNode }) => { + const [autostart, setAutostart] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + const supported = await Autostart.Supported(); + const enabled = supported ? await Autostart.IsEnabled() : false; + if (cancelled) return; + setAutostart({ supported, enabled }); + })().catch((err: unknown) => { + if (cancelled) return; + console.warn("[SettingsContext] load autostart state failed", err); + setAutostart({ supported: false, enabled: false }); + }); + return () => { + cancelled = true; + }; + }, []); + + const setAutostartEnabled = useCallback(async (enabled: boolean) => { + setAutostart((s) => (s ? { ...s, enabled } : s)); + try { + await Autostart.SetEnabled(enabled); + } catch (e) { + setAutostart((s) => (s ? { ...s, enabled: !enabled } : s)); + await errorDialog({ + Title: i18next.t("settings.general.autostart.errorTitle"), + Message: errorMessage(e), + }); + } + }, []); + + const value = useMemo( + () => ({ autostart, setAutostartEnabled }), + [autostart, setAutostartEnabled], + ); + + return {children}; +}; diff --git a/client/ui/frontend/src/contexts/StatusContext.tsx b/client/ui/frontend/src/contexts/StatusContext.tsx new file mode 100644 index 000000000..0ad9c7875 --- /dev/null +++ b/client/ui/frontend/src/contexts/StatusContext.tsx @@ -0,0 +1,106 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; +import { DaemonFeed } from "@bindings/services"; +import { Status } from "@bindings/services/models.js"; +import { DaemonOutdatedOverlay } from "@/components/empty-state/DaemonOutdatedOverlay.tsx"; +import { DaemonUnavailableOverlay } from "@/components/empty-state/DaemonUnavailableOverlay.tsx"; +import { isDaemonCompatible } from "@/lib/compat"; + +const EVENT_STATUS = "netbird:status"; + +type StatusContextValue = { + status: Status | null; + error: string | null; + refresh: () => Promise; + isReady: boolean; + isDaemonUnavailable: boolean; + isDaemonAvailable: boolean; + isDaemonOutdated: boolean; +}; + +const StatusContext = createContext(null); + +export const useStatus = () => { + const ctx = useContext(StatusContext); + if (!ctx) { + throw new Error("useStatus must be used inside StatusProvider"); + } + return ctx; +}; + +export const StatusProvider = ({ children }: { children: ReactNode }) => { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [isDaemonOutdated, setIsDaemonOutdated] = useState(false); + + const refresh = useCallback(async () => { + try { + const s = await DaemonFeed.Get(); + setStatus(s); + setError(null); + } catch (e) { + // Synthesize DaemonUnavailable so cold-start-without-daemon isn't a blank UI (isReady stays false otherwise). + setStatus(Status.createFrom({ status: "DaemonUnavailable" })); + setError(String(e)); + } + }, []); + + useEffect(() => { + refresh().catch((err: unknown) => console.error("[StatusContext] refresh failed", err)); + const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => { + setStatus(ev.data); + setError(null); + }); + return () => { + off(); + }; + }, [refresh]); + + const isReady = status !== null; + const isDaemonUnavailable = isReady && status.status === "DaemonUnavailable"; + const isDaemonAvailable = isReady && !isDaemonUnavailable; + + useEffect(() => { + if (!isDaemonAvailable) return; + let cancelled = false; + isDaemonCompatible() + .then((ok) => { + if (!cancelled) setIsDaemonOutdated(!ok); + }) + .catch((err) => { + console.error("[StatusContext] daemon compatible error", err); + }); + return () => { + cancelled = true; + }; + }, [isDaemonAvailable]); + + const value = useMemo( + () => ({ + status, + error, + refresh, + isReady, + isDaemonUnavailable, + isDaemonAvailable, + isDaemonOutdated, + }), + [status, error, refresh, isReady, isDaemonUnavailable, isDaemonAvailable, isDaemonOutdated], + ); + + return ( + + {isDaemonAvailable && !isDaemonOutdated && children} + + + + ); +}; diff --git a/client/ui/frontend/src/contexts/ViewModeContext.tsx b/client/ui/frontend/src/contexts/ViewModeContext.tsx new file mode 100644 index 000000000..7db3abae0 --- /dev/null +++ b/client/ui/frontend/src/contexts/ViewModeContext.tsx @@ -0,0 +1,89 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Window } from "@wailsio/runtime"; +import { Preferences } from "@bindings/services"; +import { ViewMode as ViewModePref } from "@bindings/preferences/models.js"; + +export type ViewMode = "default" | "advanced"; + +// Don't pass a fixed height to Window.SetSize: macOS SetSize is frame (incl. ~28px +// title bar) while creation is content, so re-asserting a constant chops the content on first switch. +export const VIEW_WIDTH: Record = { + default: 380, + advanced: 900, +}; + +type ViewModeContextValue = { + viewMode: ViewMode; + setViewMode: (mode: ViewMode) => void; +}; + +const ViewModeContext = createContext(null); + +export const ViewModeProvider = ({ children }: { children: ReactNode }) => { + const [mode, setMode] = useState("default"); + const modeRef = useRef("default"); + + useEffect(() => { + let cancelled = false; + Preferences.Get() + .then((prefs) => { + if (cancelled) return; + const saved = prefs?.viewMode as ViewMode | undefined; + if (saved === "default" || saved === "advanced") { + modeRef.current = saved; + setMode(saved); + } + }) + .catch((err: unknown) => + console.warn("[ViewModeContext] load preferences failed", err), + ); + return () => { + cancelled = true; + }; + }, []); + + // Resize before flipping React state, else the layout paints into a window that hasn't grown yet. + const setViewMode = useCallback((mode: ViewMode) => { + if (modeRef.current === mode) return; + modeRef.current = mode; + (async () => { + const size = await Window.Size().catch((err: unknown) => { + console.warn("[ViewModeContext] read window size failed", err); + return null; + }); + const width = VIEW_WIDTH[mode]; + const height = size?.height ?? 640; + await Window.SetSize(width, height).catch((err: unknown) => + console.warn("[ViewModeContext] set window size failed", err), + ); + setMode(mode); + const pref = + mode === "advanced" ? ViewModePref.ViewModeAdvanced : ViewModePref.ViewModeDefault; + Preferences.SetViewMode(pref).catch((err: unknown) => + console.error("[ViewModeContext] SetViewMode failed", err), + ); + })().catch((err: unknown) => console.error("[ViewModeContext] setViewMode failed", err)); + }, []); + + const value = useMemo( + () => ({ viewMode: mode, setViewMode }), + [mode, setViewMode], + ); + + return {children}; +}; + +export const useViewMode = () => { + const ctx = useContext(ViewModeContext); + if (!ctx) throw new Error("useViewMode must be used inside ViewModeProvider"); + return ctx; +}; diff --git a/client/ui/frontend/src/globals.css b/client/ui/frontend/src/globals.css new file mode 100644 index 000000000..84ddfdcfe --- /dev/null +++ b/client/ui/frontend/src/globals.css @@ -0,0 +1,45 @@ +@font-face { + font-family: "Inter Variable"; + font-style: normal; + font-weight: 100 900; + src: url("./assets/fonts/inter-variable.ttf") format("truetype"); +} + +@font-face { + font-family: "JetBrains Mono Variable"; + font-style: normal; + font-weight: 100 800; + src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype"); +} + +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + height: 100%; + overflow: hidden; +} + +/* + * Body bg is fully opaque on purpose. The main window uses + * MacBackdropTranslucent (main.go) and TitleBarHiddenInset, which on macOS + * lets the desktop wallpaper bleed through any non-opaque pixel. A 90% + * body alpha meant two machines with different wallpapers saw different + * effective backgrounds. Matching Wails' BackgroundColour (#181A1D / nb-gray + * DEFAULT) here keeps things consistent regardless of the OS backdrop. + */ +body { + @apply bg-nb-gray font-sans text-nb-gray-200 antialiased; +} + +.wails-draggable { + --wails-draggable: drag; + cursor: default; +} + +.wails-no-draggable { + --wails-draggable: no-drag; +} diff --git a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts new file mode 100644 index 000000000..d4f4d80b2 --- /dev/null +++ b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts @@ -0,0 +1,60 @@ +import { useLayoutEffect, useRef } from "react"; +import { Window } from "@wailsio/runtime"; +import i18next from "@/lib/i18n"; +import { isLinux } from "@/lib/platform"; + +// Sizes the current Wails window to the measured content height (keeping `width`), +// then shows it. Re-applies on content resize and language change. +export function useAutoSizeWindow(width: number, ready: boolean = true) { + const ref = useRef(null); + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + let shown = false; + let raf1 = 0; + let raf2 = 0; + const showOnce = () => { + if (shown) return; + shown = true; + Window.Show().catch(() => {}); + Window.Focus().catch(() => {}); + }; + const apply = async () => { + if (!ready) return; + const h = Math.ceil(el.getBoundingClientRect().height); + if (h <= 0) return; + try { + // Window.SetSize takes the frame size, so add the OS title-bar height or content clips. + const frame = await Window.Size(); + const targetH = h + Math.max(0, frame.height - window.innerHeight); + // Linux: SetSize no-ops on a mapped non-resizable window (X11), so pin via min/max instead. + if (isLinux()) { + await Window.SetMinSize(width, targetH); + await Window.SetMaxSize(width, targetH); + } + await Window.SetSize(width, targetH); + showOnce(); + } catch { + // window gone / not ready — ignore + } + }; + const scheduleApply = () => { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(apply); + }); + }; + apply(); + const ro = new ResizeObserver(apply); + ro.observe(el); + i18next.on("languageChanged", scheduleApply); + return () => { + ro.disconnect(); + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + i18next.off("languageChanged", scheduleApply); + }; + }, [width, ready]); + return ref; +} diff --git a/client/ui/frontend/src/hooks/useFocusVisible.ts b/client/ui/frontend/src/hooks/useFocusVisible.ts new file mode 100644 index 000000000..6061beb9c --- /dev/null +++ b/client/ui/frontend/src/hooks/useFocusVisible.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; + +// Tracks the user's current input modality (keyboard vs pointer) at module +// scope, mirroring what @react-aria/interactions does. Radix programmatically +// focuses elements like Tabs triggers and Select triggers, which makes the +// browser's :focus-visible heuristic light up on mouse-driven interactions too. +// Gating focus styles on this hook lets us only paint a focus ring when the +// user is actually navigating with the keyboard. +// See react-aria's useFocusVisible for context. + +type Modality = "keyboard" | "pointer"; + +let currentModality: Modality = "pointer"; +const subscribers = new Set<(m: Modality) => void>(); + +const setModality = (m: Modality) => { + if (m === currentModality) return; + currentModality = m; + subscribers.forEach((cb) => cb(m)); +}; + +const isKeyboardEvent = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return false; + return e.key === "Tab" || e.key === "Escape" || e.key.startsWith("Arrow"); +}; + +if (globalThis.window !== undefined) { + globalThis.addEventListener( + "keydown", + (e) => { + if (isKeyboardEvent(e)) setModality("keyboard"); + }, + true, + ); + globalThis.addEventListener("pointerdown", () => setModality("pointer"), true); +} + +export const useFocusVisible = (): boolean => { + const [visible, setVisible] = useState(currentModality === "keyboard"); + useEffect(() => { + setVisible(currentModality === "keyboard"); + const cb = (m: Modality) => setVisible(m === "keyboard"); + subscribers.add(cb); + return () => { + subscribers.delete(cb); + }; + }, []); + return visible; +}; diff --git a/client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts b/client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts new file mode 100644 index 000000000..eb68dd997 --- /dev/null +++ b/client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts @@ -0,0 +1,35 @@ +import { useCallback, useEffect, useState } from "react"; +import { Preferences } from "@bindings/services"; + +export const useKeepConnectedOnQuit = () => { + const [keepConnected, setKeepConnected] = useState(null); + + useEffect(() => { + let cancelled = false; + Preferences.Get() + .then((prefs) => { + if (cancelled) return; + setKeepConnected(prefs?.keepConnectedOnQuit ?? false); + }) + .catch((err: unknown) => { + if (cancelled) return; + console.warn("[useKeepConnectedOnQuit] load preferences failed", err); + setKeepConnected(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const setKeepConnectedOnQuit = useCallback(async (keep: boolean) => { + setKeepConnected(keep); + try { + await Preferences.SetKeepConnectedOnQuit(keep); + } catch (err: unknown) { + setKeepConnected(!keep); + console.error("[useKeepConnectedOnQuit] SetKeepConnectedOnQuit failed", err); + } + }, []); + + return { keepConnected, setKeepConnectedOnQuit }; +}; diff --git a/client/ui/frontend/src/hooks/useKeyboardShortcut.ts b/client/ui/frontend/src/hooks/useKeyboardShortcut.ts new file mode 100644 index 000000000..ea67d8375 --- /dev/null +++ b/client/ui/frontend/src/hooks/useKeyboardShortcut.ts @@ -0,0 +1,46 @@ +import { useEffect } from "react"; +import { isMacOS } from "@/lib/platform"; + +export type Shortcut = { + key: string; + cmd?: boolean; + shift?: boolean; + alt?: boolean; + preventDefault?: boolean; +}; + +export const useKeyboardShortcut = (shortcut: Shortcut, callback: () => void, enabled = true) => { + useEffect(() => { + if (!enabled) return; + const onKey = (e: KeyboardEvent) => { + if (e.key.toLowerCase() !== shortcut.key.toLowerCase()) return; + const mod = e.metaKey || e.ctrlKey; + if (!!shortcut.cmd !== mod) return; + if (!!shortcut.shift !== e.shiftKey) return; + if (!!shortcut.alt !== e.altKey) return; + if (shortcut.preventDefault !== false) e.preventDefault(); + callback(); + }; + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); + }, [ + shortcut.key, + shortcut.cmd, + shortcut.shift, + shortcut.alt, + shortcut.preventDefault, + callback, + enabled, + ]); +}; + +export const formatShortcut = (shortcut: Shortcut): string => { + // navigator.platform is empty on some WebView2 builds → misrenders ⌘ as Ctrl on Mac. + const mac = isMacOS(); + const parts: string[] = []; + if (shortcut.cmd) parts.push(mac ? "⌘" : "Ctrl"); + if (shortcut.shift) parts.push(mac ? "⇧" : "Shift"); + if (shortcut.alt) parts.push(mac ? "⌥" : "Alt"); + parts.push(shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key); + return parts.join(mac ? "" : "+"); +}; diff --git a/client/ui/frontend/src/hooks/useManagementUrl.ts b/client/ui/frontend/src/hooks/useManagementUrl.ts new file mode 100644 index 000000000..6a0ef1b81 --- /dev/null +++ b/client/ui/frontend/src/hooks/useManagementUrl.ts @@ -0,0 +1,143 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useConfirm } from "@/contexts/DialogContext.tsx"; + +export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443"; +const CLOUD_MANAGEMENT_URLS = new Set([ + CLOUD_MANAGEMENT_URL, + "https://api.wiretrustee.com:443", // legacy cloud endpoint +]); + +export function isNetbirdCloud(url: string): boolean { + if (!url || url.trim() === "") return true; + return CLOUD_MANAGEMENT_URLS.has(url); +} + +// Matches http(s)://host[:port][/path][?query][#fragment]; host = domain, localhost, or IPv4. +// Syntactic validation only — reachability is checked via checkManagementUrlReachable. +export const URL_PATTERN = new RegExp( + String.raw`^(https?:\/\/)?` + + String.raw`((([a-z\d]([a-z\d-]*[a-z\d])?)\.)+[a-z]{2,}|localhost|` + + String.raw`((\d{1,3}\.){3}\d{1,3}))` + + String.raw`(\:\d+)?(\/[-a-z\d%_.~+]*)*` + + String.raw`(\?[;&a-z\d%_.~+=-]*)?` + + String.raw`(\#[-a-z\d_]*)?$`, + "i", +); + +export function normalizeManagementUrl(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return ""; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} + +export function isValidManagementUrl(input: string): boolean { + const trimmed = input.trim(); + if (!trimmed) return false; + return URL_PATTERN.test(trimmed); +} + +// Can false-negative for self-hosted behind internal DNS / self-signed certs — treat as a soft warning, not a hard block. +export async function checkManagementUrlReachable( + url: string, + timeoutMs: number = 5000, +): Promise { + const target = normalizeManagementUrl(url); + if (!target) return false; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + await fetch(target, { method: "GET", mode: "no-cors", signal: controller.signal }); + return true; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +export enum ManagementMode { + Cloud = "cloud", + SelfHosted = "selfhosted", +} + +function modeFromUrl(url: string): ManagementMode { + return isNetbirdCloud(url) ? ManagementMode.Cloud : ManagementMode.SelfHosted; +} + +export function useManagementUrl() { + const { t } = useTranslation(); + const confirm = useConfirm(); + const { config, saveField } = useSettings(); + const [modeState, setModeState] = useState(modeFromUrl(config.managementUrl)); + const [url, setUrl] = useState( + isNetbirdCloud(config.managementUrl) ? "" : config.managementUrl, + ); + const [checking, setChecking] = useState(false); + const [unreachable, setUnreachable] = useState(false); + + useEffect(() => { + setModeState(modeFromUrl(config.managementUrl)); + if (!isNetbirdCloud(config.managementUrl)) { + setUrl(config.managementUrl); + } + }, [config.managementUrl]); + + useEffect(() => { + setUnreachable(false); + }, [url, modeState]); + + const setMode = async (next: ManagementMode) => { + if (next === ManagementMode.Cloud && !isNetbirdCloud(config.managementUrl)) { + const ok = await confirm({ + title: t("settings.general.management.switchCloudTitle"), + description: t("settings.general.management.switchCloudMessage"), + confirmLabel: t("settings.general.management.switchCloudConfirm"), + }); + if (!ok) return; + setModeState(ManagementMode.Cloud); + saveField("managementUrl", CLOUD_MANAGEMENT_URL).catch((err: unknown) => + console.error("save managementUrl failed", err), + ); + return; + } + setModeState(next); + }; + + const normalizedUrl = normalizeManagementUrl(url); + const urlValid = isValidManagementUrl(url); + const targetUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl; + const dirty = targetUrl !== config.managementUrl; + const showError = modeState === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid; + const canSave = dirty && (modeState === ManagementMode.Cloud || urlValid); + const displayUrl = modeState === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url; + + const save = async () => { + if (modeState === ManagementMode.SelfHosted && !unreachable) { + setChecking(true); + const reachable = await checkManagementUrlReachable(targetUrl); + setChecking(false); + if (!reachable) { + setUnreachable(true); + return; + } + } + await saveField("managementUrl", targetUrl); + setUnreachable(false); + }; + + return { + mode: modeState, + setMode, + url, + setUrl, + displayUrl, + showError, + canSave, + save, + checking, + unreachable, + }; +} diff --git a/client/ui/frontend/src/hooks/usePrivilege.ts b/client/ui/frontend/src/hooks/usePrivilege.ts new file mode 100644 index 000000000..05e9a7ce0 --- /dev/null +++ b/client/ui/frontend/src/hooks/usePrivilege.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; +import { Settings as SettingsSvc } from "@bindings/services"; +import { Privilege } from "@bindings/services/models.js"; + +// usePrivilege reports whether this UI process may perform the changes the daemon +// restricts to root/administrator. It is answered in-process from our own token +// with the daemon's own rule, so there is no round-trip and it works while the +// daemon is down. +// +// null means "not known yet", which includes the read having failed. Callers must +// treat that as "do not restrict": the daemon enforces this regardless, so the +// only thing a wrong guess here costs is a control that looks unavailable when it +// is not, or a save that fails with the daemon's own guidance. +export const usePrivilege = (): Privilege | null => { + const [privilege, setPrivilege] = useState(null); + + useEffect(() => { + let cancelled = false; + SettingsSvc.Privilege() + .then((p) => { + if (!cancelled) setPrivilege(p); + }) + .catch((e: unknown) => { + console.warn("[usePrivilege] read failed, not restricting controls", e); + }); + return () => { + cancelled = true; + }; + }, []); + + return privilege; +}; diff --git a/client/ui/frontend/src/layouts/AppLayout.tsx b/client/ui/frontend/src/layouts/AppLayout.tsx new file mode 100644 index 000000000..1588d9d08 --- /dev/null +++ b/client/ui/frontend/src/layouts/AppLayout.tsx @@ -0,0 +1,27 @@ +import { Outlet } from "react-router-dom"; +import { ClientVersionProvider } from "@/contexts/ClientVersionContext.tsx"; +import { StatusProvider } from "@/contexts/StatusContext.tsx"; +import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx"; +import { ProfileProvider } from "@/contexts/ProfileContext.tsx"; +import { DialogProvider } from "@/contexts/DialogContext.tsx"; +import { RestrictionsProvider } from "@/contexts/RestrictionsContext.tsx"; + +export const AppLayout = () => { + return ( +
+ + + + + + + + + + + + + +
+ ); +}; diff --git a/client/ui/frontend/src/layouts/AppRightPanel.tsx b/client/ui/frontend/src/layouts/AppRightPanel.tsx new file mode 100644 index 000000000..8474bd71f --- /dev/null +++ b/client/ui/frontend/src/layouts/AppRightPanel.tsx @@ -0,0 +1,38 @@ +import { type ReactNode } from "react"; +import { motion } from "framer-motion"; +import { cn } from "@/lib/cn.ts"; + +type Props = { + children: ReactNode; + overlay?: ReactNode; + overlayOpen?: boolean; + className?: string; +}; + +const PANEL_TRANSITION = { + duration: 0.32, + ease: [0.32, 0.72, 0, 1] as [number, number, number, number], +}; + +export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => { + return ( +
+ + {children} + + {overlay} +
+ ); +}; diff --git a/client/ui/frontend/src/lib/cn.ts b/client/ui/frontend/src/lib/cn.ts new file mode 100644 index 000000000..e6a8be071 --- /dev/null +++ b/client/ui/frontend/src/lib/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/client/ui/frontend/src/lib/compat.ts b/client/ui/frontend/src/lib/compat.ts new file mode 100644 index 000000000..b2981da90 --- /dev/null +++ b/client/ui/frontend/src/lib/compat.ts @@ -0,0 +1,19 @@ +import { Compat } from "@bindings/services"; + +let cached: boolean | null = null; + +/** + * isDaemonCompatible probes whether the running daemon implements the WailsUIReady + * RPC. A false result means the daemon predates this UI (Unimplemented) and is too + * old to drive it. The Go side returns an error instead when the daemon is simply + * unreachable, so a throw here is NOT an outdated daemon — treat it as "unknown" + * and let the normal connection flow report it. + * + * The result is cached for the session: daemon identity does not change without a + * UI restart, and a freshly started daemon is reachable again under the same socket. + */ +export async function isDaemonCompatible(): Promise { + if (cached !== null) return cached; + cached = await Compat.DaemonReady(); + return cached; +} diff --git a/client/ui/frontend/src/lib/connection.ts b/client/ui/frontend/src/lib/connection.ts new file mode 100644 index 000000000..cc0e67cb3 --- /dev/null +++ b/client/ui/frontend/src/lib/connection.ts @@ -0,0 +1,133 @@ +import { Events } from "@wailsio/runtime"; +import { Connection, WindowManager } from "@bindings/services"; +import i18next from "@/lib/i18n"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; + +export const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel"; +export const EVENT_TRIGGER_LOGIN = "trigger-login"; + +let connectionInFlight = false; + +type SsoState = { + cancelled: boolean; + offCancel?: () => void; + offSignal?: () => void; +}; + +async function openBrowserLoginUri(uri: string): Promise { + try { + await WindowManager.OpenBrowserLogin(uri); + } catch (e) { + console.error(e); + } +} + +function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + state.offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => { + state.cancelled = true; + resolve(); + }); + if (!signal) return; + const onAbort = () => { + state.cancelled = true; + resolve(); + }; + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort); + state.offSignal = () => signal.removeEventListener("abort", onAbort); + }); +} + +async function runSsoLogin( + result: { + verificationUri: string; + verificationUriComplete: string; + userCode: string; + profileId: string; + }, + state: SsoState, + signal?: AbortSignal, +): Promise { + const uri = result.verificationUriComplete || result.verificationUri; + if (uri) await openBrowserLoginUri(uri); + + const cancelPromise = buildSsoCancelPromise(state, signal); + // Combine wait + up in Go so the connection comes up the moment SSO + // completes. During SSO the tray window is hidden and the webview is + // suspended, so a frontend-driven Up (a promise continuation) would not + // fire until the user woke the window (e.g. hovering the tray icon). + const waitPromise = Connection.WaitSSOLoginAndUp( + { userCode: result.userCode, hostname: "", profileId: result.profileId }, + { profileName: "", username: "" }, + ); + + try { + await Promise.race([waitPromise, cancelPromise]); + } finally { + WindowManager.CloseBrowserLogin().catch(console.error); + } + + if (state.cancelled) { + waitPromise.cancel?.(); + waitPromise.catch(() => {}); + } +} + +export async function startConnection(onSettled?: () => void, signal?: AbortSignal): Promise { + if (connectionInFlight || signal?.aborted) { + onSettled?.(); + return; + } + connectionInFlight = true; + + const state: SsoState = { cancelled: false }; + let connectError: unknown; + + try { + const result = await Connection.Login({ + profileName: "", + username: "", + managementUrl: "", + setupKey: "", + preSharedKey: "", + hostname: "", + hint: "", + }); + + if (signal?.aborted) state.cancelled = true; + + if (!state.cancelled && result.needsSsoLogin) { + // runSsoLogin brings the connection up in Go once SSO completes. + await runSsoLogin(result, state, signal); + } else { + if (!state.cancelled && signal?.aborted) state.cancelled = true; + if (!state.cancelled) { + await Connection.Up({ profileName: "", username: "" }); + } + } + } catch (e) { + WindowManager.CloseBrowserLogin().catch(console.error); + if (!state.cancelled) connectError = e; + } finally { + state.offCancel?.(); + state.offSignal?.(); + connectionInFlight = false; + onSettled?.(); + } + + if (connectError !== undefined) { + await errorDialog({ + Title: i18next.t("connect.error.loginTitle"), + Message: formatErrorMessage(connectError), + }); + return; + } + + if (state.cancelled && signal) { + throw new DOMException("aborted", "AbortError"); + } +} diff --git a/client/ui/frontend/src/lib/errors.ts b/client/ui/frontend/src/lib/errors.ts new file mode 100644 index 000000000..b4dee2717 --- /dev/null +++ b/client/ui/frontend/src/lib/errors.ts @@ -0,0 +1,73 @@ +import { WindowManager } from "@bindings/services"; + +type ClassifiedError = { short: string; long: string; command: string }; + +const asObject = (v: unknown): Record | null => + v && typeof v === "object" ? (v as Record) : null; + +const parseJsonObject = (s: unknown): Record | null => { + if (typeof s !== "string") return null; + const t = s.trim(); + if (!t.startsWith("{") || !t.endsWith("}")) return null; + try { + return asObject(JSON.parse(t)); + } catch { + return null; + } +}; + +const toWailsEnvelope = (e: unknown): Record | null => { + const obj = asObject(e); + if (!obj) return null; + return asObject(obj.cause) ?? parseJsonObject(obj.message); +}; + +// Read { short, long, command } from wherever the classified error sits in the envelope +const toClassifiedError = (v: unknown): ClassifiedError | null => { + const o = asObject(v); + if (!o) return null; + const short = typeof o.short === "string" ? o.short : ""; + const long = typeof o.long === "string" ? o.long : ""; + const command = typeof o.command === "string" ? o.command : ""; + return short || long ? { short, long, command } : null; +}; + +const classify = (e: unknown): ClassifiedError | null => { + const envelope = toWailsEnvelope(e); + return toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope); +}; + +export const formatErrorMessage = (e: unknown): string => { + // Prefer the structured { short, long } the daemon classifier produced. + const classified = classify(e); + if (classified) { + const { short, long } = classified; + if (short && long && long !== short) return `${short} Details: ${long}`; + if (short) return short; + if (long) return long; + } + + // Unclassified (a service returned the raw daemon error) + const envelope = toWailsEnvelope(e); + const message = envelope?.message; + if (typeof message === "string" && message) return message; + if (e instanceof Error) return e.message; + return String(e); +}; + +// errorCommand returns a command the user can run to complete an operation the +// daemon refused, when the error carries one (a change that needs elevated +// privileges). Empty for every other error. +export const errorCommand = (e: unknown): string => classify(e)?.command ?? ""; + +export type ErrorDialogOptions = { + Title: string; + Message: string; + // Command is shown for copying below the message. Defaults to the one the + // error carries, so callers only pass it to override. + Command?: string; +}; + +export function errorDialog(options: ErrorDialogOptions): Promise { + return WindowManager.OpenError(options.Title, options.Message, options.Command ?? ""); +} diff --git a/client/ui/frontend/src/lib/formatters.ts b/client/ui/frontend/src/lib/formatters.ts new file mode 100644 index 000000000..231967a38 --- /dev/null +++ b/client/ui/frontend/src/lib/formatters.ts @@ -0,0 +1,44 @@ +export const formatBytes = (bytes: number, decimals: number = 2): string => { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))); + + return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i]; +}; + +export const latencyColor = (ms: number): string => { + if (ms <= 0) return "text-nb-gray-400"; + if (ms < 100) return "text-green-400"; + return "text-yellow-400"; +}; + +export const formatRelative = (unixSeconds: number, nowMs: number = Date.now()): string | null => { + if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) return null; + const diff = Math.max(0, Math.floor(nowMs / 1000 - unixSeconds)); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +}; + +// Base domain is operator-configurable, so cut at the first dot rather than match a known suffix. +export const shortenDns = (fqdn: string | undefined | null): string => { + if (!fqdn) return ""; + const dot = fqdn.indexOf("."); + return dot === -1 ? fqdn : fqdn.slice(0, dot); +}; + +// Countdown clock: mm:ss, widening to hh:mm:ss / dd:hh:mm:ss as the duration grows. +export const formatRemaining = (seconds: number): string => { + const s = Math.max(0, Math.trunc(seconds)); + const days = Math.floor(s / 86400); + const hours = Math.floor((s % 86400) / 3600); + const minutes = Math.floor((s % 3600) / 60); + const secs = s % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + if (days > 0) return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(secs)}`; + if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`; + return `${pad(minutes)}:${pad(secs)}`; +}; diff --git a/client/ui/frontend/src/lib/i18n.ts b/client/ui/frontend/src/lib/i18n.ts new file mode 100644 index 000000000..dc7fc7902 --- /dev/null +++ b/client/ui/frontend/src/lib/i18n.ts @@ -0,0 +1,103 @@ +import i18next from "i18next"; +import { initReactI18next } from "react-i18next"; +import { Events } from "@wailsio/runtime"; + +import { Preferences, I18n } from "@bindings/services"; +import { type LanguageCode } from "@bindings/i18n/models.js"; + +// Relative path on purpose — alias globs (`@/…`) silently match nothing in some Vite dev setups. +type BundleEntry = { message: string; description?: string }; +const bundleModules = import.meta.glob>( + "../../../i18n/locales/*/common.json", + { eager: true, import: "default" }, +); + +const resources: Record }> = {}; +for (const path in bundleModules) { + const match = /locales\/([^/]+)\/common\.json$/.exec(path); + if (match) { + const entries = bundleModules[path]; + const messages: Record = {}; + for (const key in entries) { + messages[key] = entries[key].message; + } + resources[match[1]] = { common: messages }; + } +} + +function detectBrowserLanguage(available: string[]): string | null { + const tags = [navigator.language, ...(navigator.languages ?? [])].filter( + (tag): tag is string => typeof tag === "string" && tag.length > 0, + ); + const byLower = new Map(available.map((code) => [code.toLowerCase(), code])); + for (const tag of tags) { + const lower = tag.toLowerCase(); + const exact = byLower.get(lower); + if (exact) return exact; + const base = byLower.get(lower.split("-")[0]); + if (base) return base; + } + return null; +} + +// An empty persisted language code is the Go-side signal for first run. +export async function initI18n(): Promise { + const available = Object.keys(resources); + let language = "en"; + let firstRun = false; + try { + const prefs = await Preferences.Get(); + if (prefs?.language) { + language = prefs.language; + } else { + firstRun = true; + language = detectBrowserLanguage(available) ?? "en"; + } + } catch (e) { + console.warn("read preferences for language failed, defaulting to en", e); + } + + if (firstRun) { + Preferences.SetLanguage(language as LanguageCode).catch((err: unknown) => + console.warn("persist detected language failed", err), + ); + } + + await i18next.use(initReactI18next).init({ + lng: language, + fallbackLng: "en", + defaultNS: "common", + ns: ["common"], + resources, + interpolation: { + prefix: "{", + suffix: "}", + escapeValue: false, + }, + returnNull: false, + }); + + syncDocumentLang(); + i18next.on("languageChanged", syncDocumentLang); + + Events.On("netbird:preferences:changed", (e) => { + const next = e.data?.language; + if (next && next !== i18next.language) { + i18next.changeLanguage(next).catch((err: unknown) => { + console.error("changeLanguage failed", err); + }); + } + }); +} + +function syncDocumentLang() { + if (typeof document !== "undefined") { + document.documentElement.lang = i18next.language; + } +} + +export async function loadLanguages() { + return I18n.Languages(); +} + +export default i18next; diff --git a/client/ui/frontend/src/lib/logs.ts b/client/ui/frontend/src/lib/logs.ts new file mode 100644 index 000000000..499023fa3 --- /dev/null +++ b/client/ui/frontend/src/lib/logs.ts @@ -0,0 +1,139 @@ +import { UILog } from "@bindings/services"; + +type Level = "trace" | "debug" | "info" | "warn" | "error"; + +const METHOD_LEVELS: Record = { + trace: "trace", + debug: "debug", + log: "info", + info: "info", + warn: "warn", + error: "error", +}; + +const IGNORED_SOURCES = new Set(["welcome.ts"]); + +const RATE_LIMIT = 50; +const RATE_WINDOW_MS = 1000; + +let installed = false; +let inForward = false; +let windowStart = 0; +let windowCount = 0; + +function describeCause(rawCause: unknown): string { + if (rawCause instanceof Error) return `${rawCause.name}: ${rawCause.message}`; + if (typeof rawCause === "object" && rawCause !== null) { + try { + return JSON.stringify(rawCause); + } catch { + // Circular ref — fall through to a tag instead of "[object Object]". + return `<${rawCause.constructor?.name ?? "object"}>`; + } + } + return String(rawCause); +} + +function formatCause(rawCause: unknown): string { + if (rawCause === undefined) return ""; + return `\ncaused by ${describeCause(rawCause)}`; +} + +// WebKit (macOS WKWebView) omits the "Name: message" header from Error.stack, +// so a bare stack hides the real cause. Prepend name+message, then the stack. +function formatError(e: Error): string { + const head = `${e.name}: ${e.message}`; + const cause = formatCause((e as { cause?: unknown }).cause); + if (!e.stack) return `${head}${cause}`; + if (e.stack.startsWith(head)) return `${head}${cause}`; + return `${head}${cause}\n${e.stack}`; +} + +function format(args: unknown[]): string { + return args + .map((a) => { + if (typeof a === "string") return a; + if (a instanceof Error) return formatError(a); + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }) + .join(" "); +} + +function parseStackLine(line: string): string { + // Find the file:line:col tail at the end of the path. + const colonCol = line.lastIndexOf(":"); + if (colonCol <= 0) return ""; + const colonLine = line.lastIndexOf(":", colonCol - 1); + if (colonLine <= 0) return ""; + const col = line.slice(colonCol + 1); + const lineNo = line.slice(colonLine + 1, colonCol); + if (!/^\d+$/.test(col) || !/^\d+$/.test(lineNo)) return ""; + const before = line.slice(0, colonLine); + const sep = Math.max( + before.lastIndexOf("/"), + before.lastIndexOf("\\"), + before.lastIndexOf("("), + before.lastIndexOf(" "), + ); + const file = before.slice(sep + 1); + if (!file.includes(".")) return ""; + return `${file}:${lineNo}`; +} + +function callerSource(): string { + const stack = new Error().stack; + if (!stack) return ""; + for (const line of stack.split("\n").slice(1)) { + if (line.includes("/logs.ts")) continue; + const parsed = parseStackLine(line); + if (parsed) return parsed; + } + return ""; +} + +function forward(level: Level, args: unknown[]) { + if (inForward) return; + inForward = true; + try { + const now = Date.now(); + if (now - windowStart >= RATE_WINDOW_MS) { + windowStart = now; + windowCount = 0; + } + if (++windowCount > RATE_LIMIT) return; + + const source = callerSource(); + if (IGNORED_SOURCES.has(source.split(":")[0])) return; + // Don't touch console here — it would recurse back into forward(). + UILog.Log(level, source, format(args)).catch(() => {}); + } catch { + // Swallow — log forwarding must never throw back into the caller. + } finally { + inForward = false; + } +} + +export function initLogForwarding() { + if (installed) return; + installed = true; + + const c = console as unknown as Record void>; + for (const [method, level] of Object.entries(METHOD_LEVELS)) { + const original = c[method]?.bind(console); + c[method] = (...args: unknown[]) => { + original?.(...args); + forward(level, args); + }; + } + + globalThis.addEventListener("error", (e) => { + forward("error", [`uncaught error: ${e.message}`, e.error ?? ""]); + }); + globalThis.addEventListener("unhandledrejection", (e) => { + forward("error", ["unhandled promise rejection:", e.reason]); + }); +} diff --git a/client/ui/frontend/src/lib/platform.ts b/client/ui/frontend/src/lib/platform.ts new file mode 100644 index 000000000..c256dde23 --- /dev/null +++ b/client/ui/frontend/src/lib/platform.ts @@ -0,0 +1,39 @@ +import { System } from "@wailsio/runtime"; + +export type Platform = { + isWindows: boolean; + isMacOS: boolean; +}; + +let cached: Platform | null = null; + +export async function initPlatform(): Promise { + if (cached) return; + + const syncIsMac = System.IsMac(); + const syncIsWindows = System.IsWindows(); + + let env: Awaited> | null = null; + try { + env = await System.Environment(); + } catch (e) { + console.error("[platform] System.Environment() threw:", e); + } + + const os = (env?.OS ?? "").toLowerCase(); + cached = { + isWindows: os ? os === "windows" : syncIsWindows, + isMacOS: os ? os === "darwin" : syncIsMac, + }; +} + +function get(): Platform { + if (!cached) { + throw new Error("platform: initPlatform() must complete before sync getters are used"); + } + return cached; +} + +export const isWindows = (): boolean => get().isWindows; +export const isMacOS = (): boolean => get().isMacOS; +export const isLinux = (): boolean => !get().isWindows && !get().isMacOS; diff --git a/client/ui/frontend/src/lib/sorting.ts b/client/ui/frontend/src/lib/sorting.ts new file mode 100644 index 000000000..70622f705 --- /dev/null +++ b/client/ui/frontend/src/lib/sorting.ts @@ -0,0 +1,27 @@ +// Stable, order-preserving reconciliation for lists that re-fetch from the daemon +// on every status push (peers, networks, profiles). Re-sorting on each refresh would +// make rows jump around under the user, so instead: +// - items already on screen keep their existing order (from `prev`), +// - items that vanished are dropped, +// - newly-arrived items are sorted among themselves (`compareFresh`) and appended. +// Net effect: the only visible movement is new rows landing at the bottom. +// +// Must stay pure and idempotent: callers write the returned `order` into a ref +// during render (useMemo), so a rerun must reproduce the first pass — never +// branch on run count or read external mutable state. +export function reconcileOrder( + prev: string[], + items: T[], + keyOf: (item: T) => string, + compareFresh: (a: T, b: T) => number, +): { order: string[]; items: T[] } { + const byKey = new Map(items.map((i) => [keyOf(i), i])); + const kept = prev.filter((k) => byKey.has(k)); + const known = new Set(kept); + const fresh = items + .filter((i) => !known.has(keyOf(i))) + .sort(compareFresh) + .map(keyOf); + const order = [...kept, ...fresh]; + return { order, items: order.map((k) => byKey.get(k)!) }; +} diff --git a/client/ui/frontend/src/lib/stallwatch.ts b/client/ui/frontend/src/lib/stallwatch.ts new file mode 100644 index 000000000..aca7d75bb --- /dev/null +++ b/client/ui/frontend/src/lib/stallwatch.ts @@ -0,0 +1,31 @@ +// Detects webview suspension (macOS App Nap / hidden-window timer throttling). +// While the webview is suspended no JS runs at all, so detection happens on +// resume: a 1s interval measures wall-clock drift and reports how long timers +// were frozen. Silent unless a stall actually occurred; a stalled webview is +// what delays promise continuations such as the WaitSSOLogin → Up handoff. + +const INTERVAL_MS = 1000; +const STALL_THRESHOLD_MS = 5000; +const REPORT_COOLDOWN_MS = 60_000; + +let started = false; + +export function initStallWatch() { + if (started) return; + started = true; + + let last = Date.now(); + let lastReport = 0; + setInterval(() => { + const now = Date.now(); + const stall = now - last - INTERVAL_MS; + last = now; + if (stall < STALL_THRESHOLD_MS) return; + if (now - lastReport < REPORT_COOLDOWN_MS) return; + lastReport = now; + console.warn( + `webview timers were suspended for ${(stall / 1000).toFixed(1)}s ` + + `(App Nap / hidden-window throttling); pending UI work ran late`, + ); + }, INTERVAL_MS); +} diff --git a/client/ui/frontend/src/lib/welcome.ts b/client/ui/frontend/src/lib/welcome.ts new file mode 100644 index 000000000..d9df3fc8d --- /dev/null +++ b/client/ui/frontend/src/lib/welcome.ts @@ -0,0 +1,25 @@ +const ART = ` + _ __ __ ____ _ __ ______ __ __ __ + / | / /__ / /_/ __ )(_)________/ / / ____/___ ___ / /_ / / / / + / |/ / _ \\/ __/ __ / / ___/ __ / / / __/ __ \`__ \\/ __ \\/ /_/ / + / /| / __/ /_/ /_/ / / / / /_/ / / /_/ / / / / / / /_/ / __ / +/_/ |_/\\___/\\__/_____/_/_/ \\__,_/ \\____/_/ /_/ /_/_.___/_/ /_/ +`; + +export function welcome() { + const message = `%c${ART}%c +NetBird — The Only Secure Access Platform You'll Ever Need. + +WEBSITE: https://netbird.io/ +WE'RE HIRING: https://netbird.io/careers +OPEN SOURCE: https://github.com/netbirdio/netbird +`; + + // Intentional NetBird ASCII banner in the devtools console. + // eslint-disable-next-line no-console + console.log( + message, + "color: #f68330; font-family: monospace; font-weight: normal; line-height: 1;", + "color: #f5f5f5; font-family: monospace; font-weight: normal; line-height: 1.4;", + ); +} diff --git a/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx b/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx new file mode 100644 index 000000000..3345e0a9f --- /dev/null +++ b/client/ui/frontend/src/modules/auto-update/UpdateBadge.tsx @@ -0,0 +1,27 @@ +import { forwardRef, type HTMLAttributes } from "react"; +import { ArrowUpCircleIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; + +type Props = HTMLAttributes & { + size?: number; +}; + +export const UpdateBadge = forwardRef(function UpdateBadge( + { size = 15, className, ...rest }, + ref, +) { + return ( +
+ + +
+ ); +}); diff --git a/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx b/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx new file mode 100644 index 000000000..1519613e0 --- /dev/null +++ b/client/ui/frontend/src/modules/auto-update/UpdateInProgressDialog.tsx @@ -0,0 +1,187 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { Loader2, XCircle } from "lucide-react"; +import { Update as UpdateSvc, WindowManager } from "@bindings/services"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { SquareIcon } from "@/components/SquareIcon"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; + +const TIMEOUT_MS = 15 * 60 * 1000; +const POLL_INTERVAL_MS = 2000; +// Sustained gRPC failure during install is taken as success (installer restarts the daemon mid-flight). +const DAEMON_DOWN_GRACE_MS = 5000; +const WINDOW_WIDTH = 360; + +type Phase = + | { kind: "running" } + | { kind: "timeout" } + | { kind: "canceled" } + | { kind: "failed"; message: string }; + +export default function UpdateInProgressDialog() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const version = params.get("version") ?? ""; + const [phase, setPhase] = useState({ kind: "running" }); + const phaseRef = useRef(phase); + phaseRef.current = phase; + const contentRef = useAutoSizeWindow(WINDOW_WIDTH); + + useEffect(() => { + let cancelled = false; + let done = false; + let timer: ReturnType | null = null; + const start = Date.now(); + let firstUnreachableAt: number | null = null; + + const poll = async () => { + if (cancelled || done) return; + if (phaseRef.current.kind !== "running") return; + + if (Date.now() - start > TIMEOUT_MS) { + done = true; + setPhase({ kind: "timeout" }); + return; + } + + try { + const r = await UpdateSvc.GetInstallerResult(); + if (cancelled || done || phaseRef.current.kind !== "running") return; + firstUnreachableAt = null; + if (r.success) { + done = true; + UpdateSvc.Quit().catch(console.error); + return; + } + if (r.errorMsg) { + done = true; + setPhase(mapInstallError(r.errorMsg)); + return; + } + } catch { + if (cancelled || done || phaseRef.current.kind !== "running") return; + const now = Date.now(); + if (firstUnreachableAt === null) { + firstUnreachableAt = now; + } else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) { + done = true; + UpdateSvc.Quit().catch(console.error); + return; + } + } + + if (!cancelled && !done) { + timer = setTimeout(poll, POLL_INTERVAL_MS); + } + }; + + timer = setTimeout(poll, POLL_INTERVAL_MS); + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, []); + + const isError = phase.kind !== "running"; + const errorInfo = isError ? classifyPhase(phase, version, t) : null; + const updatingHeading = version + ? t("update.overlay.updatingVersion", { version }) + : t("update.overlay.updating"); + + return ( + + {isError ? ( + + ) : ( + + )} + +
+ + {errorInfo ? errorInfo.title : updatingHeading} + + + {errorInfo ? ( + <> + {errorInfo.description} + {errorInfo.message && ( + <> +
+ + {errorInfo.message} + + + )} + + ) : ( + t("update.overlay.description") + )} +
+
+ + {isError && ( + + + + )} +
+ ); +} + +function mapInstallError(msg: string): Phase { + const m = msg.trim().toLowerCase(); + if (m === "") return { kind: "failed", message: "" }; + if (m.includes("deadline exceeded") || m.includes("timeout") || m.includes("timed out")) { + return { kind: "timeout" }; + } + if (m.includes("canceled") || m.includes("cancelled") || m.includes("cancel")) { + return { kind: "canceled" }; + } + return { kind: "failed", message: msg }; +} + +type Variant = { title: string; description: string; message?: string }; + +function classifyPhase( + phase: Phase, + version: string, + t: (key: string, options?: Record) => string, +): Variant { + const target = version + ? t("update.overlay.error.targetVersion", { version }) + : t("update.overlay.error.targetFallback"); + switch (phase.kind) { + case "timeout": + return { + title: t("update.overlay.error.timeoutTitle"), + description: t("update.overlay.error.timeoutDescription", { target }), + }; + case "canceled": + return { + title: t("update.overlay.error.canceledTitle"), + description: t("update.overlay.error.canceledDescription", { target }), + }; + case "failed": + return { + title: t("update.overlay.error.failTitle"), + description: t("update.overlay.error.failDescription", { target }), + message: phase.message || t("update.overlay.error.unknownMessage"), + }; + default: + return { title: "", description: "" }; + } +} diff --git a/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx new file mode 100644 index 000000000..4861c492a --- /dev/null +++ b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx @@ -0,0 +1,99 @@ +import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Browser } from "@wailsio/runtime"; +import { DownloadIcon, NotepadText } from "lucide-react"; +import { Update as UpdateSvc } from "@bindings/services"; +import { Button } from "@/components/buttons/Button"; +import { useClientVersion } from "@/contexts/ClientVersionContext"; +import { cn } from "@/lib/cn"; + +const GITHUB_RELEASES = "https://github.com/netbirdio/netbird/releases/latest"; + +function openUrl(url: string) { + Browser.OpenURL(url).catch(() => { + window.open(url, "_blank"); + }); +} + +function openInstallerDownload() { + UpdateSvc.DownloadURL() + .then(openUrl) + .catch(() => openUrl(GITHUB_RELEASES)); +} + +export function UpdateVersionCard() { + const { t } = useTranslation(); + const { updateVersion, enforced, triggerUpdate } = useClientVersion(); + + if (updateVersion) { + const titleKey = enforced + ? "update.card.versionAvailableInstall" + : "update.card.versionAvailableDownload"; + return ( + +
+ {t(titleKey, { version: updateVersion })} + + {t("update.card.whatsNew")} + +
+ {enforced ? ( + + ) : ( + + )} +
+ ); + } + + return ( + +
+ {t("update.card.onLatestVersion")} +

{t("update.card.autoCheckInterval")}

+
+ +
+ ); +} + +function Card({ children, className }: Readonly<{ children: ReactNode; className?: string }>) { + return ( +
+ {children} +
+ ); +} + +function Title({ children }: Readonly<{ children: ReactNode }>) { + return

{children}

; +} + +function Link({ url, children }: Readonly<{ url: string; children: ReactNode }>) { + return ( + + ); +} diff --git a/client/ui/frontend/src/modules/error/ErrorDialog.tsx b/client/ui/frontend/src/modules/error/ErrorDialog.tsx new file mode 100644 index 000000000..4fbb78052 --- /dev/null +++ b/client/ui/frontend/src/modules/error/ErrorDialog.tsx @@ -0,0 +1,95 @@ +import { useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { AlertCircleIcon } from "lucide-react"; +import { Button } from "@/components/buttons/Button"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { SquareIcon } from "@/components/SquareIcon"; +import { WindowManager } from "@bindings/services"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; + +const WINDOW_WIDTH = 380; +// A command needs the room to wrap at a sensible number of characters instead of +// breaking every few words. +const WINDOW_WIDTH_WITH_COMMAND = 460; + +export default function ErrorDialog() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + + const title = params.get("title") || t("window.title.error"); + const message = params.get("message") || ""; + // Set when the daemon refused an operation that needs elevated privileges: + // the command that performs it, offered for copying. + const command = params.get("command") || ""; + const contentRef = useAutoSizeWindow( + command ? WINDOW_WIDTH_WITH_COMMAND : WINDOW_WIDTH, + ); + + const close = useCallback(() => { + WindowManager.CloseError().catch(console.error); + }, []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); + }, [close]); + + return ( + + + +
+ + {title} + + {message && ( + + {/* select-text: the message often names a path, a flag or an + address the user needs to act on. */} + + {message} + + + )} + {command && ( + + + {command} + + + )} +
+ + + + +
+ ); +} diff --git a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx new file mode 100644 index 000000000..efbd1ee84 --- /dev/null +++ b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { Events } from "@wailsio/runtime"; +import { Loader2 } from "lucide-react"; +import { Connection } from "@bindings/services"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { SquareIcon } from "@/components/SquareIcon"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +const EVENT_CANCEL = "browser-login:cancel"; +const WINDOW_WIDTH = 360; + +export default function LoginWaitingForBrowserDialog() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const uri = params.get("uri") ?? ""; + const contentRef = useAutoSizeWindow(WINDOW_WIDTH); + const openedRef = useRef(false); + + const reportOpenFailure = useCallback( + (e: unknown) => { + void errorDialog({ + Title: t("browserLogin.openFailedTitle"), + Message: formatErrorMessage(e), + }); + }, + [t], + ); + + // Open the browser only after mount, or it lands on top of the still-hidden popup. + useEffect(() => { + if (!uri || openedRef.current) return; + openedRef.current = true; + Connection.OpenURL(uri).catch(reportOpenFailure); + }, [uri, reportOpenFailure]); + + const tryAgain = useCallback(() => { + if (!uri) return; + Connection.OpenURL(uri).catch(reportOpenFailure); + }, [uri, reportOpenFailure]); + + const cancel = useCallback(() => { + Events.Emit(EVENT_CANCEL).catch((err: unknown) => + console.error("emit browser-login cancel", err), + ); + }, []); + + return ( + + + +
+ + {t("browserLogin.title")} + + + {t("browserLogin.notSeeing")}{" "} + + +
+ + + + +
+ ); +} diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx new file mode 100644 index 000000000..c2a648278 --- /dev/null +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -0,0 +1,410 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Events } from "@wailsio/runtime"; +import { Connection, WindowManager } from "@bindings/services"; +import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx"; +import { useStatus } from "@/contexts/StatusContext.tsx"; +import { useProfile } from "@/contexts/ProfileContext.tsx"; +import { cn } from "@/lib/cn.ts"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; +import { + startConnection, + EVENT_BROWSER_LOGIN_CANCEL, + EVENT_TRIGGER_LOGIN, +} from "@/lib/connection.ts"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { TruncatedText } from "@/components/TruncatedText"; +import { shortenDns } from "@/lib/formatters"; +import { contentTop } from "@/components/empty-state/EmptyState"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react"; +import * as Popover from "@radix-ui/react-popover"; +import netbirdFullLogo from "@/assets/logos/netbird-full.svg"; + +enum ConnectionState { + Disconnected = "disconnected", + Connecting = "connecting", + Connected = "connected", + Disconnecting = "disconnecting", +} + +const STATUS_KEY: Record = { + [ConnectionState.Disconnected]: "connect.status.disconnected", + [ConnectionState.Connecting]: "connect.status.connecting", + [ConnectionState.Connected]: "connect.status.connected", + [ConnectionState.Disconnecting]: "connect.status.disconnecting", +}; + +const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]); + +const FORCE_TOGGLE_DELAY_MS = 7000; + +const errorMessage = formatErrorMessage; + +export const MainConnectionStatusSwitch = () => { + const { t } = useTranslation(); + const { status, refresh } = useStatus(); + const { activeProfileId, username } = useProfile(); + + const daemonState = status?.status ?? "Idle"; + const needsLogin = NEEDS_LOGIN_STATES.has(daemonState); + const unreachable = daemonState === "DaemonUnavailable"; + + type Action = "connect" | "logging-in" | "disconnect" | null; + const [action, setAction] = useState(null); + + const loginGuard = useRef(false); + const driveLogin = useCallback(() => { + if (loginGuard.current) return; + loginGuard.current = true; + setAction("logging-in"); + void startConnection(() => { + loginGuard.current = false; + setAction(null); + refresh().catch((err: unknown) => console.error("refresh after login failed", err)); + }); + }, [refresh]); + + const connState: ConnectionState = useMemo(() => { + if (action === "disconnect" && daemonState === "Connected") { + return ConnectionState.Disconnecting; + } + if ((action === "connect" || action === "logging-in") && daemonState !== "Connected") { + return ConnectionState.Connecting; + } + switch (daemonState) { + case "Connected": + return ConnectionState.Connected; + case "Connecting": + return ConnectionState.Connecting; + case "Idle": + case "NeedsLogin": + case "LoginFailed": + case "SessionExpired": + case "DaemonUnavailable": + return ConnectionState.Disconnected; + default: + return ConnectionState.Disconnected; + } + }, [daemonState, action]); + + const connect = async () => { + setAction("connect"); + try { + await Connection.Up({ + profileName: activeProfileId, + username, + }); + await refresh(); + } catch (e) { + setAction(null); + await refresh(); + await errorDialog({ + Title: t("connect.error.connectTitle"), + Message: errorMessage(e), + }); + } + }; + + const disconnect = async () => { + setAction("disconnect"); + try { + await Connection.Down(); + await refresh(); + } catch (e) { + setAction(null); + await refresh(); + await errorDialog({ + Title: t("connect.error.disconnectTitle"), + Message: errorMessage(e), + }); + } + }; + + const sawConnectingRef = useRef(false); + + useEffect(() => { + if (action === null) { + sawConnectingRef.current = false; + return; + } + if (daemonState === "Connecting") { + sawConnectingRef.current = true; + } + if (action === "connect") { + if (needsLogin) { + driveLogin(); + return; + } + if (daemonState === "Connected" || unreachable) { + setAction(null); + return; + } + if (sawConnectingRef.current && daemonState === "Idle") { + setAction(null); + } + return; + } + if (action === "disconnect") { + if (daemonState === "Idle" || daemonState === "Disconnected" || unreachable) { + setAction(null); + } + } + }, [action, daemonState, needsLogin, unreachable, driveLogin]); + + useEffect(() => { + const off = Events.On(EVENT_TRIGGER_LOGIN, () => { + driveLogin(); + }); + return () => off(); + }, [driveLogin]); + + const handleSwitch = (next: boolean) => { + if (unreachable) return; + if (isTransitioning) { + if (canForceCancel) void forceCancel(); + return; + } + if (action !== null) return; + if (needsLogin) { + driveLogin(); + return; + } + if (next && connState === ConnectionState.Disconnected) { + void connect(); + } else if (!next && connState === ConnectionState.Connected) { + void disconnect(); + } + }; + + const isTransitioning = + connState === ConnectionState.Connecting || connState === ConnectionState.Disconnecting; + const isOn = + connState === ConnectionState.Connected || connState === ConnectionState.Connecting; + + const [canForceCancel, setCanForceCancel] = useState(false); + useEffect(() => { + if (!isTransitioning) { + setCanForceCancel(false); + return; + } + const id = setTimeout(() => setCanForceCancel(true), FORCE_TOGGLE_DELAY_MS); + return () => clearTimeout(id); + }, [isTransitioning]); + + const forceCancel = async () => { + if (action === "logging-in") { + Events.Emit(EVENT_BROWSER_LOGIN_CANCEL).catch((err: unknown) => + console.error("emit browser-login cancel failed", err), + ); + } + WindowManager.CloseBrowserLogin().catch((err: unknown) => + console.warn("close browser-login window failed", err), + ); + setAction("disconnect"); + try { + await Connection.Down(); + await refresh(); + } catch (e) { + setAction(null); + await refresh(); + await errorDialog({ + Title: t("connect.error.disconnectTitle"), + Message: errorMessage(e), + }); + } + }; + const show = connState === ConnectionState.Connected; + const fqdn = status?.local.fqdn || ""; + const ip = status?.local.ip || ""; + const ipv6 = status?.local.ipv6 || ""; + + return ( +
+ {"NetBird"} + + + +
+

+ {t(STATUS_KEY[connState])} +

+ + + + +
+
+ ); +}; + +const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const isFocusVisible = useFocusVisible(); + const hasV6 = !!ipv6; + + if (!hasV6) { + return ( + + + {ip || " "} + + + ); + } + + return ( +
+ + + + + + e.preventDefault()} + className={cn( + "z-50 min-w-64 max-w-[280px] overflow-hidden", + "rounded-lg border border-nb-gray-900 bg-nb-gray-935", + "p-1 text-nb-gray-200 shadow-lg outline-none", + "flex flex-col", + )} + > + +
+ + + + +
+ ); +}; + +const IpRow = ({ value }: { value: string }) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const isFocusVisible = useFocusVisible(); + const handleClick = async () => { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 500); + } catch (e) { + console.warn("copy IP to clipboard failed", e); + } + }; + return ( + + ); +}; diff --git a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx new file mode 100644 index 000000000..fc7ce37b2 --- /dev/null +++ b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx @@ -0,0 +1,256 @@ +import { forwardRef, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Popover from "@radix-ui/react-popover"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Command } from "cmdk"; +import { Check, ChevronsUpDown, type LucideProps, SquareArrowUpRight } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { TruncatedText } from "@/components/TruncatedText"; +import { useNetworks } from "@/contexts/NetworksContext"; +import { useStatus } from "@/contexts/StatusContext"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; + +const NONE_VALUE = "__none__"; + +export const MainExitNodeSwitcher = () => { + const { t } = useTranslation(); + const { status } = useStatus(); + const { exitNodes, toggleExitNode } = useNetworks(); + const active = exitNodes.find((n) => n.selected) ?? null; + const isConnected = status?.status === "Connected"; + const hasAny = exitNodes.length > 0; + const disabled = !isConnected || !hasAny; + + const [open, setOpen] = useState(false); + const listRef = useRef(null); + + const handleTriggerKeyDown = (e: React.KeyboardEvent) => { + if (open || disabled) return; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setOpen(true); + } + }; + + const handleSelect = (next: string) => { + setOpen(false); + if (next === NONE_VALUE) { + if (active) + toggleExitNode(active.id, true).catch((err: unknown) => + console.error("toggle exit node failed", err), + ); + return; + } + if (active?.id === next) return; + toggleExitNode(next, false).catch((err: unknown) => + console.error("toggle exit node failed", err), + ); + }; + + const title = active ? active.id : t("exitNodes.card.title"); + const activeDescription = active + ? t("exitNodes.card.statusActive") + : t("exitNodes.card.statusInactive"); + const description = hasAny ? activeDescription : t("exitNodes.empty.title"); + + return ( + + + + + + { + e.preventDefault(); + listRef.current?.focus(); + }} + style={{ width: "var(--radix-popover-trigger-width)" }} + className={cn( + "wails-no-draggable z-50 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + )} + > + e.stopPropagation()} + className={"outline-none focus:outline-none focus-visible:outline-none"} + > + + handleSelect(NONE_VALUE)} /> + {hasAny &&
} + {hasAny && ( + + + {exitNodes.map((n) => ( + handleSelect(n.id)} + /> + ))} + + + + + + )} + + + + + + ); +}; + +type TriggerProps = React.ButtonHTMLAttributes & { + title: string; + description: string; + active?: boolean; +}; + +const ExitNodeTriggerCard = forwardRef( + function ExitNodeTriggerCard( + { title, description, disabled, active = false, className, ...props }, + ref, + ) { + const isFocusVisible = useFocusVisible(); + return ( + + ); + }, +); + +type NoneRowProps = { + isActive: boolean; + onSelect: () => void; +}; + +const NoneRow = ({ isActive, onSelect }: NoneRowProps) => { + const { t } = useTranslation(); + return ( + + {t("exitNodes.dropdown.noneTitle")} + {isActive && ( + + )} + + ); +}; + +type ExitNodeRowProps = { + id: string; + label: string; + isActive: boolean; + onSelect: () => void; +}; + +const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => ( + + {label} + {isActive && } + +); + +const ExitNodeIcon = ({ size, ...props }: LucideProps) => ( + +); diff --git a/client/ui/frontend/src/modules/main/MainHeader.tsx b/client/ui/frontend/src/modules/main/MainHeader.tsx new file mode 100644 index 000000000..becfde47c --- /dev/null +++ b/client/ui/frontend/src/modules/main/MainHeader.tsx @@ -0,0 +1,192 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ArrowUpCircleIcon, + Check, + MoreVertical, + PanelsRightBottom, + RectangleVertical, + Settings, + type LucideIcon, +} from "lucide-react"; +import { WindowManager } from "@bindings/services"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; +import { IconButton } from "@/components/buttons/IconButton"; +import { ProfileDropdown } from "@/modules/profiles/ProfileDropdown"; +import { useClientVersion } from "@/contexts/ClientVersionContext"; +import { cn } from "@/lib/cn"; +import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut"; +import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext"; +import { useRestrictions } from "@/contexts/RestrictionsContext"; +import { isWindows } from "@/lib/platform.ts"; + +const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const; + +export const MainHeader = () => { + const { t } = useTranslation(); + const [menuOpen, setMenuOpen] = useState(false); + const { viewMode, setViewMode } = useViewMode(); + const { updateAvailable } = useClientVersion(); + const { mdm, features } = useRestrictions(); + + const openSettings = useCallback(() => { + setMenuOpen(false); + WindowManager.OpenSettings("").catch((err: unknown) => + console.error("open settings window failed", err), + ); + }, []); + + useKeyboardShortcut(SETTINGS_SHORTCUT, openSettings); + + const openAbout = () => { + setMenuOpen(false); + WindowManager.OpenSettings("about").catch((err: unknown) => + console.error("open settings (about) window failed", err), + ); + }; + + const openManageProfiles = () => { + WindowManager.OpenSettings("profiles").catch((err: unknown) => + console.error("open settings (profiles) window failed", err), + ); + }; + + const selectMode = (mode: ViewMode) => { + setMenuOpen(false); + setViewMode(mode); + }; + + const profileSlot = features.disableProfiles ? null : ( + + ); + + const settingsSlot = ( +
+ + + + + + {updateAvailable && ( + <> + +
+ + + {t("header.menu.updateAvailable")} + +
+
+ + + )} + +
+ + {t("header.menu.settings")} + + {formatShortcut(SETTINGS_SHORTCUT)} + +
+
+ {!mdm.disableAdvancedView && ( + <> + + selectMode("default")} + /> + selectMode("advanced")} + /> + + )} +
+
+ {updateAvailable && ( + + + + + )} +
+ ); + + return ( +
+ {/* Windows narrower width compensates for the OS frame Wails counts differently than macOS. + See https://github.com/wailsapp/wails/issues/3260 */} +
+
+
{profileSlot}
+
+
+
{settingsSlot}
+
+ ); +}; + +type ViewModeItemProps = { + icon: LucideIcon; + label: string; + selected: boolean; + onSelect: () => void; +}; + +const ViewModeItem = ({ icon: Icon, label, selected, onSelect }: ViewModeItemProps) => ( + +
+ + {label} + {selected && } +
+
+); diff --git a/client/ui/frontend/src/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx new file mode 100644 index 000000000..c05b3a025 --- /dev/null +++ b/client/ui/frontend/src/modules/main/MainPage.tsx @@ -0,0 +1,114 @@ +import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx"; +import { MainExitNodeSwitcher } from "@/modules/main/MainExitNodeSwitcher.tsx"; +import { MainHeader } from "@/modules/main/MainHeader.tsx"; +import { AppRightPanel } from "@/layouts/AppRightPanel.tsx"; +import { Navigation } from "@/modules/main/advanced/Navigation.tsx"; +import { cn } from "@/lib/cn"; +import { NavSectionProvider, useNavSection } from "@/contexts/NavSectionContext"; +import { ViewModeProvider, useViewMode } from "@/contexts/ViewModeContext"; +import { useEffect } from "react"; +import { NotConnectedState } from "@/components/empty-state/NotConnectedState"; +import { useStatus } from "@/contexts/StatusContext"; +import { Peers } from "@/modules/main/advanced/peers/Peers"; +import { Networks } from "@/modules/main/advanced/networks/Networks"; +import { NetworksProvider } from "@/contexts/NetworksContext"; +import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext"; +import { useRestrictions } from "@/contexts/RestrictionsContext"; +import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel"; +import { isWindows } from "@/lib/platform.ts"; + +export const MainPage = () => { + return ( + + + + + + + + + ); +}; + +const MainBody = () => { + const { viewMode, setViewMode } = useViewMode(); + const { mdm, features } = useRestrictions(); + + // Force flip the view if MDM disabled advanced + useEffect(() => { + if (mdm.disableAdvancedView && viewMode === "advanced") { + setViewMode("default"); + } + }, [mdm.disableAdvancedView, viewMode, setViewMode]); + + const isAdvanced = viewMode === "advanced"; + + return ( +
+ {/* Windows narrower width compensates for the OS frame Wails counts differently than macOS. + See https://github.com/wailsapp/wails/issues/3260 */} +
+ + {!features.disableNetworks && ( +
+ +
+ )} +
+ {isAdvanced && ( + + + + )} +
+ ); +}; + +const AdvancedAppRightPanel = () => { + const { section } = useNavSection(); + const { selected } = usePeerDetail(); + const { status } = useStatus(); + const isConnected = status?.status === "Connected"; + + return ( + } + overlayOpen={selected !== null} + className={"m-5 ml-0"} + > +
{ + if (!el) return; + if (isConnected) el.removeAttribute("inert"); + else el.setAttribute("inert", ""); + }} + className={cn( + "flex min-h-0 min-w-0 flex-1 flex-col", + !isConnected && "pointer-events-none select-none", + )} + aria-hidden={!isConnected} + > + +
+ {section === "peers" && } + {section === "networks" && } +
+
+ {!isConnected && ( +
+ +
+ )} +
+ ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx new file mode 100644 index 000000000..8d69f3580 --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx @@ -0,0 +1,134 @@ +import { type ComponentType, type KeyboardEvent, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { useNavSection, type NavSection } from "@/contexts/NavSectionContext"; +import { useStatus } from "@/contexts/StatusContext"; +import { useRestrictions } from "@/contexts/RestrictionsContext"; + +type TabEntry = { + value: NavSection; + label: string; + icon: ComponentType; +}; + +export const Navigation = () => { + const { t } = useTranslation(); + const { section, setSection } = useNavSection(); + const { status } = useStatus(); + const { features } = useRestrictions(); + const isConnected = status?.status === "Connected"; + + // Reset back to peers tab if mdm or feature flag flipped it + useEffect(() => { + if (features.disableNetworks && section === "networks") { + setSection("peers"); + } + }, [features.disableNetworks, section, setSection]); + + const tabs: TabEntry[] = [ + { + value: "peers", + label: t("nav.peers.title"), + icon: MonitorSmartphoneIcon, + }, + ]; + if (!features.disableNetworks) { + tabs.push({ + value: "networks", + label: t("nav.resources.title"), + icon: Layers3Icon, + }); + } + + const tabRefs = useRef>({}); + + const focusTab = (value: NavSection) => { + setSection(value); + requestAnimationFrame(() => tabRefs.current[value]?.focus()); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + const enabled = tabs.filter((t) => isConnected || t.value === section); + if (enabled.length < 2) return; + const currentIndex = enabled.findIndex((t) => t.value === section); + if (currentIndex === -1) return; + let nextIndex: number; + switch (e.key) { + case "ArrowRight": + nextIndex = (currentIndex + 1) % enabled.length; + break; + case "ArrowLeft": + nextIndex = (currentIndex - 1 + enabled.length) % enabled.length; + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = enabled.length - 1; + break; + default: + return; + } + e.preventDefault(); + focusTab(enabled[nextIndex].value); + }; + + return ( +
+ {tabs.map((tab, index) => { + const isActive = tab.value === section; + const isDisabled = !isConnected && !isActive; + const isFirst = index === 0; + const isLast = index === tabs.length - 1; + const Icon = tab.icon; + return ( + + ); + })} +
+ ); +}; + +export type { NavSection } from "@/contexts/NavSectionContext"; diff --git a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx new file mode 100644 index 000000000..ec1823f0b --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { CheckIcon, ChevronDown, ListFilter } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; + +export type NetworkFilter = "all" | "active" | "overlapping"; + +type Props = { + value: NetworkFilter; + onChange: (value: NetworkFilter) => void; + counts: Record; + disabled?: boolean; +}; + +export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const filters: { value: NetworkFilter; label: string }[] = [ + { value: "all", label: t("networks.filter.all") }, + { value: "active", label: t("networks.filter.active") }, + { value: "overlapping", label: t("networks.filter.overlapping") }, + ]; + const active = filters.find((f) => f.value === value) ?? filters[0]; + + const handleSelect = (v: NetworkFilter) => { + onChange(v); + setOpen(false); + }; + + return ( + + + + + {active.label} ({counts[active.value]}) + + + + + {filters.map((f) => { + const checked = f.value === value; + return ( + handleSelect(f.value)} + role={"menuitemradio"} + aria-checked={checked} + className={"gap-2"} + > + + {f.label}{" "} + ({counts[f.value]}) + + + {checked && } + + + ); + })} + + + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx new file mode 100644 index 000000000..f6fe23aaf --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -0,0 +1,528 @@ +import { + type KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, + type ComponentType, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; +import { GlobeIcon, Layers3Icon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react"; +import type { Network } from "@bindings/services/models.js"; +import { cn } from "@/lib/cn"; +import { reconcileOrder } from "@/lib/sorting"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { Tooltip } from "@/components/Tooltip"; +import { TruncatedText } from "@/components/TruncatedText"; +import { SearchInput } from "@/components/inputs/SearchInput"; +import { EmptyState } from "@/components/empty-state/EmptyState"; +import { NoResults } from "@/components/empty-state/NoResults"; +import { useStatus } from "@/contexts/StatusContext"; +import { useNetworks } from "@/contexts/NetworksContext"; +import { type NetworkFilter, NetworkFilters } from "./NetworkFilters"; + +// Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix". +const INVALID_PREFIX = "invalid Prefix"; + +const isDnsRoute = (n: Network): boolean => + n.domains.length > 0 && (!n.range || n.range === INVALID_PREFIX); + +type ResourceType = "host" | "subnet" | "domain"; + +const isHostCidr = (cidr: string): boolean => { + const [addr, bitsStr] = cidr.split("/"); + if (!addr || !bitsStr) return false; + const bits = Number(bitsStr); + const isV6 = addr.includes(":"); + return isV6 ? bits === 128 : bits === 32; +}; + +const resourceTypeOf = (n: Network): ResourceType => { + if (isDnsRoute(n)) return "domain"; + const primary = n.range.split(",")[0].trim(); + return isHostCidr(primary) ? "host" : "subnet"; +}; + +const resourceIconFor = (type: ResourceType): ComponentType => { + if (type === "host") return WorkflowIcon; + if (type === "domain") return GlobeIcon; + return NetworkIcon; +}; + +const buildOverlapMap = ( + routes: { id: string; range: string; domains: string[] }[], +): Map => { + const byRange = new Map(); + for (const r of routes) { + if (r.domains.length > 0) continue; + const arr = byRange.get(r.range) ?? []; + arr.push(r.id); + byRange.set(r.range, arr); + } + const out = new Map(); + for (const [range, ids] of byRange) { + if (ids.length > 1) out.set(range, ids); + } + return out; +}; + +export const Networks = () => { + const { t } = useTranslation(); + const { status } = useStatus(); + const isConnected = status?.status === "Connected"; + const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks(); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const [scrollParent, setScrollParent] = useState(null); + const searchRef = useRef(null); + + useEffect(() => { + searchRef.current?.focus(); + }, []); + + const overlapGroups = useMemo(() => buildOverlapMap(networkRoutes), [networkRoutes]); + + const overlapById = useMemo(() => { + const map = new Map(); + for (const ids of overlapGroups.values()) { + for (const id of ids) map.set(id, ids); + } + return map; + }, [overlapGroups]); + + const counts = useMemo>( + () => ({ + all: networkRoutes.length, + active: networkRoutes.filter((r) => r.selected).length, + overlapping: overlapById.size, + }), + [networkRoutes, overlapById], + ); + + const orderRef = useRef([]); + const ordered = useMemo(() => { + const { order, items } = reconcileOrder( + orderRef.current, + networkRoutes, + (r) => r.id, + (a, b) => { + if (a.selected !== b.selected) return a.selected ? -1 : 1; + return a.id.localeCompare(b.id); + }, + ); + orderRef.current = order; + return items; + }, [networkRoutes]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return ordered.filter((r) => { + if (filter === "active" && !r.selected) return false; + if (filter === "overlapping" && !overlapById.has(r.id)) return false; + if (q) { + const haystack = [r.id, r.range, ...r.domains].join(" ").toLowerCase(); + if (!haystack.includes(q)) return false; + } + return true; + }); + }, [ordered, search, filter, overlapById]); + + if (isConnected && networkRoutes.length === 0) { + return ( + + ); + } + + const selectedInView = filtered.filter((r) => r.selected).length; + const allSelected = filtered.length > 0 && selectedInView === filtered.length; + const bulkLabel = allSelected ? t("networks.bulk.disableAll") : t("networks.bulk.enableAll"); + + const onBulkClick = () => { + if (filtered.length === 0) return; + if (allSelected) { + setNetworksSelected( + filtered.map((r) => r.id), + false, + ).catch((err: unknown) => console.error("disable all networks failed", err)); + } else { + const ids = filtered.filter((r) => !r.selected).map((r) => r.id); + setNetworksSelected(ids, true).catch((err: unknown) => + console.error("enable all networks failed", err), + ); + } + }; + + return ( +
+
+
+ setSearch(e.target.value)} + /> +
+ +
+ {filtered.length === 0 ? ( + + ) : ( + + + {scrollParent && ( + + )} + + + + + + )} + {filtered.length > 0 && ( +
+ + {t("networks.bulk.selectionCount", { + selected: selectedInView, + total: filtered.length, + })} + + +
+ )} +
+ ); +}; + +type NetworksListProps = { + data: Network[]; + onToggle: (id: string, selected: boolean) => void; + scrollParent: HTMLElement; +}; + +const NetworksHeader = () =>
; + +const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => { + const virtuosoRef = useRef(null); + const rowRefs = useRef>(new Map()); + + const focusRow = (index: number) => { + if (index < 0 || index >= data.length) return; + const row = data[index]; + const tryFocus = () => { + const el = rowRefs.current.get(row.id); + if (el) { + el.focus(); + return true; + } + return false; + }; + if (!tryFocus()) { + virtuosoRef.current?.scrollToIndex({ index, behavior: "auto" }); + requestAnimationFrame(() => { + if (!tryFocus()) requestAnimationFrame(tryFocus); + }); + } + }; + + const handleRowKeyDown = (e: KeyboardEvent, index: number) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + focusRow(Math.min(index + 1, data.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + focusRow(Math.max(index - 1, 0)); + break; + case "Home": + e.preventDefault(); + focusRow(0); + break; + case "End": + e.preventDefault(); + focusRow(data.length - 1); + break; + } + }; + + const setRowRef = (id: string, el: HTMLButtonElement | null) => { + if (el) rowRefs.current.set(id, el); + else rowRefs.current.delete(id); + }; + + const ctx = useMemo( + () => ({ onKeyDown: handleRowKeyDown, onToggle, setRowRef }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [data, onToggle], + ); + + return ( + + ref={virtuosoRef} + data={data} + customScrollParent={scrollParent} + increaseViewportBy={400} + computeItemKey={(_, n) => n.id} + components={{ Header: NetworksHeader }} + context={ctx} + itemContent={renderNetworkRow} + /> + ); +}; + +type NetworkRowContext = { + onKeyDown: (e: KeyboardEvent, index: number) => void; + onToggle: (id: string, selected: boolean) => void; + setRowRef: (id: string, el: HTMLButtonElement | null) => void; +}; + +const renderNetworkRow = (index: number, n: Network, ctx: NetworkRowContext): ReactNode => ( + +); + +type NetworkRowProps = { + network: Network; + index: number; + onKeyDown: (e: KeyboardEvent, index: number) => void; + onToggle: (id: string, selected: boolean) => void; + setRowRef: (id: string, el: HTMLButtonElement | null) => void; +}; + +const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: NetworkRowProps) => { + const { t } = useTranslation(); + // Same handler is attached to the overlay button and to the network-id copy + // button so arrow nav works wherever focus sits inside the row. + const handleKey = (e: KeyboardEvent) => onKeyDown(e, index); + return ( +
+
+ ); +}; + +const ResourceIconBadge = ({ type }: { type: ResourceType }) => { + const Icon = resourceIconFor(type); + return ( +
+ +
+ ); +}; + +type SubtitleProps = { + network: Network; + onKeyDown: (e: KeyboardEvent) => void; +}; + +const Subtitle = ({ network, onKeyDown }: SubtitleProps) => { + if (isDnsRoute(network)) { + const domain = network.domains[0]; + const ips = network.resolvedIps[domain] ?? []; + return ; + } + + if (network.range && network.range !== INVALID_PREFIX) { + return ( +
+ + + +
+ ); + } + + return null; +}; + +type DomainSubtitleProps = { + domain: string; + ips: string[]; + onKeyDown: (e: KeyboardEvent) => void; +}; + +const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => { + const span = ( + + {domain} + + ); + return ( +
+ + {ips.length > 0 ? ( + } + delayDuration={300} + closeDelay={300} + side={"right"} + align={"start"} + alignOffset={-8} + interactive + keepOpenOnClick + contentClassName={cn( + "max-h-72 max-w-[18rem] overflow-auto", + "rounded-lg border border-nb-gray-900 bg-nb-gray-935", + "p-2 pr-4", + )} + > + {span} + + ) : ( + span + )} + +
+ ); +}; + +const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => { + const { t } = useTranslation(); + return ( + <> +
+ {t("networks.ips.heading")} +
+
    + {ips.map((ip) => ( +
  • + + + {ip} + + +
  • + ))} +
+ + ); +}; + +type ToggleProps = { + checked: boolean; + mixed?: boolean; +}; + +const NetworkToggle = ({ checked, mixed }: ToggleProps) => { + const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5"; + return ( + + + + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx new file mode 100644 index 000000000..16500589d --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -0,0 +1,575 @@ +import { + type ComponentType, + Fragment, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { AnimatePresence, motion, type Transition } from "framer-motion"; +import * as Popover from "@radix-ui/react-popover"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { + ArrowDownIcon, + ArrowLeftIcon, + ArrowUpDownIcon, + ArrowUpIcon, + Check as CheckIcon, + ChevronDownIcon, + ChevronsLeftRightEllipsisIcon, + ClockIcon, + Copy as CopyIcon, + GaugeIcon, + HandshakeIcon, + KeyRoundIcon, + Layers3Icon, + type LucideProps, + MapPinIcon, + MonitorIcon, + Radio, + RefreshCwIcon, + WaypointsIcon, +} from "lucide-react"; +import type { PeerStatus } from "@bindings/services/models.js"; +import { cn } from "@/lib/cn"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { Tooltip } from "@/components/Tooltip"; +import { TruncatedText } from "@/components/TruncatedText"; +import { formatBytes, formatRelative, latencyColor, shortenDns } from "@/lib/formatters"; +import { useStatus } from "@/contexts/StatusContext"; +import { usePeerDetail } from "@/contexts/PeerDetailContext"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { peerStatusLabelKey } from "./Peers"; + +const DEFAULT_TRANSITION: Transition = { + duration: 0.32, + ease: [0.32, 0.72, 0, 1], +}; + +const DASH = "-"; + +const dotClass = (connStatus: string): string => { + switch (connStatus) { + case "Connected": + return "bg-green-400"; + case "Connecting": + return "bg-yellow-300 animate-pulse-slow"; + default: + return "bg-nb-gray-500"; + } +}; + +type Props = { + transition?: Transition; +}; + +export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { + const { t } = useTranslation(); + const { selected, setSelected } = usePeerDetail(); + const { status, refresh } = useStatus(); + + useEffect(() => { + if (!selected) return; + const peers = status?.peers ?? []; + const fresh = peers.find((p) => p.pubKey === selected.pubKey); + if (!fresh) { + setSelected(null); + return; + } + if (fresh !== selected) setSelected(fresh); + }, [status, selected, setSelected]); + + // Daemon updates latency/bytes/handshake without pushing a fresh status + // snapshot, so tick locally to keep relative timestamps live. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!selected) return; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [selected]); + + const [refreshing, setRefreshing] = useState(false); + const onRefresh = useCallback(async () => { + if (refreshing) return; + setRefreshing(true); + const MIN_SPIN_MS = 600; + const minDelay = new Promise((r) => setTimeout(r, MIN_SPIN_MS)); + try { + await Promise.all([refresh(), minDelay]); + } finally { + setRefreshing(false); + } + }, [refresh, refreshing]); + + useEffect(() => { + if (!selected) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setSelected(null); + return; + } + if (e.key === "ArrowLeft") { + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return; + setSelected(null); + } + }; + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); + }, [selected, setSelected]); + + const dialogRef = useRef(null); + const backButtonRef = useRef(null); + + useEffect(() => { + if (!selected) return; + // Defer focus until the slide-in animation has started rendering. + // preventScroll avoids the browser scrolling the parent to chase the + // still-offscreen button, which lands as a stutter at the end of the slide. + requestAnimationFrame(() => backButtonRef.current?.focus({ preventScroll: true })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected?.pubKey]); + + const getFocusable = (): HTMLElement[] => { + const root = dialogRef.current; + if (!root) return []; + const sel = + "button:not([disabled]), [href], input:not([disabled]), select:not([disabled])," + + ' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + return Array.from(root.querySelectorAll(sel)).filter( + (el) => el.offsetParent !== null || el === document.activeElement, + ); + }; + + const onDialogKeyDown = (e: ReactKeyboardEvent) => { + if (e.key !== "Tab") return; + const focusables = getFocusable(); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement as HTMLElement | null; + if (e.shiftKey) { + if (active === first || !active || !dialogRef.current?.contains(active)) { + e.preventDefault(); + last.focus(); + } + } else if (active === last) { + e.preventDefault(); + first.focus(); + } + }; + + return ( + + {selected && ( + +
+ + + + + + + {shortenDns(selected.fqdn) || selected.ip} + + + + + +
+ + + + + + + + +
+ )} +
+ ); +}; + +const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => { + const { t } = useTranslation(); + const formatAge = (unix: number, fallback: string): string => { + if (!Number.isFinite(unix) || unix <= 0) return fallback; + const diff = Math.floor(now / 1000 - unix); + if (diff < 1) return t("peers.details.justNow"); + return formatRelative(unix, now) ?? fallback; + }; + const lastHandshake = formatAge(peer.lastHandshakeUnix, t("peers.details.never")); + const statusSince = formatAge(peer.connStatusUpdateUnix, DASH); + const isConnected = peer.connStatus === "Connected"; + const connectionLabel = peer.relayed ? t("peers.details.relayed") : t("peers.details.p2p"); + + return ( +
    + + {peer.ip ? ( + + {peer.ip} + + ) : ( + DASH + )} + + {peer.ipv6 && ( + + + + + + )} + {isConnected && ( + + {connectionLabel} + + )} + {peer.relayed && ( + + {peer.relayAddress ? ( + + + + ) : ( + DASH + )} + + )} + {peer.latencyMs > 0 && ( + + + {peer.latencyMs} ms + + + )} + {(peer.bytesRx > 0 || peer.bytesTx > 0) && ( + +
    +
    + + {t("peers.details.bytesReceived")}: + {formatBytes(peer.bytesRx)} +
    +
    + + {t("peers.details.bytesSent")}: + {formatBytes(peer.bytesTx)} +
    +
    +
    + )} + + {lastHandshake} + + + {statusSince} + + {peer.networks.length > 0 && ( + + + + )} + + + + {peer.pubKey ? ( + + + + ) : ( + DASH + )} + +
+ ); +}; + +type RowProps = { + icon: ComponentType; + iconClassName?: string; + label: string; + children: ReactNode; +}; + +type IceRowProps = { + icon: ComponentType; + baseLabel: string; + type: string; + endpoint: string; +}; + +const capitalize = (s: string): string => (s ? s[0].toUpperCase() + s.slice(1) : s); + +const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => { + if (!type && !endpoint) return null; + const label = type ? `${baseLabel} (${capitalize(type)})` : baseLabel; + return ( + + {endpoint ? ( + + + + ) : ( + {capitalize(type)} + )} + + ); +}; + +const ResourcesValue = ({ networks }: { networks: string[] }) => ( + +); + +const ResourcesPopover = ({ networks }: { networks: string[] }) => { + const [open, setOpen] = useState(false); + + return ( + + + + + + e.preventDefault()} + className={cn( + "z-50 max-h-72 min-w-64 max-w-[280px] overflow-auto", + "rounded-lg border border-nb-gray-900 bg-nb-gray-935", + "p-1 text-nb-gray-200 shadow-lg outline-none", + "flex flex-col", + )} + > + {networks.map((n, i) => ( + + {i > 0 &&
} + + + ))} + + + + ); +}; + +const ResourceRow = ({ value }: { value: string }) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const isFocusVisible = useFocusVisible(); + const handleClick = async () => { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 500); + } catch (e) { + console.warn("copy resource to clipboard failed", e); + } + }; + return ( + + ); +}; + +const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => ( + +); + +const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => ( +
  • + + {label} + + {children} + +
  • +); diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx new file mode 100644 index 000000000..25d9ef822 --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { CheckIcon, ChevronDown, ListFilter } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; + +export type StatusFilter = "all" | "online" | "offline"; + +type Props = { + value: StatusFilter; + onChange: (value: StatusFilter) => void; + counts: Record; + disabled?: boolean; +}; + +export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const filters: { value: StatusFilter; label: string }[] = [ + { value: "all", label: t("peers.filter.all") }, + { value: "online", label: t("peers.filter.online") }, + { value: "offline", label: t("peers.filter.offline") }, + ]; + const active = filters.find((f) => f.value === value) ?? filters[0]; + + const handleSelect = (v: StatusFilter) => { + onChange(v); + setOpen(false); + }; + + return ( + + + + + {active.label} ({counts[active.value]}) + + + + + {filters.map((f) => { + const checked = f.value === value; + return ( + handleSelect(f.value)} + role={"menuitemradio"} + aria-checked={checked} + className={"gap-2"} + > + + {f.label}{" "} + ({counts[f.value]}) + + + {checked && } + + + ); + })} + + + ); +}; diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx new file mode 100644 index 000000000..d61221758 --- /dev/null +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -0,0 +1,353 @@ +import { type KeyboardEvent, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; +import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react"; +import type { PeerStatus } from "@bindings/services/models.js"; +import { cn } from "@/lib/cn"; +import { reconcileOrder } from "@/lib/sorting"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { SearchInput } from "@/components/inputs/SearchInput"; +import { EmptyState } from "@/components/empty-state/EmptyState"; +import { NoResults } from "@/components/empty-state/NoResults"; +import { latencyColor, shortenDns } from "@/lib/formatters"; +import { useStatus } from "@/contexts/StatusContext"; +import { usePeerDetail } from "@/contexts/PeerDetailContext"; +import { Tooltip } from "@/components/Tooltip"; +import { TruncatedText } from "@/components/TruncatedText"; +import { PeerFilters, type StatusFilter } from "./PeerFilters"; + +const isOnline = (connStatus: string) => connStatus === "Connected"; + +const dotClass = (connStatus: string): string => { + switch (connStatus) { + case "Connected": + return "bg-green-400"; + case "Connecting": + return "bg-yellow-300 animate-pulse-slow"; + default: + return "bg-nb-gray-500"; + } +}; + +export const peerStatusLabelKey = (connStatus: string): string => { + switch (connStatus) { + case "Connected": + return "peers.status.connected"; + case "Connecting": + return "peers.status.connecting"; + default: + return "peers.status.disconnected"; + } +}; + +export const Peers = () => { + const { t } = useTranslation(); + const { status } = useStatus(); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [scrollParent, setScrollParent] = useState(null); + const searchRef = useRef(null); + + useEffect(() => { + searchRef.current?.focus(); + }, []); + + const isConnected = status?.status === "Connected"; + const peers = useMemo(() => status?.peers ?? [], [status?.peers]); + + const counts = useMemo>(() => { + const online = peers.filter((p) => isOnline(p.connStatus)).length; + return { + all: peers.length, + online, + offline: peers.length - online, + }; + }, [peers]); + + // Stay in live-sort until every peer is stable. Right after Up the daemon + // emits all peers as "Connecting"; committing then would lock that + // alphabetical-only order forever. + const orderRef = useRef([]); + const stickyRef = useRef(false); + const ordered = useMemo(() => { + const compare = (a: PeerStatus, b: PeerStatus) => { + const aOnline = isOnline(a.connStatus); + const bOnline = isOnline(b.connStatus); + if (aOnline !== bOnline) return aOnline ? -1 : 1; + const aName = (a.fqdn || a.ip).toLowerCase(); + const bName = (b.fqdn || b.ip).toLowerCase(); + return aName.localeCompare(bName); + }; + + if (peers.length === 0) { + orderRef.current = []; + stickyRef.current = false; + return []; + } + + if (!stickyRef.current) { + const sorted = [...peers].sort(compare); + if (peers.every((p) => p.connStatus !== "Connecting")) { + orderRef.current = sorted.map((p) => p.pubKey); + stickyRef.current = true; + } + return sorted; + } + + const { order, items } = reconcileOrder(orderRef.current, peers, (p) => p.pubKey, compare); + orderRef.current = order; + return items; + }, [peers]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return ordered.filter((p) => { + if (statusFilter === "online" && !isOnline(p.connStatus)) return false; + if (statusFilter === "offline" && isOnline(p.connStatus)) return false; + return !q || p.fqdn.toLowerCase().includes(q) || p.ip.includes(q); + }); + }, [ordered, search, statusFilter]); + + if (isConnected && peers.length === 0) { + return ( + + ); + } + + return ( +
    +
    +
    + setSearch(e.target.value)} + /> +
    + +
    + {filtered.length === 0 ? ( + + ) : ( + + + {scrollParent && } + + + + + + )} +
    + ); +}; + +const ListTopSpacer = () =>
    ; + +type PeersListProps = { + data: PeerStatus[]; + scrollParent: HTMLElement; +}; + +const PeersList = ({ data, scrollParent }: PeersListProps) => { + const { setSelected } = usePeerDetail(); + const virtuosoRef = useRef(null); + const rowRefs = useRef>(new Map()); + + const focusRow = (index: number) => { + if (index < 0 || index >= data.length) return; + const peer = data[index]; + const tryFocus = () => { + const el = rowRefs.current.get(peer.pubKey); + if (el) { + el.focus(); + return true; + } + return false; + }; + if (!tryFocus()) { + virtuosoRef.current?.scrollToIndex({ index, behavior: "auto" }); + // Row may not be mounted yet — retry after Virtuoso renders it. + requestAnimationFrame(() => { + if (!tryFocus()) requestAnimationFrame(tryFocus); + }); + } + }; + + const handleRowKeyDown = (e: KeyboardEvent, index: number) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + focusRow(Math.min(index + 1, data.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + focusRow(Math.max(index - 1, 0)); + break; + case "ArrowRight": + e.preventDefault(); + setSelected(data[index]); + break; + case "Home": + e.preventDefault(); + focusRow(0); + break; + case "End": + e.preventDefault(); + focusRow(data.length - 1); + break; + } + }; + + const setRowRef = (pubKey: string, el: HTMLButtonElement | null) => { + if (el) rowRefs.current.set(pubKey, el); + else rowRefs.current.delete(pubKey); + }; + + const ctx = useMemo( + () => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [data, setSelected], + ); + + return ( + + ref={virtuosoRef} + data={data} + customScrollParent={scrollParent} + increaseViewportBy={400} + computeItemKey={(_, peer) => peer.pubKey} + components={{ Header: ListTopSpacer }} + context={ctx} + itemContent={renderPeerRow} + /> + ); +}; + +type PeerRowContext = { + onKeyDown: (e: KeyboardEvent, index: number) => void; + onSelect: (peer: PeerStatus) => void; + setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void; +}; + +const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): ReactNode => ( + +); + +type PeerRowProps = { + peer: PeerStatus; + index: number; + onKeyDown: (e: KeyboardEvent, index: number) => void; + onSelect: (peer: PeerStatus) => void; + setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void; +}; + +const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) => { + const { t } = useTranslation(); + const isConnected = peer.connStatus === "Connected"; + const peerName = shortenDns(peer.fqdn) || peer.ip; + const statusLabel = t(peerStatusLabelKey(peer.connStatus)); + const handleKey = (e: KeyboardEvent) => onKeyDown(e, index); + return ( +
    +
    + ); +}; diff --git a/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx b/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx new file mode 100644 index 000000000..70d45acc5 --- /dev/null +++ b/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx @@ -0,0 +1,76 @@ +import { type ButtonHTMLAttributes, forwardRef } from "react"; +import { + Briefcase, + Building, + Cloud, + Construction, + FlaskConical, + Gamepad2, + GraduationCap, + House, + Radio, + Server, + SquareCode, + Terminal, + UserCircle, + UserPlus, + Users, + type LucideIcon, +} from "lucide-react"; +import { cn } from "@/lib/cn"; + +// Scanned in order — put more-specific tokens first (e.g. "staging" before "stage"). +const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [ + [/(default|personal)/i, UserCircle], + [/(work|business|office|company|corp|corporate)/i, Briefcase], + [/(home|house|private)/i, House], + [/(dev|development|developer|code|coding|engineering)/i, SquareCode], + [/(local|localhost|loopback)/i, Terminal], + [/(stage|staging|preprod|pre-prod)/i, Construction], + [/(test|testing|qa)/i, FlaskConical], + [/(prod|production)/i, Cloud], + [/(live)/i, Radio], + [/(selfhosted|self-hosted|on-prem|onprem)/i, Server], + [/(school|university|edu|study|student)/i, GraduationCap], + [/(client|customer)/i, Building], + [/(family)/i, Users], + [/(gaming|game)/i, Gamepad2], + [/(guest)/i, UserPlus], +]; + +export const pickProfileIcon = (name: string | undefined): LucideIcon | null => { + if (!name) return null; + for (const [pattern, Icon] of ICON_MAP) { + if (pattern.test(name)) return Icon; + } + return null; +}; + +type Props = ButtonHTMLAttributes & { + name?: string; + size?: number; +}; + +export const ProfileAvatar = forwardRef(function ProfileAvatar( + { name = "", size = 28, className, type = "button", ...props }, + ref, +) { + const Icon = pickProfileIcon(name) ?? UserCircle; + return ( + + ); +}); diff --git a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx new file mode 100644 index 000000000..19313ccb3 --- /dev/null +++ b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx @@ -0,0 +1,263 @@ +import { type FormEvent, useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Dialog from "@/components/dialog/Dialog"; +import { Input } from "@/components/inputs/Input"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { Label } from "@/components/typography/Label"; +import { HelpText } from "@/components/typography/HelpText"; +import { ManagementServerSwitch } from "@/components/ManagementServerSwitch"; +import { + CLOUD_MANAGEMENT_URL, + ManagementMode, + checkManagementUrlReachable, + isValidManagementUrl, + normalizeManagementUrl, +} from "@/hooks/useManagementUrl"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +export type ProfileFormInitial = { + name: string; + managementUrl: string; +}; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (name: string, managementUrl: string) => void | Promise; + initial?: ProfileFormInitial; +}; + +const MAX_PROFILE_NAME_LEN = 128; + +export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: Props) => { + const { t } = useTranslation(); + const { mdm } = useRestrictions(); + const managedManagementUrl = mdm.managementURL; + const nameId = useId(); + const urlId = useId(); + const isEdit = !!initial; + const initialModeFromUrl = (u: string): ManagementMode => + u && u !== CLOUD_MANAGEMENT_URL ? ManagementMode.SelfHosted : ManagementMode.Cloud; + const initialSelfHostedUrl = (u: string): string => (u && u !== CLOUD_MANAGEMENT_URL ? u : ""); + + const [name, setName] = useState(initial?.name ?? ""); + const [nameError, setNameError] = useState(null); + const nameRef = useRef(null); + + const [mode, setMode] = useState( + initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud, + ); + const [url, setUrl] = useState(initial ? initialSelfHostedUrl(initial.managementUrl) : ""); + const [urlError, setUrlError] = useState(null); + const [unreachable, setUnreachable] = useState(false); + const [checking, setChecking] = useState(false); + const urlRef = useRef(null); + + useEffect(() => { + if (open) { + setName(initial?.name ?? ""); + setMode(initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud); + setUrl(initial ? initialSelfHostedUrl(initial.managementUrl) : ""); + setNameError(null); + setUrlError(null); + setUnreachable(false); + setChecking(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, initial?.name, initial?.managementUrl]); + + const initialModeRef = useRef(ManagementMode.Cloud); + useEffect(() => { + if (!open) return; + initialModeRef.current = mode; + const id = globalThis.setTimeout(() => { + nameRef.current?.focus(); + nameRef.current?.select(); + }, 0); + return () => globalThis.clearTimeout(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // When the user toggles to Self-hosted inside the dialog (not on initial + // open), move focus to the URL input so they can start typing immediately. + useEffect(() => { + if (!open) return; + if (mode === initialModeRef.current) return; + if (mode !== ManagementMode.SelfHosted) return; + urlRef.current?.focus(); + }, [open, mode]); + + useEffect(() => { + setUrlError(null); + setUnreachable(false); + }, [url, mode]); + + const resolveTargetUrl = (): { url: string; needsReachCheck: boolean } | null => { + if (managedManagementUrl) { + return { url: managedManagementUrl, needsReachCheck: false }; + } + if (mode === ManagementMode.Cloud) { + return { url: CLOUD_MANAGEMENT_URL, needsReachCheck: false }; + } + const trimmed = url.trim(); + if (!trimmed || !isValidManagementUrl(trimmed)) { + setUrlError(t("settings.general.management.urlError")); + urlRef.current?.focus(); + return null; + } + const target = normalizeManagementUrl(trimmed); + + const unchanged = target === initial?.managementUrl; + return { url: target, needsReachCheck: !unchanged }; + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (checking) return; + + const sanitized = name.trim(); + if (sanitized.length === 0) { + setNameError(t("profile.dialog.required")); + nameRef.current?.focus(); + return; + } + + const target = resolveTargetUrl(); + if (!target) return; + + if (target.needsReachCheck) { + setChecking(true); + const reachable = await checkManagementUrlReachable(target.url); + setChecking(false); + if (!reachable && !unreachable) { + setUnreachable(true); + return; + } + } + + await onSubmit(sanitized, target.url); + onOpenChange(false); + }; + + const handleNameChange = (value: string) => { + setName(value); + if (nameError) setNameError(null); + }; + + const trimmedUrl = url.trim(); + const showUrlSyntaxError = + mode === ManagementMode.SelfHosted && + trimmedUrl !== "" && + !isValidManagementUrl(trimmedUrl); + const urlInputError = showUrlSyntaxError + ? t("settings.general.management.urlError") + : (urlError ?? undefined); + const urlInputWarning = + !urlInputError && unreachable ? t("profile.dialog.urlUnreachable") : undefined; + + return ( + + { + e.preventDefault(); + // Focus + select-all so editing an existing name is one + // keystroke away from overwriting it. + nameRef.current?.focus(); + nameRef.current?.select(); + }} + > +
    +
    +
    +
    + + + {t("profile.dialog.description")} + +
    + handleNameChange(e.target.value)} + error={nameError ?? undefined} + maxLength={MAX_PROFILE_NAME_LEN} + spellCheck={false} + autoComplete={"off"} + autoCapitalize={"off"} + /> +
    + + {!managedManagementUrl && ( +
    +
    + + + {t("profile.dialog.managementHelp")} + +
    +
    + + {mode === ManagementMode.SelfHosted && ( + setUrl(e.target.value)} + error={urlInputError} + warning={urlInputWarning} + spellCheck={false} + autoComplete={"off"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> + )} +
    +
    + )} + + + + + +
    +
    +
    +
    + ); +}; diff --git a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx new file mode 100644 index 000000000..e76e300c1 --- /dev/null +++ b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx @@ -0,0 +1,293 @@ +import { forwardRef, useLayoutEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Popover from "@radix-ui/react-popover"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Command } from "cmdk"; +import { Check, ChevronDown, Settings2, UserCircle } from "lucide-react"; +import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar"; +import type { Profile } from "@bindings/services/models.js"; +import { Tooltip } from "@/components/Tooltip"; +import { useProfile } from "@/contexts/ProfileContext"; +import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { cn } from "@/lib/cn"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +type ProfileDropdownProps = { + onManageProfiles?: () => void; +}; + +const MANAGE_VALUE = "__manage_profiles__"; + +export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { + const { t } = useTranslation(); + const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile(); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const listRef = useRef(null); + + const handleTriggerKeyDown = (e: React.KeyboardEvent) => { + if (open) return; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + setOpen(true); + } + }; + + const sortedProfiles = [...profiles].sort((a, b) => { + if (a.id === activeProfileId) return -1; + if (b.id === activeProfileId) return 1; + return a.name.localeCompare(b.name); + }); + + const guarded = async (title: string, fn: () => Promise) => { + if (busy) return; + setBusy(true); + try { + await fn(); + } catch (e) { + await errorDialog({ + Title: title, + Message: formatErrorMessage(e), + }); + } finally { + setBusy(false); + } + }; + + const handleSelect = (id: string) => { + setOpen(false); + if (id === activeProfileId) return; + void guarded(t("profile.error.switchTitle"), () => switchProfile(id)); + }; + + const handleManage = () => { + setOpen(false); + onManageProfiles?.(); + }; + + if (!loaded) return ; + + const hasProfile = !!activeProfileId; + const activeFromList = profiles.find((p) => p.id === activeProfileId)?.name; + const displayName = hasProfile + ? (activeFromList ?? activeProfile) + : t("profile.selector.noProfile"); + + return ( + + + + + + { + e.preventDefault(); + listRef.current?.focus(); + }} + className={cn( + "wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", + "data-[side=bottom]:origin-top data-[side=top]:origin-bottom", + "data-[side=left]:origin-right data-[side=right]:origin-left", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + )} + > + e.stopPropagation()} + className={"outline-none focus:outline-none focus-visible:outline-none"} + > + + {sortedProfiles.length > 0 && ( + <> + + + {sortedProfiles.map((profile) => ( + + ))} + + + + + +
    + + )} + +
    + + + + {t("profile.dropdown.manageProfiles")} + + +
    + + + + + + ); +}; + +const ProfileTriggerSkeleton = () => ( +
    +
    +
    +
    +); + +type ProfileTriggerButtonProps = React.ButtonHTMLAttributes & { + name: string; +}; + +const ProfileTriggerButton = forwardRef( + function ProfileTriggerButton({ name, className, disabled, ...props }, ref) { + const { t } = useTranslation(); + const isFocusVisible = useFocusVisible(); + const Icon = pickProfileIcon(name) ?? UserCircle; + return ( + + ); + }, +); + +type ProfileRowProps = { + profile: Profile; + isActive: boolean; + onSelect: (id: string) => void; +}; + +const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => { + const showEmail = !!profile.email; + return ( + onSelect(profile.id)} + className={cn( + "flex w-auto gap-2 px-2 py-2 pr-3 last:mb-1", + "cursor-default rounded-md text-sm outline-none", + "data-[selected=true]:bg-nb-gray-900", + showEmail ? "items-start" : "items-center", + )} + > +
    + {profile.name} + {showEmail && } +
    + {isActive && ( + + )} +
    + ); +}; + +const TruncatedEmail = ({ email }: { email: string }) => { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [email]); + + const span = ( + + {email} + + ); + if (!overflowing) return span; + return {span}; +}; diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx new file mode 100644 index 000000000..97261ccc9 --- /dev/null +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -0,0 +1,678 @@ +import { type KeyboardEvent, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + CircleMinus, + LogIn, + MoreVertical, + PencilLine, + PlusCircle, + Trash2, + UserCircle, +} from "lucide-react"; +import type { Profile } from "@bindings/services/models.js"; +import { Badge } from "@/components/Badge"; +import { Button } from "@/components/buttons/Button"; +import HelpText from "@/components/typography/HelpText"; +import { + ProfileCreationModal, + type ProfileFormInitial, +} from "@/modules/profiles/ProfileCreationModal"; +import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar"; +import { Tooltip } from "@/components/Tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; +import i18next from "@/lib/i18n"; +import { useProfile } from "@/contexts/ProfileContext"; +import { useConfirm } from "@/contexts/DialogContext"; +import { Settings as SettingsSvc } from "@bindings/services"; +import { SetConfigParams } from "@bindings/services/models.js"; +import { isNetbirdCloud } from "@/hooks/useManagementUrl.ts"; +import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; +import { cn } from "@/lib/cn"; +import { reconcileOrder } from "@/lib/sorting"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; + +const DEFAULT_PROFILE_ID = "default"; + +export function ProfilesTab() { + const { t } = useTranslation(); + const { + profiles, + activeProfileId, + loaded, + username, + switchProfileNoConnect, + addProfile, + removeProfile, + renameProfile, + logoutProfile, + } = useProfile(); + + const confirm = useConfirm(); + const [newOpen, setNewOpen] = useState(false); + const [editTarget, setEditTarget] = useState<{ + profile: Profile; + initial: ProfileFormInitial; + } | null>(null); + const [busy, setBusy] = useState(false); + + // Order is held stable so switching only flips the badge, never reorders rows + // (else the clicked row jumps to the top under the cursor). + const orderRef = useRef([]); + const ordered = useMemo(() => { + const { order, items } = reconcileOrder( + orderRef.current, + profiles, + (p) => p.id, + (a, b) => { + if (a.id === activeProfileId) return -1; + if (b.id === activeProfileId) return 1; + return a.name.localeCompare(b.name); + }, + ); + orderRef.current = order; + return items; + }, [profiles, activeProfileId]); + + const guarded = async (title: string, fn: () => Promise) => { + if (busy) return; + setBusy(true); + try { + await fn(); + } catch (e) { + await errorDialog({ + Title: title, + Message: formatErrorMessage(e), + }); + } finally { + setBusy(false); + } + }; + + const handleSwitch = async (id: string, name: string) => { + const ok = await confirm({ + title: t("profile.switch.title", { name }), + description: t("profile.switch.message", { name }), + confirmLabel: t("profile.switch.confirm"), + }); + if (!ok) return; + await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id)); + }; + + const handleDeregister = async (id: string, name: string) => { + const ok = await confirm({ + title: t("profile.deregister.title", { name }), + description: t("profile.deregister.message", { name }), + confirmLabel: t("profile.deregister.confirm"), + }); + if (!ok) return; + void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(id)); + }; + + const handleDelete = async (id: string, name: string) => { + if (id === DEFAULT_PROFILE_ID) return; + const ok = await confirm({ + title: t("profile.delete.title", { name }), + description: t("profile.delete.message", { name }), + confirmLabel: t("common.delete"), + danger: true, + }); + if (!ok) return; + void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(id)); + }; + + const handleCreate = async (name: string, managementUrl: string) => { + await guarded(i18next.t("profile.error.createTitle"), async () => { + const id = await addProfile(name); + // SetConfig is keyed by the new profile's ID, so it writes the + // not-yet-active profile before the switch makes it current. + if (!isNetbirdCloud(managementUrl)) { + await SettingsSvc.SetConfig( + new SetConfigParams({ profileName: id, username, managementUrl }), + ); + } + await switchProfileNoConnect(id); + }); + }; + + const handleEdit = async (id: string, name: string) => { + await guarded(i18next.t("profile.error.editTitle"), async () => { + const config = await SettingsSvc.GetConfig({ profileName: id, username }); + const profile = profiles.find((p) => p.id === id); + if (!profile) return; + setEditTarget({ + profile, + initial: { name, managementUrl: config.managementUrl }, + }); + }); + }; + + const handleSave = async (name: string, managementUrl: string) => { + if (!editTarget) return; + const { profile, initial } = editTarget; + await guarded(i18next.t("profile.error.editTitle"), async () => { + if (name !== initial.name) { + await renameProfile(profile.id, name); + } + if (managementUrl !== initial.managementUrl) { + await SettingsSvc.SetConfig( + new SetConfigParams({ + profileName: profile.id, + username, + managementUrl, + }), + ); + } + }); + }; + + return ( +
    + + {t("settings.profiles.intro")} + +
    + + + {loaded && ordered.length === 0 && ( +
    + +

    + {t("settings.profiles.emptyTitle")} +

    +

    + {t("settings.profiles.emptyDescription")} +

    +
    + )} +
    + + + + +
    + + + + { + if (!o) setEditTarget(null); + }} + initial={editTarget?.initial} + onSubmit={handleSave} + /> +
    + ); +} + +type ProfilesTableProps = { + ordered: Profile[]; + activeProfileId: string | undefined; + onSwitch: (id: string, name: string) => void; + onEdit: (id: string, name: string) => void; + onDeregister: (id: string, name: string) => void; + onDelete: (id: string, name: string) => void; +}; + +const ProfilesTable = ({ + ordered, + activeProfileId, + onSwitch, + onEdit, + onDeregister, + onDelete, +}: ProfilesTableProps) => { + const { t } = useTranslation(); + const [focusedIndex, setFocusedIndex] = useState(0); + const rowRefs = useRef>(new Map()); + + const focusRow = (index: number) => { + if (index < 0 || index >= ordered.length) return; + setFocusedIndex(index); + const el = rowRefs.current.get(ordered[index].id); + el?.focus(); + }; + + const actionButtonsIn = (row: HTMLTableRowElement | undefined) => + Array.from( + row?.querySelectorAll( + "button:not([aria-hidden='true']):not([aria-disabled='true'])", + ) ?? [], + ); + + const handleRowKey = (e: KeyboardEvent, index: number): boolean => { + switch (e.key) { + case "ArrowDown": + focusRow(Math.min(index + 1, ordered.length - 1)); + return true; + case "ArrowUp": + focusRow(Math.max(index - 1, 0)); + return true; + case "Home": + focusRow(0); + return true; + case "End": + focusRow(ordered.length - 1); + return true; + } + return false; + }; + + const handleButtonKey = ( + e: KeyboardEvent, + index: number, + row: HTMLTableRowElement, + ): boolean => { + const buttons = actionButtonsIn(row); + const current = buttons.indexOf(e.target as HTMLButtonElement); + if (current === -1) return false; + + switch (e.key) { + case "ArrowDown": + focusRow(Math.min(index + 1, ordered.length - 1)); + return true; + case "ArrowUp": + focusRow(Math.max(index - 1, 0)); + return true; + case "Escape": + row.focus(); + return true; + case "Tab": + // At the last button: jump to the next row instead of exiting the table. + // At the first button with Shift+Tab: jump back to the row. + if (!e.shiftKey && current === buttons.length - 1 && index < ordered.length - 1) { + focusRow(index + 1); + return true; + } + if (e.shiftKey && current === 0) { + row.focus(); + return true; + } + return false; + } + return false; + }; + + const handleRowKeyDown = (e: KeyboardEvent, index: number) => { + const row = rowRefs.current.get(ordered[index].id); + if (!row) return; + const onRow = e.target === row; + const handled = onRow ? handleRowKey(e, index) : handleButtonKey(e, index, row); + if (handled) e.preventDefault(); + }; + + const safeFocusedIndex = Math.min(focusedIndex, Math.max(0, ordered.length - 1)); + + return ( + + + {ordered.map((profile, index) => ( + { + if (el) rowRefs.current.set(profile.id, el); + else rowRefs.current.delete(profile.id); + }} + onKeyDown={(e) => handleRowKeyDown(e, index)} + onFocus={() => setFocusedIndex(index)} + onSwitch={() => onSwitch(profile.id, profile.name)} + onEdit={() => onEdit(profile.id, profile.name)} + onDeregister={() => onDeregister(profile.id, profile.name)} + onDelete={() => onDelete(profile.id, profile.name)} + /> + ))} + +
    + ); +}; + +type ProfileRowProps = { + profile: Profile; + isActive: boolean; + isFocused: boolean; + isFirst: boolean; + isLast: boolean; + rowRef: (el: HTMLTableRowElement | null) => void; + onKeyDown: (e: KeyboardEvent) => void; + onFocus: () => void; + onSwitch: () => void; + onEdit: () => void; + onDeregister: () => void; + onDelete: () => void; +}; + +const ProfileRow = ({ + profile, + isActive, + isFocused, + isFirst, + isLast, + rowRef, + onKeyDown, + onFocus, + onSwitch, + onEdit, + onDeregister, + onDelete, +}: ProfileRowProps) => { + const { t } = useTranslation(); + const Icon = pickProfileIcon(profile.name) ?? UserCircle; + const showEmail = !!profile.email; + + return ( + + + +
    +
    + + {profile.name} + + {isActive && {t("settings.profiles.active")}} +
    + {showEmail && } +
    + + + + + + ); +}; + +const TruncatedEmail = ({ email }: { email: string }) => { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [email]); + + const span = ( + + {email} + + ); + if (!overflowing) return span; + return {span}; +}; + +type RowActionsProps = { + canSwitch: boolean; + canDeregister: boolean; + isDefault: boolean; + isActive: boolean; + rowFocused: boolean; + onSwitch: () => void; + onEdit: () => void; + onDeregister: () => void; + onDelete: () => void; +}; + +const RowActions = ({ + canSwitch, + canDeregister, + isDefault, + isActive, + rowFocused, + onSwitch, + onEdit, + onDeregister, + onDelete, +}: RowActionsProps) => { + const { t } = useTranslation(); + const deleteDisabled = isDefault || isActive; + let deleteDisabledReason: string | null = null; + if (isDefault) deleteDisabledReason = t("profile.delete.disabledDefault"); + else if (isActive) deleteDisabledReason = t("profile.delete.disabledActive"); + return ( +
    +
    + ); +}; + +type RowMoreMenuProps = { + canDeregister: boolean; + deleteDisabled: boolean; + deleteDisabledReason: string | null; + rowFocused: boolean; + onEdit: () => void; + onDeregister: () => void; + onDelete: () => void; +}; + +const RowMoreMenu = ({ + canDeregister, + deleteDisabled, + deleteDisabledReason, + rowFocused, + onEdit, + onDeregister, + onDelete, +}: RowMoreMenuProps) => { + const { t } = useTranslation(); + const moreLabel = t("profile.selector.moreOptions"); + return ( + + + + + + +
    + + {t("profile.selector.edit")} +
    +
    + {canDeregister && ( + +
    + + {t("profile.selector.deregister")} +
    +
    + )} + +
    +
    + ); +}; + +type DeleteMenuItemProps = { + disabled: boolean; + disabledReason: string | null; + onDelete: () => void; +}; + +const DeleteMenuItem = ({ disabled, disabledReason, onDelete }: DeleteMenuItemProps) => { + const { t } = useTranslation(); + const item = ( + +
    + + {t("profile.selector.delete")} +
    +
    + ); + if (!disabled || !disabledReason) return item; + return ( + {disabledReason}} + side={"left"} + > + {item} + + ); +}; + +type ActionIconButtonProps = { + label: string; + icon: typeof CircleMinus; + onClick: () => void; + variant?: "default" | "danger"; + /** Occupies space but invisible and non-interactive (preserves row layout). */ + hidden?: boolean; + disabled?: boolean; + tabbable?: boolean; +}; + +const ActionIconButton = ({ + label, + icon: Icon, + onClick, + variant = "default", + hidden = false, + disabled = false, + tabbable = true, +}: ActionIconButtonProps) => { + const button = ( + + ); + if (hidden) return button; + return ( + {label}} + side={"top"} + > + {button} + + ); +}; diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx new file mode 100644 index 000000000..ef8d6862f --- /dev/null +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -0,0 +1,220 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { Events } from "@wailsio/runtime"; +import { AlertCircleIcon, ClockIcon } from "lucide-react"; +import { Button } from "@/components/buttons/Button"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { SquareIcon } from "@/components/SquareIcon"; +import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; +import { EVENT_BROWSER_LOGIN_CANCEL, EVENT_TRIGGER_LOGIN } from "@/lib/connection"; +import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; +import { formatRemaining } from "@/lib/formatters"; + +const DEFAULT_SECONDS = 360; +const WINDOW_WIDTH = 360; +const SOON_THRESHOLD_SECONDS = 60 * 60; + +export default function SessionExpirationDialog() { + const { t } = useTranslation(); + const contentRef = useAutoSizeWindow(WINDOW_WIDTH); + const [params] = useSearchParams(); + const initialSeconds = useMemo(() => { + const raw = params.get("seconds"); + if (!raw) return DEFAULT_SECONDS; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : DEFAULT_SECONDS; + }, [params]); + + const [remaining, setRemaining] = useState(initialSeconds); + const [busy, setBusy] = useState(false); + const busyRef = useRef(busy); + busyRef.current = busy; + const expired = remaining <= 0; + const expiredRef = useRef(expired); + expiredRef.current = expired; + const soon = remaining <= SOON_THRESHOLD_SECONDS; + const activeTitle = soon ? t("sessionExpiration.title") : t("sessionExpiration.titleLater"); + const activeDescription = soon + ? t("sessionExpiration.description") + : t("sessionExpiration.descriptionLater"); + + useEffect(() => { + setRemaining(initialSeconds); + }, [initialSeconds]); + + useEffect(() => { + const id = globalThis.setInterval(() => { + setRemaining((s) => (s <= 1 ? 0 : s - 1)); + }, 1000); + return () => globalThis.clearInterval(id); + }, [initialSeconds]); + + // Don't auto-close while busy (aborts our WaitExtend) or expired (hides the state). + useEffect(() => { + const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => { + if (busyRef.current || expiredRef.current) return; + if (ev?.data?.status === "Connected") { + WindowManager.CloseSessionExpiration().catch(console.error); + } + }); + return () => { + off(); + }; + }, []); + + const stay = useCallback(async () => { + if (busy) return; + setBusy(true); + + let offCancel: (() => void) | undefined; + + // Return the dialog to its interactive state and dismiss the browser popup + const resetDialog = () => { + offCancel?.(); + WindowManager.CloseBrowserLogin().catch(console.error); + setBusy(false); + }; + + try { + const start = await Session.RequestExtend({ hint: "" }); + const uri = start.verificationUriComplete || start.verificationUri; + + // The popup opens the URL and (Go-side) hides this window, restoring it on close. + if (uri) { + try { + await WindowManager.OpenBrowserLogin(uri); + } catch (e) { + console.error(e); + } + } + + const cancelPromise = new Promise((resolve) => { + offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => { + resolve(); + }); + }); + + const waitPromise = Session.WaitExtend({ + deviceCode: start.deviceCode, + userCode: start.userCode, + }); + + const outcome = await Promise.race([ + waitPromise.then((r) => ({ kind: "done" as const, result: r })), + cancelPromise.then(() => ({ kind: "cancel" as const })), + ]); + + if (outcome.kind === "cancel") { + waitPromise.cancel?.(); + waitPromise.catch(() => {}); + resetDialog(); + return; + } + + // Another surface owns this flow; keep the dialog open to retry. + if (outcome.result.preempted) { + resetDialog(); + return; + } + WindowManager.CloseRenewFlow().catch(console.error); + } catch (e) { + resetDialog(); + await errorDialog({ + Title: t("sessionExpiration.extendFailedTitle"), + Message: formatErrorMessage(e), + }); + } + }, [busy, t]); + + const authenticate = useCallback(async () => { + if (busy) return; + setBusy(true); + try { + await Events.Emit(EVENT_TRIGGER_LOGIN); + await WindowManager.CloseSessionExpiration(); + } catch (e) { + setBusy(false); + await errorDialog({ + Title: t("connect.error.loginTitle"), + Message: formatErrorMessage(e), + }); + } + }, [busy, t]); + + const logout = useCallback(async () => { + if (busy) return; + setBusy(true); + try { + const username = await ProfilesSvc.Username(); + const active = await ProfilesSvc.GetActive(); + await Connection.Logout({ + profileName: active.id || "default", + username, + }); + WindowManager.CloseSessionExpiration().catch(console.error); + } catch (e) { + setBusy(false); + await errorDialog({ + Title: t("sessionExpiration.logoutFailedTitle"), + Message: formatErrorMessage(e), + }); + } + }, [busy, t]); + + const close = useCallback(() => { + WindowManager.CloseSessionExpiration().catch(console.error); + }, []); + + return ( + + + +
    + + {expired ? t("sessionExpiration.expired") : activeTitle} + + + {expired ? t("sessionExpiration.expiredDescription") : activeDescription} + +
    + + {!expired && ( +
    + {formatRemaining(remaining)} +
    + )} + + + + + +
    + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx new file mode 100644 index 000000000..8ba1009ba --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx @@ -0,0 +1,169 @@ +import type { ComponentType, SVGProps } from "react"; +import { useTranslation } from "react-i18next"; +import { Browser } from "@wailsio/runtime"; +import { BookOpen, MessageSquareText, MessagesSquare } from "lucide-react"; +import netbirdFull from "@/assets/logos/netbird-full.svg"; + +// Brand glyphs from simpleicons.org (lucide deprecated its brand icons). +const GithubIcon = (props: SVGProps) => ( + + + +); +const SlackIcon = (props: SVGProps) => ( + + + +); +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useStatus } from "@/contexts/StatusContext.tsx"; +import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard"; +import { useAccentTrigger } from "@/modules/settings/SettingsAccent"; + +function openUrl(url: string) { + Browser.OpenURL(url).catch(() => { + window.open(url, "_blank"); + }); +} + +export function SettingsAbout() { + const { t } = useTranslation(); + const { status } = useStatus(); + const { guiVersion } = useSettings(); + const daemonVersion = status?.daemonVersion ?? "—"; + + const handleVersionClick = useAccentTrigger(); + + const COMMUNITY_LINKS: { + label: string; + url: string; + Icon: ComponentType>; + iconClassName?: string; + }[] = [ + { + label: t("settings.about.community.github"), + url: "https://github.com/netbirdio/netbird", + Icon: GithubIcon, + iconClassName: "h-3 w-3", + }, + { + label: t("settings.about.community.slack"), + url: "https://docs.netbird.io/slack-url", + Icon: SlackIcon, + iconClassName: "h-3 w-3", + }, + { + label: t("settings.about.community.forum"), + url: "https://forum.netbird.io", + Icon: MessagesSquare, + }, + { + label: t("settings.about.community.documentation"), + url: "https://docs.netbird.io", + Icon: BookOpen, + }, + { + label: t("settings.about.community.feedback"), + url: "https://forms.gle/TeLw2zrXEdw6RcQ36", + Icon: MessageSquareText, + }, + ]; + + const LEGAL_LINKS: { label: string; url: string }[] = [ + { label: t("settings.about.links.imprint"), url: "https://netbird.io/imprint" }, + { label: t("settings.about.links.privacy"), url: "https://netbird.io/privacy" }, + { label: t("settings.about.links.cla"), url: "https://netbird.io/cla" }, + { label: t("settings.about.links.terms"), url: "https://netbird.io/terms" }, + ]; + + return ( +
    + {t("common.netbird")} +
    + +

    + {guiVersion === "development" ? ( + + {t("settings.about.guiName")}{" "} + + {t("settings.about.development")} + + + ) : ( + t("settings.about.gui", { version: guiVersion }) + )} +

    +
    + + + +

    + {t("settings.about.copyright", { year: new Date().getFullYear() })} +

    +
    + {COMMUNITY_LINKS.map(({ label, url, Icon, iconClassName }) => ( + + ))} +
    +
    + {LEGAL_LINKS.map((link) => ( + + ))} +
    +
    + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsAccent.tsx b/client/ui/frontend/src/modules/settings/SettingsAccent.tsx new file mode 100644 index 000000000..b7d9c96c8 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsAccent.tsx @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; + +export function useAccentTrigger() { + const clicksRef = useRef(0); + const lastClickRef = useRef(0); + + return useCallback(() => { + const now = performance.now(); + if (now - lastClickRef.current > 400) { + clicksRef.current = 0; + } + lastClickRef.current = now; + clicksRef.current += 1; + if (clicksRef.current >= 10) { + clicksRef.current = 0; + triggerAccent(); + } + }, []); +} + +function triggerAccent() { + if (document.getElementById("nb-accent-root")) return; + + const container = document.createElement("div"); + container.id = "nb-accent-root"; + document.body.appendChild(container); + const root = createRoot(container); + + const cleanup = () => { + root.unmount(); + container.remove(); + }; + + root.render(); +} + +function Accent({ onDone }: Readonly<{ onDone: () => void }>) { + const canvasRef = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const raf = requestAnimationFrame(() => setVisible(true)); + return () => cancelAnimationFrame(raf); + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const resize = () => { + canvas.width = window.innerWidth * dpr; + canvas.height = window.innerHeight * dpr; + canvas.style.width = `${window.innerWidth}px`; + canvas.style.height = `${window.innerHeight}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + resize(); + window.addEventListener("resize", resize); + + const chars = "TEAMNETBIRD"; + const fontSize = 16; + const columns = Math.floor(window.innerWidth / fontSize); + const drops = Array.from({ length: columns }, () => Math.random() * -50); + + let raf = 0; + let last = 0; + const draw = (t: number) => { + if (t - last > 50) { + last = t; + + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "rgba(0, 0, 0, 0.12)"; + ctx.fillRect(0, 0, window.innerWidth, window.innerHeight); + + ctx.globalCompositeOperation = "source-over"; + ctx.font = `${fontSize}px ui-monospace, monospace`; + ctx.fillStyle = "#f68330"; + + for (let i = 0; i < drops.length; i++) { + const ch = chars[Math.floor(Math.random() * chars.length)]; + const y = drops[i] * fontSize; + ctx.fillText(ch, i * fontSize, y); + if (y > window.innerHeight && Math.random() > 0.975) { + drops[i] = 0; + } + drops[i]++; + } + } + raf = requestAnimationFrame(draw); + }; + raf = requestAnimationFrame(draw); + + const timeout = globalThis.setTimeout(() => { + setVisible(false); + globalThis.setTimeout(onDone, 500); + }, 9000); + + return () => { + cancelAnimationFrame(raf); + globalThis.clearTimeout(timeout); + window.removeEventListener("resize", resize); + }; + }, [onDone]); + + return ( +
    + +
    + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx new file mode 100644 index 000000000..37b1932d9 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { System } from "@wailsio/runtime"; +import Button from "@/components/buttons/Button"; +import { HelpText } from "@/components/typography/HelpText"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; +import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +// macOS daemon/CLI only accept utun (Darwin parses digits as the utun unit); Linux caps at IFNAMSIZ-1 = 15 chars. +const IS_MAC = System.IsMac(); +const INTERFACE_NAME_RE = IS_MAC ? /^utun\d+$/ : /^[A-Za-z0-9._-]{1,15}$/; +const INTERFACE_NAME_ERROR_KEY = IS_MAC + ? "settings.advanced.interfaceName.errorMac" + : "settings.advanced.interfaceName.error"; + +// Port 0 lets the daemon pick a random free port. +const PORT_MIN = 0; +const PORT_MAX = 65535; + +// Mirrors client/iface/iface.go MinMTU / MaxMTU. +const MTU_MIN = 576; +const MTU_MAX = 8192; + +const PSK_MASK = "**********"; + +export function SettingsAdvanced() { + const { t } = useTranslation(); + const { config, saveFields } = useSettings(); + const { mdm } = useRestrictions(); + + const initialPsk = config.preSharedKeySet ? PSK_MASK : ""; + + const [values, setValues] = useState({ + interfaceName: config.interfaceName, + wireguardPort: config.wireguardPort, + mtu: config.mtu, + }); + + const [pskInputValue, setPskInputValue] = useState(initialPsk); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setValues({ + interfaceName: config.interfaceName, + wireguardPort: config.wireguardPort, + mtu: config.mtu, + }); + setPskInputValue(config.preSharedKeySet ? PSK_MASK : ""); + }, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKeySet]); + + const errors = useMemo(() => { + const out: { interfaceName?: string; wireguardPort?: string; mtu?: string } = {}; + if (!INTERFACE_NAME_RE.test(values.interfaceName)) { + out.interfaceName = t(INTERFACE_NAME_ERROR_KEY); + } + if ( + !Number.isInteger(values.wireguardPort) || + values.wireguardPort < PORT_MIN || + values.wireguardPort > PORT_MAX + ) { + out.wireguardPort = t("settings.advanced.port.error", { + min: PORT_MIN, + max: PORT_MAX, + }); + } + if (!Number.isInteger(values.mtu) || values.mtu < MTU_MIN || values.mtu > MTU_MAX) { + out.mtu = t("settings.advanced.mtu.error", { min: MTU_MIN, max: MTU_MAX }); + } + return out; + }, [values.interfaceName, values.wireguardPort, values.mtu, t]); + + const filteredErrors = mdm.wireguardPort ? { ...errors, wireguardPort: undefined } : errors; + const hasErrors = Object.values(filteredErrors).some((v) => v !== undefined); + const pskChanged = pskInputValue !== initialPsk; + const hasChanges = + values.interfaceName !== config.interfaceName || + (!mdm.wireguardPort && values.wireguardPort !== config.wireguardPort) || + values.mtu !== config.mtu || + (!mdm.preSharedKey && pskChanged); + + const handleSave = async () => { + if (!hasChanges || saving || hasErrors) return; + setSaving(true); + try { + const partial: typeof values = { ...values }; + if (mdm.wireguardPort) partial.wireguardPort = config.wireguardPort; + + const pskEdited = !mdm.preSharedKey && pskChanged && pskInputValue !== PSK_MASK; + const pskOpts = pskEdited ? { preSharedKey: pskInputValue } : undefined; + await saveFields(partial, pskOpts); + if (pskEdited) setPskInputValue(pskInputValue === "" ? "" : PSK_MASK); + } finally { + setSaving(false); + } + }; + + return ( + <> + + setValues((v) => ({ ...v, interfaceName: e.target.value }))} + spellCheck={false} + autoComplete={"off"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> +
    + {!mdm.wireguardPort && ( +
    + + setValues((v) => ({ + ...v, + wireguardPort: Number(e.target.value), + })) + } + /> + + {t("settings.advanced.port.help")} + +
    + )} + setValues((v) => ({ ...v, mtu: Number(e.target.value) }))} + /> +
    +
    + + {!mdm.preSharedKey && ( + +
    + + {t("settings.advanced.psk.help")} + setPskInputValue(e.target.value)} + spellCheck={false} + autoComplete={"new-password"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> +
    +
    + )} + + + + + + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx new file mode 100644 index 000000000..71720aebe --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx @@ -0,0 +1,124 @@ +import { useEffect, useId, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/buttons/Button"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { HelpText } from "@/components/typography/HelpText"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; +import { useAutostartSetting, useSettings } from "@/contexts/SettingsContext.tsx"; +import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx"; +import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts"; +import { LanguagePicker } from "@/components/LanguagePicker.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; +import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts"; + +export function SettingsGeneral() { + const { t } = useTranslation(); + const { config, setField } = useSettings(); + const { autostart, setAutostartEnabled } = useAutostartSetting(); + const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } = + useManagementUrl(); + const { mdm, features } = useRestrictions(); + const { keepConnected, setKeepConnectedOnQuit } = useKeepConnectedOnQuit(); + + const inputRef = useRef(null); + const managementUrlId = useId(); + const prevMode = useRef(mode); + useEffect(() => { + if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) { + inputRef.current?.focus(); + } + prevMode.current = mode; + }, [mode]); + + return ( + <> + + + setField("disableNotifications", !v)} + label={t("settings.general.notifications.label")} + helpText={t("settings.general.notifications.help")} + /> + {!mdm.disableAutoConnect && !features.disableUpdateSettings && ( + setField("disableAutoConnect", !v)} + label={t("settings.general.connectOnStartup.label")} + helpText={t("settings.general.connectOnStartup.help")} + /> + )} + {(autostart === null || autostart.supported) && ( + + )} + { + void setKeepConnectedOnQuit(v); + }} + loading={keepConnected === null} + label={t("settings.general.keepConnectedOnQuit.label")} + helpText={t("settings.general.keepConnectedOnQuit.help")} + /> + + + {!mdm.managementURL && !features.disableUpdateSettings && ( + +
    +
    +
    + + {t("settings.general.management.help")} +
    + +
    + {mode === ManagementMode.SelfHosted && ( +
    + setUrl(e.target.value)} + placeholder={t("settings.general.management.urlPlaceholder")} + error={ + showError + ? t("settings.general.management.urlError") + : undefined + } + warning={ + unreachable + ? t("settings.general.management.urlUnreachable") + : undefined + } + spellCheck={false} + autoComplete={"off"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> + +
    + )} +
    +
    + )} + + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx new file mode 100644 index 000000000..816dcb71f --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx @@ -0,0 +1,87 @@ +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@/components/Tooltip.tsx"; +import { VerticalTabs } from "@/components/VerticalTabs.tsx"; +import { UpdateBadge } from "@/modules/auto-update/UpdateBadge.tsx"; +import { useClientVersion } from "@/contexts/ClientVersionContext.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; +import { + BoltIcon, + InfoIcon, + LifeBuoyIcon, + NetworkIcon, + ShieldIcon, + SlidersHorizontalIcon, + SquareTerminalIcon, + UserCircleIcon, +} from "lucide-react"; + +export const SettingsNavigation = () => { + const { t } = useTranslation(); + const { updateAvailable } = useClientVersion(); + const { mdm, features } = useRestrictions(); + const showSsh = mdm.allowServerSSH ?? !features.disableUpdateSettings; + + const aboutAdornment = updateAvailable ? ( + + + + ) : undefined; + + return ( +
    + + + {!features.disableUpdateSettings && ( + <> + + + + )} + {!features.disableProfiles && ( + + )} + {showSsh && ( + + )} + {!features.disableUpdateSettings && ( + + )} + + + +
    + ); +}; diff --git a/client/ui/frontend/src/modules/settings/SettingsNetwork.tsx b/client/ui/frontend/src/modules/settings/SettingsNetwork.tsx new file mode 100644 index 000000000..7c903b634 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsNetwork.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from "react-i18next"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +export function SettingsNetwork() { + const { t } = useTranslation(); + const { config, setField } = useSettings(); + const { mdm } = useRestrictions(); + + return ( + <> + + setField("networkMonitor", v)} + label={t("settings.network.monitor.label")} + helpText={t("settings.network.monitor.help")} + /> + + + + setField("disableDns", !v)} + label={t("settings.network.dns.label")} + helpText={t("settings.network.dns.help")} + /> + {!mdm.disableClientRoutes && ( + setField("disableClientRoutes", !v)} + label={t("settings.network.clientRoutes.label")} + helpText={t("settings.network.clientRoutes.help")} + /> + )} + {!mdm.disableServerRoutes && ( + setField("disableServerRoutes", !v)} + label={t("settings.network.serverRoutes.label")} + helpText={t("settings.network.serverRoutes.help")} + /> + )} + setField("disableIpv6", !v)} + label={t("settings.network.ipv6.label")} + helpText={t("settings.network.ipv6.help")} + /> + + + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsPage.tsx b/client/ui/frontend/src/modules/settings/SettingsPage.tsx new file mode 100644 index 000000000..bf0db3bec --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsPage.tsx @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useLocation } from "react-router-dom"; +import { Events } from "@wailsio/runtime"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { cn } from "@/lib/cn"; +import { isMacOS } from "@/lib/platform"; +import { AppRightPanel } from "@/layouts/AppRightPanel.tsx"; +import { VerticalTabs } from "@/components/VerticalTabs.tsx"; +import { SettingsNavigation } from "@/modules/settings/SettingsNavigation.tsx"; +import { AutostartSettingsProvider, SettingsProvider } from "@/contexts/SettingsContext.tsx"; +import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx"; +import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx"; +import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx"; +import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx"; +import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx"; +import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx"; +import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx"; +import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +const EVENT_SETTINGS_OPEN = "netbird:settings:open"; + +const enum Tab { + General = "general", + Network = "network", + Security = "security", + Profiles = "profiles", + SSH = "ssh", + Advanced = "advanced", + Troubleshooting = "troubleshooting", + About = "about", +} + +const TAB_CONTENT: Record = { + [Tab.General]: , + [Tab.Network]: , + [Tab.Security]: , + [Tab.Profiles]: , + [Tab.SSH]: , + [Tab.Advanced]: , + [Tab.Troubleshooting]: , + [Tab.About]: , +}; + +export const SettingsPage = () => { + const location = useLocation(); + const navState = location.state as { tab?: string } | null; + const { mdm, features } = useRestrictions(); + + const visibleTabs = useMemo(() => { + const editable = !features.disableUpdateSettings; + const visibility: Record = { + [Tab.General]: true, + [Tab.Network]: editable, + [Tab.Security]: editable, + [Tab.Profiles]: !features.disableProfiles, + [Tab.SSH]: mdm.allowServerSSH ?? editable, + [Tab.Advanced]: editable, + [Tab.Troubleshooting]: true, + [Tab.About]: true, + }; + return (Object.keys(visibility) as Tab[]).filter((t) => visibility[t]); + }, [features.disableUpdateSettings, features.disableProfiles, mdm.allowServerSSH]); + + const defaultTab = visibleTabs[0]; + const [active, setActive] = useState(() => navState?.tab ?? defaultTab); + + useEffect(() => { + if (navState?.tab) setActive(navState.tab); + }, [navState?.tab, location.key]); + + useEffect(() => { + return Events.On(EVENT_SETTINGS_OPEN, (e: { data: string }) => { + setActive(e.data || defaultTab); + }); + }, [defaultTab]); + + // Reset active tab if it got disabled by any feature flag or mdm restrictions + useEffect(() => { + if (!visibleTabs.includes(active as Tab)) setActive(defaultTab); + }, [visibleTabs, active, defaultTab]); + + return ( + <> + {isMacOS() ? ( +
    + ) : ( +
    + )} +
    + + + + + + + +
    + {visibleTabs.map((tab) => ( + + {TAB_CONTENT[tab]} + + ))} +
    +
    + + + +
    +
    +
    +
    +
    +
    + + ); +}; diff --git a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx new file mode 100644 index 000000000..bd91e520c --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx @@ -0,0 +1,203 @@ +import { useTranslation } from "react-i18next"; +import { CopyToClipboard } from "@/components/CopyToClipboard"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { HelpText } from "@/components/typography/HelpText"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; +import { cn } from "@/lib/cn"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { usePrivilege } from "@/hooks/usePrivilege.ts"; +import { Privilege } from "@bindings/services/models.js"; +import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react"; + +export function SettingsSSH() { + const { t } = useTranslation(); + const { config, setField } = useSettings(); + const privilege = usePrivilege(); + const isSSHServerEnabled = config.serverSshAllowed; + + // The daemon restricts only the direction that hands out shells from a process + // running as root. So for an unprivileged user a guarded control is either + // unavailable (it is off and only they could turn it on) or a one-way switch + // (it is on, they may turn it off, but not back on) — say which, either way. + // + // A null privilege means we could not determine it: leave the control alone + // rather than greying it out with nothing to explain why. The daemon enforces + // this regardless, and a rejected save reports its own guidance. + const guarded = ( + guardedDirectionActive: boolean, + command: (p: Privilege) => string, + // inverted marks a control whose guarded direction is switching it off, so + // the one-way warning has to read the other way round. + inverted = false, + ) => { + if (!privilege || privilege.privileged) { + return { disabled: false, hint: undefined }; + } + const hint = ( + + ); + return { disabled: !guardedDirectionActive, hint }; + }; + + const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer); + const sshRoot = guarded(config.enableSshRoot, (p) => p.enableSshRoot); + // Inverted control: the guarded direction is switching authentication off, so + // it is the already-disabled state that is the one-way one. + const sshAuth = guarded(config.disableSshAuth, (p) => p.disableSshAuth, true); + const jwtTtlId = useId(); + const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl)); + + useEffect(() => { + setJwtTtlInput(String(config.sshJwtCacheTtl)); + }, [config.sshJwtCacheTtl]); + + const handleJwtTtlChange = (e: ChangeEvent) => { + const v = e.target.value; + setJwtTtlInput(v); + if (v === "") return; + const n = Number(v); + if (Number.isFinite(n) && n >= 0) { + setField("sshJwtCacheTtl", n); + } + }; + + const handleJwtTtlBlur = () => { + if (jwtTtlInput === "") { + setJwtTtlInput("0"); + setField("sshJwtCacheTtl", 0); + return; + } + const n = Number(jwtTtlInput); + if (!Number.isFinite(n) || n < 0) { + setJwtTtlInput(String(config.sshJwtCacheTtl)); + } + }; + return ( + <> + + setField("serverSshAllowed", v)} + disabled={sshServer.disabled} + label={t("settings.ssh.server.label")} + helpText={t("settings.ssh.server.help")} + /> + {sshServer.hint} + + + + setField("enableSshRoot", v)} + disabled={sshRoot.disabled} + label={t("settings.ssh.root.label")} + helpText={t("settings.ssh.root.help")} + /> + {sshRoot.hint} + setField("enableSshSftp", v)} + label={t("settings.ssh.sftp.label")} + helpText={t("settings.ssh.sftp.help")} + /> + setField("enableSshLocalPortForwarding", v)} + label={t("settings.ssh.localForward.label")} + helpText={t("settings.ssh.localForward.help")} + /> + setField("enableSshRemotePortForwarding", v)} + label={t("settings.ssh.remoteForward.label")} + helpText={t("settings.ssh.remoteForward.help")} + /> + + + + setField("disableSshAuth", !v)} + disabled={sshAuth.disabled} + label={t("settings.ssh.jwt.label")} + helpText={t("settings.ssh.jwt.help")} + /> + {sshAuth.hint} +
    +
    + + {t("settings.ssh.jwtTtl.help")} +
    +
    + +
    +
    +
    + + ); +} + +// PrivilegeHint explains what an unprivileged user can and cannot do with a +// guarded control, and offers the command that does it with the privileges the +// daemon requires. oneWay covers the control being in the guarded state already: +// switching it back is the part that needs privileges. +function PrivilegeHint({ + actor, + command, + oneWay, + inverted, +}: { + actor: string; + command: string; + oneWay: boolean; + inverted: boolean; +}): ReactNode { + const { t } = useTranslation(); + if (!command) return null; + return ( +
    + + {!oneWay + ? t("settings.ssh.privilege.hint", { actor }) + : inverted + ? t("settings.ssh.privilege.oneWayInverted", { actor }) + : t("settings.ssh.privilege.oneWay", { actor })} + + + + {command} + + +
    + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsSection.tsx b/client/ui/frontend/src/modules/settings/SettingsSection.tsx new file mode 100644 index 000000000..adba65cdc --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsSection.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/cn"; + +export const SectionGroup = ({ + title, + children, + disabled = false, +}: { + title: string; + children: ReactNode; + disabled?: boolean; +}) => ( +
    +

    + {title} +

    +
    {children}
    +
    +); + +export const SettingsBottomBar = ({ children }: { children: ReactNode }) => ( + <> +
    +
    +
    + {children} +
    +
    + +); diff --git a/client/ui/frontend/src/modules/settings/SettingsSecurity.tsx b/client/ui/frontend/src/modules/settings/SettingsSecurity.tsx new file mode 100644 index 000000000..db1ad9c8f --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsSecurity.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; + +export function SettingsSecurity() { + const { t } = useTranslation(); + const { config, setField } = useSettings(); + const { mdm } = useRestrictions(); + const hideRosenpassEnabled = mdm.rosenpassEnabled; + const hideRosenpassPermissive = + mdm.rosenpassPermissive || (mdm.rosenpassEnabled && !config.rosenpassEnabled); + const showEncryptionSection = !(hideRosenpassEnabled && hideRosenpassPermissive); + + return ( + <> + + {!mdm.blockInbound && ( + setField("blockInbound", v)} + label={t("settings.security.blockInbound.label")} + helpText={t("settings.security.blockInbound.help")} + /> + )} + setField("blockLanAccess", v)} + label={t("settings.security.blockLan.label")} + helpText={t("settings.security.blockLan.help")} + /> + + + {showEncryptionSection && ( + + {!hideRosenpassEnabled && ( + { + setField("rosenpassEnabled", v); + if (!v) setField("rosenpassPermissive", false); + }} + label={t("settings.security.rosenpass.label")} + helpText={t("settings.security.rosenpass.help")} + /> + )} + {!hideRosenpassPermissive && ( + setField("rosenpassPermissive", v)} + label={t("settings.security.rosenpassPermissive.label")} + helpText={t("settings.security.rosenpassPermissive.help")} + disabled={!config.rosenpassEnabled} + /> + )} + + )} + + ); +} diff --git a/client/ui/frontend/src/modules/settings/SettingsSkeleton.tsx b/client/ui/frontend/src/modules/settings/SettingsSkeleton.tsx new file mode 100644 index 000000000..afce192e6 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsSkeleton.tsx @@ -0,0 +1,27 @@ +import Skeleton from "react-loading-skeleton"; + +export const SettingsSkeleton = () => { + return ( +
    +
    + +
    + + +
    +
    + + +
    +
    +
    + +
    + + + +
    +
    +
    + ); +}; diff --git a/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx new file mode 100644 index 000000000..8b1774ed6 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx @@ -0,0 +1,405 @@ +import { useId, type ReactNode } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { ChevronDown, CircleCheckBig, FolderOpen, Info, Loader2 } from "lucide-react"; +import { Browser } from "@wailsio/runtime"; +import { Debug as DebugSvc } from "@bindings/services"; +import type { DebugBundleResult } from "@bindings/services/models.js"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; +import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; +import HelpText from "@/components/typography/HelpText.tsx"; +import { Input } from "@/components/inputs/Input"; +import { Label } from "@/components/typography/Label"; +import { SquareIcon } from "@/components/SquareIcon"; +import { Tooltip } from "@/components/Tooltip"; +import { cn } from "@/lib/cn"; +import { formatRemaining } from "@/lib/formatters"; +import type { AnonymizeLevel, DebugStage } from "@/contexts/DebugBundleContext"; +import { useDebugBundleContext } from "@/contexts/DebugBundleContext"; +import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; + +const SUPPORT_DOCS_URL = "https://docs.netbird.io/help/report-bug-issues"; + +export function SettingsTroubleshooting() { + const { t } = useTranslation(); + const durationId = useId(); + const { + anonymizeLevel, + setAnonymizeLevel, + systemInfo, + setSystemInfo, + upload, + setUpload, + trace, + setTrace, + capture, + setCapture, + traceMinutes, + setTraceMinutes, + capturePackets, + setCapturePackets, + run, + stage, + cancel, + reset, + } = useDebugBundleContext(); + + if (stage.kind === "done") { + return ( + + ); + } + if (stage.kind !== "idle") { + return ; + } + + return ( + +
    +
    +
    + } + > + + + + + + {t("settings.troubleshooting.anonymize.help")} + +
    +
    + + + + + + setAnonymizeLevel(v as AnonymizeLevel)} + > + + {t("settings.troubleshooting.anonymize.none")} + + + {t("settings.troubleshooting.anonymize.default")} + + + {t("settings.troubleshooting.anonymize.strict")} + + + + +
    +
    + + + + +
    + +
    +
    + + + {t("settings.troubleshooting.duration.help")} + +
    +
    + + setTraceMinutes( + Math.max(1, Math.min(30, Number(e.target.value) || 1)), + ) + } + customSuffix={t("settings.troubleshooting.duration.suffix")} + disabled={!capture} + /> +
    +
    +
    + + + + + + ); +} + +function CenteredPanel({ children }: Readonly<{ children: ReactNode }>) { + return ( +
    + {children} +
    + ); +} + +function ProgressSection({ + stage, + onCancel, +}: Readonly<{ stage: DebugStage; onCancel: () => void }>) { + const { t } = useTranslation(); + const cancelling = stage.kind === "cancelling"; + return ( + + + +
    + {stageLabel(stage, t)} + + {t("settings.troubleshooting.progress.description")} + +
    + + {stage.kind === "capturing" && ( +
    + {formatRemaining(stage.remainingSec)} +
    + )} + + + + +
    + ); +} + +function DoneResult({ + result, + uploaded, + onClose, +}: Readonly<{ + result: DebugBundleResult; + uploaded: boolean; + onClose: () => void; +}>) { + const { t } = useTranslation(); + const showKey = uploaded && Boolean(result.uploadedKey); + const uploadFailed = uploaded && !result.uploadedKey; + const onRevealPath = () => { + if (!result.path) return; + DebugSvc.RevealFile(result.path).catch((err: unknown) => + console.error("reveal debug bundle file", err), + ); + }; + return ( + + + +
    + + {showKey + ? t("settings.troubleshooting.done.uploadedTitle") + : t("settings.troubleshooting.done.savedTitle")} + + + {showKey ? ( + { + e.preventDefault(); + Browser.OpenURL(SUPPORT_DOCS_URL).catch(() => + globalThis.open(SUPPORT_DOCS_URL, "_blank"), + ); + }} + className={"text-netbird hover:underline"} + > + {/* content is provided by */} + + + ), + }} + /> + ) : ( + t("settings.troubleshooting.done.savedDescription") + )} + +
    + +
    + {showKey && } + + {result.path && !showKey && ( + + + + } + /> + )} + + {uploadFailed && ( +
    + {result.uploadFailureReason + ? t("settings.troubleshooting.uploadFailedWithReason", { + reason: result.uploadFailureReason, + }) + : t("settings.troubleshooting.uploadFailed")} +
    + )} +
    + + + {showKey ? ( + + ) : ( + result.path && ( + + ) + )} + + +
    + ); +} + +const stageLabel = ( + stage: DebugStage, + t: (key: string, options?: Record) => string, +): string => { + switch (stage.kind) { + case "reconnecting": + return t("settings.troubleshooting.stage.reconnecting"); + case "capturing": + return t("settings.troubleshooting.stage.capturing"); + case "bundling": + return t("settings.troubleshooting.stage.bundling"); + case "uploading": + return t("settings.troubleshooting.stage.uploading"); + case "cancelling": + return t("settings.troubleshooting.stage.cancelling"); + default: + return ""; + } +}; diff --git a/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx b/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx new file mode 100644 index 000000000..7be59f102 --- /dev/null +++ b/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx @@ -0,0 +1,170 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Preferences, + Profiles as ProfilesSvc, + Settings as SettingsSvc, + WindowManager, +} from "@bindings/services"; +import { Restrictions, SetConfigParams } from "@bindings/services/models.js"; +import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; +import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; +import { errorDialog, formatErrorMessage } from "@/lib/errors"; +import i18next from "@/lib/i18n"; +import { isNetbirdCloud } from "@/hooks/useManagementUrl"; +import { WelcomeStepTray } from "./WelcomeStepTray"; +import { WelcomeStepManagement } from "./WelcomeStepManagement"; + +const WINDOW_WIDTH = 360; + +type WelcomeStep = "tray" | "management"; + +function shouldShowManagementStep( + activeProfileId: string, + email: string, + managementUrl: string, + managedManagementUrl: string, +): boolean { + if (managedManagementUrl) return false; + // The default profile's ID equals the literal "default", so this check + // holds whether we pass an ID or the legacy name. + if (activeProfileId !== "default") return false; + if (email.trim() !== "") return false; + return isNetbirdCloud(managementUrl); +} + +type InitialState = { + profileName: string; + username: string; + managementUrl: string; + needsManagementStep: boolean; +}; + +export default function WelcomeDialog() { + const [step, setStep] = useState("tray"); + const [initial, setInitial] = useState(null); + const [closing, setClosing] = useState(false); + const contentRef = useAutoSizeWindow(WINDOW_WIDTH, initial !== null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [username, active] = await Promise.all([ + ProfilesSvc.Username(), + ProfilesSvc.GetActive(), + ]); + const profileId = active.id || "default"; + const [config, list, restrictions] = await Promise.all([ + SettingsSvc.GetConfig({ profileName: profileId, username }), + ProfilesSvc.List(username), + SettingsSvc.GetRestrictions().catch(() => new Restrictions()), + ]); + const profile = list.find((p) => p.id === profileId); + const email = profile?.email ?? ""; + if (cancelled) return; + setInitial({ + profileName: profileId, + username, + managementUrl: config.managementUrl, + needsManagementStep: shouldShowManagementStep( + profileId, + email, + config.managementUrl, + restrictions.mdm.managementURL, + ), + }); + } catch (e) { + console.error("welcome: initial probe failed", e); + if (cancelled) return; + setInitial({ + profileName: "default", + username: "", + managementUrl: "", + needsManagementStep: false, + }); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const finish = useCallback(async () => { + if (closing) return; + setClosing(true); + try { + await Preferences.SetOnboardingCompleted(true); + } catch (e) { + console.error("persist onboarding flag:", e); + } + try { + await WindowManager.OpenMain(); + } catch (e) { + console.error("open main window:", e); + } + try { + await WindowManager.CloseWelcome(); + } catch (e) { + console.error("close welcome window:", e); + } + }, [closing]); + + const handleTrayContinue = useCallback(async () => { + if (initial?.needsManagementStep) { + setStep("management"); + } else { + await finish(); + } + }, [initial, finish]); + + const handleManagementContinue = useCallback( + async (url: string) => { + if (!initial) return; + try { + // SetConfig is a partial update — undefined fields are preserved Go-side. + await SettingsSvc.SetConfig( + new SetConfigParams({ + profileName: initial.profileName, + username: initial.username, + managementUrl: url, + }), + ); + } catch (e) { + await errorDialog({ + Title: i18next.t("settings.error.saveTitle"), + Message: formatErrorMessage(e), + }); + throw e; + } + setInitial((s) => (s ? { ...s, managementUrl: url } : s)); + await finish(); + }, + [initial, finish], + ); + + const content = useMemo(() => { + if (!initial) { + return null; + } + switch (step) { + case "tray": + return ; + case "management": + return ( + + ); + } + }, [initial, step, handleTrayContinue, handleManagementContinue]); + + return ( + + {content} + + ); +} diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx new file mode 100644 index 000000000..5cb04d484 --- /dev/null +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepManagement.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { Input } from "@/components/inputs/Input"; +import { ManagementServerSwitch } from "@/components/ManagementServerSwitch"; +import { + CLOUD_MANAGEMENT_URL, + ManagementMode, + checkManagementUrlReachable, + isNetbirdCloud, + isValidManagementUrl, + normalizeManagementUrl, +} from "@/hooks/useManagementUrl"; +import { cn } from "@/lib/cn.ts"; +import { isMacOS } from "@/lib/platform.ts"; + +type WelcomeStepManagementProps = { + initialUrl: string; + onContinue: (url: string) => Promise; +}; + +export function WelcomeStepManagement({ + initialUrl, + onContinue, +}: Readonly) { + const { t } = useTranslation(); + const startsCloud = isNetbirdCloud(initialUrl); + const [mode, setMode] = useState( + startsCloud ? ManagementMode.Cloud : ManagementMode.SelfHosted, + ); + const [url, setUrl] = useState(startsCloud ? "" : initialUrl); + const [syntaxError, setSyntaxError] = useState(null); + const [unreachable, setUnreachable] = useState(false); + const [checking, setChecking] = useState(false); + + const trimmedUrl = url.trim(); + const syntaxValid = mode === ManagementMode.Cloud || isValidManagementUrl(trimmedUrl); + const inputRef = useRef(null); + const initialMountRef = useRef(true); + const initialSelfHostedRef = useRef(!startsCloud); + + useEffect(() => { + setSyntaxError(null); + setUnreachable(false); + }, [url, mode]); + + useEffect(() => { + if (initialMountRef.current && initialSelfHostedRef.current) { + inputRef.current?.focus(); + } + initialMountRef.current = false; + }, []); + + const handleContinue = useCallback(async () => { + if (checking) return; + if (mode === ManagementMode.SelfHosted && (!trimmedUrl || !syntaxValid)) { + setSyntaxError(t("welcome.management.urlInvalid")); + inputRef.current?.focus(); + return; + } + const target = + mode === ManagementMode.Cloud + ? CLOUD_MANAGEMENT_URL + : normalizeManagementUrl(trimmedUrl); + if (mode === ManagementMode.SelfHosted && !unreachable) { + setChecking(true); + const reachable = await checkManagementUrlReachable(target); + setChecking(false); + if (!reachable) { + setUnreachable(true); + return; + } + } + try { + await onContinue(target); + } catch (e) { + console.error("save management url:", e); + } + }, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]); + + const inputError = syntaxError ?? undefined; + const inputWarning = useMemo( + () => (!syntaxError && unreachable ? t("welcome.management.urlUnreachable") : undefined), + [syntaxError, unreachable, t], + ); + + return ( + <> +
    + + {t("welcome.management.title")} + + + {t("welcome.management.description")} + +
    + +
    + +
    + + {mode === ManagementMode.SelfHosted && ( +
    + setUrl(e.target.value)} + error={inputError} + warning={inputWarning} + spellCheck={false} + autoComplete={"off"} + autoCorrect={"off"} + autoCapitalize={"off"} + /> +
    + )} + + + + + + ); +} diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx new file mode 100644 index 000000000..5a8b0d015 --- /dev/null +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/buttons/Button"; +import { DialogActions } from "@/components/dialog/DialogActions"; +import { DialogDescription } from "@/components/dialog/DialogDescription"; +import { DialogHeading } from "@/components/dialog/DialogHeading"; +import { isMacOS, isWindows } from "@/lib/platform"; +import trayScreenshotDarwin from "@/assets/img/tray-darwin.png"; +import trayScreenshotWindows from "@/assets/img/tray-windows.png"; +import trayScreenshotLinux from "@/assets/img/tray-linux.png"; + +// Call at render time, not module scope: initPlatform() must run before isMacOS/isWindows. +function trayScreenshotForOS(): string { + if (isMacOS()) return trayScreenshotDarwin; + if (isWindows()) return trayScreenshotWindows; + return trayScreenshotLinux; +} + +type WelcomeStepTrayProps = { + onContinue: () => void; +}; + +export function WelcomeStepTray({ onContinue }: Readonly) { + const { t } = useTranslation(); + const trayScreenshot = trayScreenshotForOS(); + // macOS has no tray — the icon sits in the menu bar, so the copy says so. + const titleKey = isMacOS() ? "welcome.titleMac" : "welcome.title"; + const descriptionKey = isMacOS() ? "welcome.descriptionMac" : "welcome.description"; + + return ( + <> +
    + {""} +
    + +
    + + {t(titleKey)} + + {t(descriptionKey)} +
    + + + + + + ); +} diff --git a/client/ui/frontend/src/vite-env.d.ts b/client/ui/frontend/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/client/ui/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/client/ui/frontend/tailwind.config.ts b/client/ui/frontend/tailwind.config.ts new file mode 100644 index 000000000..93ff39eea --- /dev/null +++ b/client/ui/frontend/tailwind.config.ts @@ -0,0 +1,177 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + darkMode: "class", + theme: { + fontFamily: { + sans: ['"Inter Variable"', 'ui-sans-serif', 'system-ui', 'sans-serif'], + mono: ['"JetBrains Mono Variable"', 'ui-monospace', 'monospace'], + }, + extend: { + colors: { + "nb-gray": { + DEFAULT: "#181A1D", + 50: "#f4f6f7", + 100: "#e4e7e9", + 200: "#cbd2d6", + 250: "#b7c0c6", + 300: "#a3adb5", + 350: "#8f9ca8", + 400: "#7c8994", + 500: "#616e79", + 600: "#535d67", + 700: "#474e57", + 800: "#3f444b", + 850: "#363b40", + 900: "#2e3238", + 910: "#2b2f33", + 920: "#25282d", + 925: "#1e2123", + 930: "#25282c", + 935: "#1f2124", + 940: "#1c1e21", + 950: "#181a1d", + 960: "#16181b", + }, + gray: { + 50: "#F9FAFB", + 100: "#F3F4F6", + 200: "#E5E7EB", + 300: "#D1D5DB", + 400: "#9CA3AF", + 500: "#6B7280", + 600: "#4B5563", + 700: "#374151", + 800: "#1F2937", + 900: "#111827", + }, + red: { + 50: "#FDF2F2", + 100: "#FDE8E8", + 200: "#FBD5D5", + 300: "#F8B4B4", + 400: "#F98080", + 500: "#F05252", + 600: "#E02424", + 700: "#C81E1E", + 800: "#9B1C1C", + 900: "#771D1D", + }, + yellow: { + 50: "#FDFDEA", + 100: "#FDF6B2", + 200: "#FCE96A", + 300: "#FACA15", + 400: "#E3A008", + 500: "#C27803", + 600: "#9F580A", + 700: "#8E4B10", + 800: "#723B13", + 900: "#633112", + }, + green: { + 50: "#F3FAF7", + 100: "#DEF7EC", + 200: "#BCF0DA", + 300: "#84E1BC", + 400: "#31C48D", + 500: "#0E9F6E", + 600: "#057A55", + 700: "#046C4E", + 800: "#03543F", + 900: "#014737", + }, + blue: { + 50: "#EBF5FF", + 100: "#E1EFFE", + 200: "#C3DDFD", + 300: "#A4CAFE", + 400: "#76A9FA", + 500: "#3F83F8", + 600: "#1C64F2", + 700: "#1A56DB", + 800: "#1E429F", + 900: "#233876", + }, + indigo: { + 50: "#F0F5FF", + 100: "#E5EDFF", + 200: "#CDDBFE", + 300: "#B4C6FC", + 400: "#8DA2FB", + 500: "#6875F5", + 600: "#5850EC", + 700: "#5145CD", + 800: "#42389D", + 900: "#362F78", + }, + purple: { + 50: "#F6F5FF", + 100: "#EDEBFE", + 200: "#DCD7FE", + 300: "#CABFFD", + 400: "#AC94FA", + 500: "#9061F9", + 600: "#7E3AF2", + 700: "#6C2BD9", + 800: "#5521B5", + 900: "#4A1D96", + }, + pink: { + 50: "#FDF2F8", + 100: "#FCE8F3", + 200: "#FAD1E8", + 300: "#F8B4D9", + 400: "#F17EB8", + 500: "#E74694", + 600: "#D61F69", + 700: "#BF125D", + 800: "#99154B", + 900: "#751A3D", + }, + netbird: { + DEFAULT: "#f68330", + 50: "#fff6ed", + 100: "#feecd6", + 150: "#ffdfb8", + 200: "#ffd4a6", + 300: "#fab677", + 400: "#f68330", + 500: "#f46d1b", + 600: "#e55311", + 700: "#be3e10", + 800: "#973215", + 900: "#7a2b14", + 950: "#421308", + }, + }, + backgroundImage: { + "conic-netbird": "conic-gradient(from 0deg, #e55311 0%, #f68330 10%, #e55311 20%, #e55311 100%)", + }, + keyframes: { + "pulse-reverse": { + "0%, 100%": { opacity: "1" }, + "50%": { opacity: "0.4" }, + }, + "spin-slow": { + "0%": { transform: "rotate(0deg)" }, + "100%": { transform: "rotate(360deg)" }, + }, + "ping-slow": { + "0%": { transform: "scale(1)", opacity: "1" }, + "75%, 100%": { transform: "scale(2)", opacity: "0" }, + }, + }, + animation: { + "ping-slow": "ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite", + "pulse-slow": "pulse-reverse 2s cubic-bezier(0.5, 0, 0.6, 1) infinite", + "pulse-slower": "pulse-reverse 3s cubic-bezier(0.5, 0, 0.6, 1) infinite", + "spin-slow": "spin-slow 2s linear infinite", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; + +export default config; diff --git a/client/ui/frontend/tsconfig.json b/client/ui/frontend/tsconfig.json new file mode 100644 index 000000000..f95ce9015 --- /dev/null +++ b/client/ui/frontend/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "noImplicitAny": false, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@bindings/*": ["bindings/github.com/netbirdio/netbird/client/ui/*"] + } + }, + "include": ["src", "bindings"], +} diff --git a/client/ui/frontend/vite.config.ts b/client/ui/frontend/vite.config.ts new file mode 100644 index 000000000..48b1c5630 --- /dev/null +++ b/client/ui/frontend/vite.config.ts @@ -0,0 +1,28 @@ +import path from "path"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import wails from "@wailsio/runtime/plugins/vite"; + +// https://vitejs.dev/config/ +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "@bindings": path.resolve( + __dirname, + "./bindings/github.com/netbirdio/netbird/client/ui", + ), + }, + }, + plugins: [react(), wails("./bindings")], + server: { + host: "127.0.0.1", + port: 9245, + strictPort: true, + fs: { + // The i18n bundles live at ../i18n/locales (shared with the Go tray). + // Whitelist the parent dir so Vite's dev server serves them. + allow: [path.resolve(__dirname, ".."), __dirname], + }, + }, +}); diff --git a/client/ui/grpc.go b/client/ui/grpc.go new file mode 100644 index 000000000..5450e136d --- /dev/null +++ b/client/ui/grpc.go @@ -0,0 +1,71 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "fmt" + "runtime" + "sync" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/backoff" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/desktop" +) + +// Conn is the lazy, lock-protected gRPC connection shared by all services so they reuse one channel. +type Conn struct { + addr string + + mu sync.Mutex + client proto.DaemonServiceClient +} + +func NewConn(addr string) *Conn { + return &Conn{addr: addr} +} + +func (c *Conn) Client() (proto.DaemonServiceClient, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.client != nil { + return c.client, nil + } + + // Lazy on purpose: grpc.NewClient does not connect here, so a daemon that + // is down surfaces as a per-RPC Unavailable instead of blocking the UI. + target, opts := daemonaddr.DialTarget(daemonaddr.ResolveDaemonAddr(c.addr)) + opts = append(opts, + grpc.WithUserAgent(desktop.GetUIUserAgent()), + // Cap reconnect backoff at 5s; gRPC's default 120s MaxDelay would + // leave the UI waiting 30-60s to notice a freshly-started daemon. + grpc.WithConnectParams(grpc.ConnectParams{ + Backoff: backoff.Config{ + BaseDelay: 1 * time.Second, + Multiplier: 1.6, + Jitter: 0.2, + MaxDelay: 5 * time.Second, + }, + }), + ) + + cc, err := grpc.NewClient(target, opts...) + if err != nil { + return nil, fmt.Errorf("dial daemon: %w", err) + } + c.client = proto.NewDaemonServiceClient(cc) + return c.client, nil +} + +// DaemonAddr returns the default daemon gRPC address: a Unix socket on +// Linux/macOS, a named pipe on Windows. The pipe carries the caller's token, +// which loopback TCP does not, so the daemon can tell who is calling. +func DaemonAddr() string { + if runtime.GOOS == "windows" { + return daemonaddr.WindowsPipeAddr + } + return "unix:///var/run/netbird.sock" +} diff --git a/client/ui/guilog/debuglog.go b/client/ui/guilog/debuglog.go new file mode 100644 index 000000000..3a25c26c8 --- /dev/null +++ b/client/ui/guilog/debuglog.go @@ -0,0 +1,77 @@ +//go:build !android && !ios && !freebsd && !js + +// Package guilog manages gui-client.log, which follows the daemon's log level: +// in debug/trace the GUI attaches a rotated file alongside the console so its +// (and the React frontend's forwarded) output is captured for the debug bundle. +package guilog + +import ( + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/util" +) + +// DebugLog attaches/detaches gui-client.log based on the daemon's log level, +// fed via Apply. The file is left on disk for the debug bundle to collect. +// Disabled (and never touches logging) when the user set --log-file explicitly. +type DebugLog struct { + uiPath string + enabled bool + + mu sync.Mutex + fileOn bool +} + +// NewDebugLog builds the GUI debug log. enabled is false when the user passed +// --log-file (manual override). +func NewDebugLog(uiPath string, enabled bool) *DebugLog { + return &DebugLog{uiPath: uiPath, enabled: enabled} +} + +// Path returns the GUI log path to register with the daemon, or "" when disabled +// so the daemon won't collect a file the GUI never writes. +func (d *DebugLog) Path() string { + if !d.enabled { + return "" + } + return d.uiPath +} + +// Apply reacts to a daemon log level (the logrus name, e.g. "debug"). +// Idempotent via the fileOn guard, so the startup replay plus a racing +// change-event are harmless. +func (d *DebugLog) Apply(level string) { + if !d.enabled { + return + } + + // Compared numerically so there are no hard-coded level-name literals. + lvl, err := log.ParseLevel(level) + if err != nil { + lvl = log.InfoLevel + } + debug := lvl >= log.DebugLevel + + d.mu.Lock() + defer d.mu.Unlock() + + switch { + case debug && !d.fileOn: + if err := util.SetLogOutputs(log.StandardLogger(), util.LogConsole, d.uiPath); err != nil { + log.Errorf("attach GUI file log %s: %v", d.uiPath, err) + return + } + log.SetLevel(lvl) + d.fileOn = true + log.Infof("GUI file logging enabled (daemon level %s), writing to %s", level, d.uiPath) + case !debug && d.fileOn: + if err := util.SetLogOutputs(log.StandardLogger(), util.LogConsole); err != nil { + log.Errorf("detach GUI file log: %v", err) + } + log.SetLevel(log.InfoLevel) + d.fileOn = false + log.Infof("GUI file logging disabled (daemon level: %s)", level) + } +} diff --git a/client/ui/i18n/TRANSLATING.md b/client/ui/i18n/TRANSLATING.md new file mode 100644 index 000000000..803e093c8 --- /dev/null +++ b/client/ui/i18n/TRANSLATING.md @@ -0,0 +1,130 @@ +# Translating the NetBird UI + +A short brief for translating the desktop UI — for any translator, human or AI agent (*"you"* = whoever's translating). + +**Translations are managed on Crowdin: .** Join the project, pick your language, and translate in the editor. Each string carries a context note (the `description` from the source file) telling you what it is and where it shows up, and the project's glossary, style guide, and QA checks mirror this document. + +> 💡 **The one habit that matters most:** read each string's context before translating it. Labels are terse and ambiguous on their own; the context tells you what the string is, where it shows up, what to keep verbatim, and what it actually means. + +--- + +## How contributions flow + +```text +i18n/locales/en/common.json ──sync──▶ Crowdin ──service PR──▶ i18n/locales//common.json +``` + +- `i18n/locales/en/common.json` is the source of truth. New and changed strings sync to Crowdin automatically (see `crowdin.yml` in the repository root). +- Crowdin opens and updates a service pull request with the translated bundles, keeping the source's file shape and key order. Keys nobody has translated yet are left out of the export; the app falls back to English for them at runtime. Maintainers review and merge that PR. +- Don't hand-edit `i18n/locales//common.json` in your own PRs: the next sync would conflict with or overwrite your changes. Translate on Crowdin instead. +- Missing your language? Request it on the Crowdin project page or in a [GitHub discussion](https://github.com/netbirdio/netbird/discussions). When a language first ships, a maintainer adds its row to `i18n/locales/_index.json` with `code`, `displayName` (the native name), and `englishName`, which puts it in the app's language picker. + +**Prefer translating with an AI agent?** That still works: drive it with *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* as before, but deliver the result to Crowdin instead of a pull request. Download your language's file from the Crowdin editor, let the agent translate it, and upload it back (the editor's offline translation flow). Crowdin runs its QA checks on upload, and the next service PR carries the strings into the repo. + +--- + +## What NetBird is + +A **business zero-trust VPN** — an encrypted **overlay mesh** between a company's devices, built on **WireGuard®**, connecting peers directly with a **relay** fallback. This is the **desktop client** (tray app + windows) someone runs to connect, switch profiles, browse peers, and pick an exit node — *not* the admin dashboard. + +**Audience:** IT-literate professionals. **Tone:** clear and professional, never consumer-cute. + +**The vocabulary you'll meet:** + +| Term | What it means here | +|---|---| +| **Peer** | A device on the network (laptop, server, phone) | +| **Resource / Network** | A routed network or service reachable through NetBird (UI calls these "Resources") | +| **Exit Node** | A peer that routes *all* internet traffic, like a full-tunnel gateway | +| **Profile** | A saved connection identity you can switch between | +| **Daemon** | The background service the UI talks to | +| **Management server** | The control plane — *Cloud* (hosted) or *self-hosted* (customer-run) | +| **Relay** | Forwards traffic when two peers can't connect directly | +| **Rosenpass** | Post-quantum security layered over WireGuard® | +| **Handshake** | The periodic WireGuard® key sync between peers | + +--- + +## Hard rules — get these exactly right + +These are the usual ways a translation *breaks the app*, not just reads oddly. + +| ✅ Do | ❌ Don't | +|---|---| +| Copy `{placeholders}` verbatim — `{version}`, `{count}`, `{name}`… | Translate the word inside the braces (`{verbleibend}` breaks it) | +| Reposition a placeholder so the sentence flows | Drop or duplicate a placeholder | +| Preserve every `\n`, leading/trailing space, and trailing `...` | Trim "invisible" spaces or the `...` (they're load-bearing) | +| Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the context flags | + +**Plurals:** the app has only a *one / other* split — the singular key fires only when `count == 1`; the `{count}` key covers everything else (0, 2, 5, 100…). Languages with more than two forms (ru, pl, uk) can't be fully correct here — use the form that fits the widest range (Russian genitive plural: `минут` / `часов` / `дней`). Don't invent extra keys or cram multiple forms into one string. When no single form fits every value — a unit label after a number field, say — reach for a number-agnostic form (an abbreviation, or wording that reads the same for 1 and 100) instead of forcing a plural the *one / other* split can't supply. + +**Agreement:** a `{placeholder}` drops a value into a fixed frame, so the words around it must fit *every* value the app can supply. In inflected languages, write the frame in the case the surrounding preposition demands — German's duration fragments are **dative** because they land inside "…in {remaining}" (`in {count} Tagen`, `weniger als einer Minute`), not nominative `Tage`. Check the key that *consumes* the fragment (here `tray.session.expiresIn`) before choosing the form. + +--- + +## Glossary + +**Tier A — never translate (brands):** `NetBird` · `WireGuard®` · `Rosenpass` · `GitHub` · `ICE` · company/product names · sample URLs · version numbers. + +When a brand sits beside a common noun, keep its exact spelling but join them the way your language builds such phrases — a hyphen, a connector word, an inflected noun — rather than copying English's bare noun-stack. + +**Tier B — keep as-is (acronyms):** `SSO` · `MFA` · `DNS` · `IP`/`IPv6` · `ACL` · `SSH` · `GUI` · `P2P` · `URL` · `TCP`/`UDP`. + +**Tier C — judgment.** One rule decides every term: + +> **Use the word that language's IT users actually say.** Translate when a natural, common term exists; keep the English term *only* when the literal translation would be awkward or no one in that field really uses it. + +Apply each term **consistently** — same English term → same translation everywhere — and keep a term once you've settled it. Whether a term stays English or takes a native word is **language-dependent**: a technical loanword (e.g. *Daemon*, *Handshake*) often stays, an everyday word (e.g. *Latency*, *Public key*) usually localizes, and some (*Exit Node*, *Peer*) go either way depending on the language. Decide per term with the rule above — a foreign origin alone is no reason to keep English. **Your main reference is the existing translation:** match how a term was already rendered for your language rather than re-deciding it. + +Two checks before you commit a term: + +- **Prefer established localized wording.** If a widely used tool in this space (for example WireGuard) ships your language, its wording for a shared term such as *handshake* is what users already expect — look at the translated app, not just English docs. For generic UI verbs and formal address, follow your OS vendor's style guide (Microsoft / Apple / Google). +- **Watch for false friends.** A literal translation can collide with a *different* established term in your field — confirm your word doesn't already mean something else in this domain before using it. + +These tiers are mirrored in the Crowdin project glossary, so the editor highlights them inline. When you settle a new Tier C term for your language, add its translation to the glossary entry so it sticks for everyone who comes after you. + +--- + +## Style + +| ✅ Do | ❌ Don't | +|---|---| +| Use the **formal "you"** (de *Sie*, fr *vous*, ru *вы*, it *Lei*, zh 您) | Use casual/informal address | +| Keep **buttons, menu, and tray** items short, in your language's action form (de "Speichern", fr "Enregistrer") | Let a label run much longer than the English — space is tight | +| Follow **locale punctuation** (fr NBSP + « », de „…", zh full-width), including around a quoted UI label | Carry over English Title Case (use sentence case; German nouns excepted) | +| Translate a term the **same way everywhere** | Vary wording for the same concept across screens | + +Where it reads naturally, aim to keep each string **roughly the same length** as the English — the UI is tight and over-long strings can wrap or truncate. It's a soft preference, not a rule: if your language simply needs more words, use them. + +A few habits that keep a translation reading like one product rather than a word-for-word port: + +- **Translate meaning, not words.** Render what a string *does*. An idiom or an awkward source phrase should become natural in your language, not a literal calque. +- **Keep one voice within a family.** Sibling strings — the connection states, every settings *help* caption, every "… Failed" title — should share a grammatical form. If one member sounds wrong in that form, re-voice the whole family rather than leave one odd sibling. +- **Mirror opposites.** A status should read as the natural counterpart of its pair: translate *Disconnected* as the opposite of however you rendered *Connected*, not as an unrelated word. Same for Active/Inactive, Selected/Not selected. +- **Give a standalone label its subject.** A bare button or title can lose the context the surrounding English UI implied — add the noun back if it would otherwise read ambiguously. + +--- + +## Reviewing a language + +**On Crowdin:** proofread in the editor — context, glossary highlights, and QA flags sit inline next to each string. + +**In the repo** — e.g. driving an AI agent with *"Read `i18n/TRANSLATING.md` and review the existing German translation"* — read source and target side by side; for each key check glossary conformance (e.g. de `Exit-Node` → `Exit Node`, hu `Kilépő csomópont` → `Exit Node`), placeholder/`\n` integrity, consistency, tone, and that the meaning matches the English `description`. Report what you found, and apply the fixes **on Crowdin** — direct edits to the locale files are overwritten by the next sync. + +--- + +## QA before you finish + +- [ ] Every `{placeholder}`, `\n`, and intentional space preserved · `...` / `… Failed` / `{name}` quotes kept +- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing translation for your language) +- [ ] Buttons & tray short · locale punctuation and capitalization applied +- [ ] Crowdin QA flags resolved (variables, glossary terms, punctuation) +- [ ] **Tested in the running app** ↓ + +--- + +## Test it in the app + +A translation can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens. + +How to run the app and switch language: see the project README. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step. diff --git a/client/ui/i18n/bundle.go b/client/ui/i18n/bundle.go new file mode 100644 index 000000000..892916999 --- /dev/null +++ b/client/ui/i18n/bundle.go @@ -0,0 +1,196 @@ +//go:build !android && !ios && !freebsd && !js + +// Package i18n loads and serves translation strings for both the tray (Go) +// and the React UI (via the services.I18n facade). +// +// The locale tree is passed in as an fs.FS so the embed directive can live in +// the main binary. +package i18n + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "path" + "sort" + "strings" + "sync" + + log "github.com/sirupsen/logrus" +) + +const ( + localeIndexFile = "_index.json" + + // commonBundleFile shape is Chrome-extension JSON (key -> "message" plus + // optional Crowdin "description"); loadBundle flattens to key->message. + commonBundleFile = "common.json" +) + +// LanguageCode is a BCP-47-ish locale identifier ("en", "hu", ...). +type LanguageCode string + +// DefaultLanguage is the fallback bundle for missing keys and the default +// when no preference is on disk. +const DefaultLanguage LanguageCode = "en" + +var ErrUnsupportedLanguage = errors.New("unsupported language") + +// Language describes one shipped UI locale. DisplayName is in the locale's +// own script (a Hungarian entry reads "Magyar" regardless of UI language). +type Language struct { + Code LanguageCode `json:"code"` + DisplayName string `json:"displayName"` + EnglishName string `json:"englishName"` +} + +type localeIndex struct { + Languages []Language `json:"languages"` +} + +// Bundle holds the parsed translation bundles. Loaded once at construction +// and never mutated. +type Bundle struct { + mu sync.RWMutex + languages []Language + bundles map[LanguageCode]map[string]string +} + +// NewBundle parses _index.json plus every /common.json in the locale +// tree. Hard-fails only when the default language is missing; other locales +// without a bundle are dropped with a warning. +func NewBundle(localesFS fs.FS) (*Bundle, error) { + idx, err := loadLocaleIndex(localesFS) + if err != nil { + return nil, fmt.Errorf("load locale index: %w", err) + } + + bundles := make(map[LanguageCode]map[string]string, len(idx.Languages)) + available := make([]Language, 0, len(idx.Languages)) + for _, l := range idx.Languages { + b, err := loadBundle(localesFS, l.Code) + if err != nil { + log.Warnf("skip language %q: %v", l.Code, err) + continue + } + bundles[l.Code] = b + available = append(available, l) + } + + if _, ok := bundles[DefaultLanguage]; !ok { + return nil, fmt.Errorf("default language %q bundle missing", DefaultLanguage) + } + + sort.Slice(available, func(i, j int) bool { return available[i].Code < available[j].Code }) + + return &Bundle{ + languages: available, + bundles: bundles, + }, nil +} + +// Languages returns a copy of the available locales. +func (b *Bundle) Languages() []Language { + b.mu.RLock() + defer b.mu.RUnlock() + out := make([]Language, len(b.languages)) + copy(out, b.languages) + return out +} + +func (b *Bundle) HasLanguage(code LanguageCode) bool { + b.mu.RLock() + defer b.mu.RUnlock() + _, ok := b.bundles[code] + return ok +} + +// BundleFor returns a copy of the full key->text map for one language. +func (b *Bundle) BundleFor(code LanguageCode) (map[string]string, error) { + b.mu.RLock() + defer b.mu.RUnlock() + + bundle, ok := b.bundles[code] + if !ok { + return nil, fmt.Errorf("%w: %q", ErrUnsupportedLanguage, code) + } + out := make(map[string]string, len(bundle)) + for k, v := range bundle { + out[k] = v + } + return out, nil +} + +// Translate resolves key for lang, substituting args given as name/value +// pairs ("version", "1.2.3" replaces "{version}"). Unknown keys fall back to +// the default language, then to the key itself so a miss is visible in the UI. +func (b *Bundle) Translate(lang LanguageCode, key string, args ...string) string { + b.mu.RLock() + defer b.mu.RUnlock() + + if v, ok := b.bundles[lang][key]; ok { + return applyPlaceholders(v, args) + } + if lang != DefaultLanguage { + if v, ok := b.bundles[DefaultLanguage][key]; ok { + return applyPlaceholders(v, args) + } + } + return key +} + +// applyPlaceholders substitutes {name} in s using args as flat name/value +// pairs. An odd-length args drops the trailing item. +func applyPlaceholders(s string, args []string) string { + if len(args) == 0 { + return s + } + if len(args)%2 != 0 { + log.Debugf("i18n placeholder args not paired: %d items, last dropped", len(args)) + args = args[:len(args)-1] + } + for j := 0; j < len(args); j += 2 { + s = strings.ReplaceAll(s, "{"+args[j]+"}", args[j+1]) + } + return s +} + +func loadLocaleIndex(localesFS fs.FS) (*localeIndex, error) { + data, err := fs.ReadFile(localesFS, localeIndexFile) + if err != nil { + return nil, err + } + var idx localeIndex + if err := json.Unmarshal(data, &idx); err != nil { + return nil, fmt.Errorf("parse %s: %w", localeIndexFile, err) + } + if len(idx.Languages) == 0 { + return nil, errors.New("no languages declared") + } + return &idx, nil +} + +// bundleEntry is one translation key on disk; Description is Crowdin context, +// ignored at runtime. +type bundleEntry struct { + Message string `json:"message"` + Description string `json:"description,omitempty"` +} + +func loadBundle(localesFS fs.FS, code LanguageCode) (map[string]string, error) { + p := path.Join(string(code), commonBundleFile) + data, err := fs.ReadFile(localesFS, p) + if err != nil { + return nil, err + } + var entries map[string]bundleEntry + if err := json.Unmarshal(data, &entries); err != nil { + return nil, fmt.Errorf("parse %s: %w", p, err) + } + bundle := make(map[string]string, len(entries)) + for k, e := range entries { + bundle[k] = e.Message + } + return bundle, nil +} diff --git a/client/ui/i18n/bundle_test.go b/client/ui/i18n/bundle_test.go new file mode 100644 index 000000000..d0b0d24d7 --- /dev/null +++ b/client/ui/i18n/bundle_test.go @@ -0,0 +1,156 @@ +//go:build !android && !ios && !freebsd && !js + +package i18n + +import ( + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeLocales returns an in-memory FS that mirrors the real +// client/ui/i18n/locales layout (root-level _index.json plus +// /common.json bundles). Used by every Bundle test so we don't +// depend on the embedded production bundles staying stable. +func fakeLocales() fstest.MapFS { + return fstest.MapFS{ + "_index.json": {Data: []byte(`{ + "languages": [ + {"code": "en", "displayName": "English", "englishName": "English"}, + {"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"} + ] + }`)}, + "en/common.json": {Data: []byte(`{ + "tray.menu.connect": {"message": "Connect", "description": "Tray menu item"}, + "tray.menu.installVersion": {"message": "Install version {version}"}, + "notify.update.body": {"message": "NetBird {version} is available."} + }`)}, + "hu/common.json": {Data: []byte(`{ + "tray.menu.connect": {"message": "Csatlakozás"}, + "tray.menu.installVersion": {"message": "{version} telepítése"} + }`)}, + } +} + +func TestBundle_LoadsAllLanguages(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + langs := b.Languages() + require.Len(t, langs, 2) + codes := []LanguageCode{langs[0].Code, langs[1].Code} + assert.ElementsMatch(t, []LanguageCode{"en", "hu"}, codes, "Languages should list every bundle that loaded") +} + +func TestBundle_TranslateLooksUpKey(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + assert.Equal(t, "Csatlakozás", b.Translate("hu", "tray.menu.connect")) + assert.Equal(t, "Connect", b.Translate("en", "tray.menu.connect")) +} + +func TestBundle_TranslateSubstitutesPlaceholders(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + assert.Equal(t, "Install version 1.2.3", + b.Translate("en", "tray.menu.installVersion", "version", "1.2.3"), + "placeholders should substitute by name") + assert.Equal(t, "1.2.3 telepítése", + b.Translate("hu", "tray.menu.installVersion", "version", "1.2.3")) +} + +func TestBundle_TranslateFallsBackToEnglish(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + // notify.update.body is missing from the hu bundle; English fallback + // applies so the user always sees a populated label rather than the + // raw key. + got := b.Translate("hu", "notify.update.body", "version", "9.9.9") + assert.Equal(t, "NetBird 9.9.9 is available.", got, "missing hu key should fall back to en bundle") +} + +func TestBundle_TranslateUnknownKeyReturnsKey(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + assert.Equal(t, "tray.missing", b.Translate("en", "tray.missing"), + "unknown key should return the key itself for debugability") +} + +func TestBundle_BundleForReturnsCopy(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + m, err := b.BundleFor("en") + require.NoError(t, err) + require.NotEmpty(t, m, "BundleFor should return populated map for known language") + + m["tray.menu.connect"] = "Mutated" + assert.Equal(t, "Connect", b.Translate("en", "tray.menu.connect"), + "BundleFor must return a copy, not the live map") +} + +func TestBundle_BundleForUnknownLanguage(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + _, err = b.BundleFor("xx") + assert.ErrorIs(t, err, ErrUnsupportedLanguage) +} + +func TestBundle_HasLanguage(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + assert.True(t, b.HasLanguage("en")) + assert.True(t, b.HasLanguage("hu")) + assert.False(t, b.HasLanguage("de")) +} + +func TestBundle_MissingDefaultBundleFails(t *testing.T) { + // Without an en bundle we have nothing to fall back to, so construction + // must hard-fail. Catches packaging accidents where someone drops the + // English locale. + fs := fstest.MapFS{ + "_index.json": {Data: []byte(`{"languages":[{"code":"hu","displayName":"Magyar","englishName":"Hungarian"}]}`)}, + "hu/common.json": {Data: []byte(`{"k":{"message":"v"}}`)}, + } + _, err := NewBundle(fs) + require.Error(t, err) + assert.Contains(t, err.Error(), "default language") +} + +func TestBundle_MissingBundleSkipsLanguage(t *testing.T) { + // A language declared in the index but missing its bundle file is + // dropped from Languages with a warning — adding a new language must + // be a two-step process (declare + ship), not declare-only. + fs := fstest.MapFS{ + "_index.json": {Data: []byte(`{"languages":[ + {"code":"en","displayName":"English","englishName":"English"}, + {"code":"de","displayName":"Deutsch","englishName":"German"} + ]}`)}, + "en/common.json": {Data: []byte(`{"k":{"message":"v"}}`)}, + } + b, err := NewBundle(fs) + require.NoError(t, err) + + langs := b.Languages() + require.Len(t, langs, 1) + assert.Equal(t, LanguageCode("en"), langs[0].Code, "language without a bundle file must be dropped") + assert.False(t, b.HasLanguage("de")) +} + +func TestBundle_OddPlaceholderArgsDoNotPanic(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + // Trailing dangling arg should be dropped, not panic — preserves UI + // stability when a caller passes an unpaired placeholder by mistake. + got := b.Translate("en", "tray.menu.installVersion", "version", "1.2.3", "extra") + assert.Equal(t, "Install version 1.2.3", got) +} diff --git a/client/ui/i18n/check-translations.mjs b/client/ui/i18n/check-translations.mjs new file mode 100644 index 000000000..bd076e0e0 --- /dev/null +++ b/client/ui/i18n/check-translations.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +// Validates that every shipped translation bundle carries exactly the same set +// of keys as the English source of truth. English (en) defines the keys; every +// other locale declared in _index.json must match it 1:1: +// +// - no missing keys — a missing key silently falls back to English at runtime +// (see i18n bundle fallback), so the gap never surfaces to users or CI +// without this check; +// - no orphaned keys — keys left behind after an English key is renamed or +// removed are dead weight and a sign the locale is drifting. +// +// Pure Node, no dependencies, so it runs without installing the frontend +// toolchain. +// +// Local: node client/ui/i18n/check-translations.mjs (or: pnpm i18n:check) +// CI: .github/workflows/ui-translations.yml + +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SOURCE = "en"; +const localesDir = join(dirname(fileURLToPath(import.meta.url)), "locales"); +const isCI = Boolean(process.env.GITHUB_ACTIONS); + +function readJSON(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function keysOf(langCode) { + return Object.keys(readJSON(join(localesDir, langCode, "common.json"))); +} + +// Emit a GitHub Actions annotation so failures render inline on the PR diff. +function annotate(file, message) { + if (isCI) console.log(`::error file=${file}::${message}`); +} + +const index = readJSON(join(localesDir, "_index.json")); +const declared = index.languages.map((l) => l.code); + +if (!declared.includes(SOURCE)) { + console.error(`FATAL: source language "${SOURCE}" is not declared in _index.json`); + process.exit(1); +} + +const sourceKeys = keysOf(SOURCE); +const sourceSet = new Set(sourceKeys); +console.log(`Source of truth: ${SOURCE}/common.json — ${sourceKeys.length} keys\n`); + +let failed = false; + +for (const code of declared) { + if (code === SOURCE) continue; + const file = `client/ui/i18n/locales/${code}/common.json`; + + let keys; + try { + keys = keysOf(code); + } catch (e) { + failed = true; + const msg = `bundle is declared in _index.json but common.json is missing or invalid (${e.message})`; + console.error(`✗ ${code}: ${msg}`); + annotate("client/ui/i18n/locales/_index.json", `${code}: ${msg}`); + continue; + } + + const set = new Set(keys); + const missing = sourceKeys.filter((k) => !set.has(k)); + const extra = keys.filter((k) => !sourceSet.has(k)); + + if (missing.length === 0 && extra.length === 0) { + console.log(`✓ ${code}: ${keys.length} keys`); + continue; + } + + failed = true; + console.error(`✗ ${code}: ${keys.length} keys (expected ${sourceKeys.length})`); + if (missing.length) { + console.error(` missing ${missing.length}: ${missing.join(", ")}`); + annotate(file, `Missing ${missing.length} key(s) present in ${SOURCE}: ${missing.join(", ")}`); + } + if (extra.length) { + console.error(` extra ${extra.length}: ${extra.join(", ")}`); + annotate(file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`); + } +} + +// Locale directories present on disk but not declared in _index.json are never +// loaded by the app — surface them so dead translation files don't rot silently. +const onDisk = readdirSync(localesDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); +const undeclared = onDisk.filter((d) => !declared.includes(d)); +if (undeclared.length) { + console.warn(`\n⚠ locale directories not declared in _index.json (not shipped): ${undeclared.join(", ")}`); +} + +console.log(); +if (failed) { + console.error("Translation check FAILED — every locale must match the English key set."); + process.exit(1); +} +console.log("Translation check passed — all locales match the English key set."); diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json new file mode 100644 index 000000000..419358d36 --- /dev/null +++ b/client/ui/i18n/locales/_index.json @@ -0,0 +1,14 @@ +{ + "languages": [ + {"code": "en", "displayName": "English (US)", "englishName": "English (US)"}, + {"code": "de", "displayName": "Deutsch", "englishName": "German"}, + {"code": "hu", "displayName": "Magyar", "englishName": "Hungarian"}, + {"code": "ru", "displayName": "Русский", "englishName": "Russian"}, + {"code": "es", "displayName": "Español", "englishName": "Spanish"}, + {"code": "fr", "displayName": "Français", "englishName": "French"}, + {"code": "it", "displayName": "Italiano", "englishName": "Italian"}, + {"code": "pt", "displayName": "Português", "englishName": "Portuguese"}, + {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"}, + {"code": "ja", "displayName": "日本語", "englishName": "Japanese"} + ] +} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json new file mode 100644 index 000000000..1208a37fe --- /dev/null +++ b/client/ui/i18n/locales/de/common.json @@ -0,0 +1,1363 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Nicht verbunden" + }, + "tray.status.daemonUnavailable": { + "message": "Nicht aktiv" + }, + "tray.status.error": { + "message": "Fehler" + }, + "tray.status.connected": { + "message": "Verbunden" + }, + "tray.status.connecting": { + "message": "Wird verbunden" + }, + "tray.status.needsLogin": { + "message": "Anmeldung erforderlich" + }, + "tray.status.loginFailed": { + "message": "Anmeldung fehlgeschlagen" + }, + "tray.status.sessionExpired": { + "message": "Sitzung abgelaufen" + }, + "tray.session.expiresIn": { + "message": "Sitzung läuft ab in {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "weniger als einer Minute" + }, + "tray.session.unit.minute": { + "message": "1 Minute" + }, + "tray.session.unit.minutes": { + "message": "{count} Minuten" + }, + "tray.session.unit.hour": { + "message": "1 Stunde" + }, + "tray.session.unit.hours": { + "message": "{count} Stunden" + }, + "tray.session.unit.day": { + "message": "1 Tag" + }, + "tray.session.unit.days": { + "message": "{count} Tagen" + }, + "tray.menu.open": { + "message": "NetBird öffnen" + }, + "tray.menu.connect": { + "message": "Verbinden" + }, + "tray.menu.disconnect": { + "message": "Trennen" + }, + "tray.menu.exitNode": { + "message": "Exit Node" + }, + "tray.menu.networks": { + "message": "Ressourcen" + }, + "tray.menu.profiles": { + "message": "Profile" + }, + "tray.menu.manageProfiles": { + "message": "Profile verwalten" + }, + "tray.menu.settings": { + "message": "Einstellungen …" + }, + "tray.menu.debugBundle": { + "message": "Debug-Paket erstellen" + }, + "tray.menu.about": { + "message": "Hilfe & Support" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Dokumentation" + }, + "tray.menu.troubleshoot": { + "message": "Fehlerbehebung" + }, + "tray.menu.downloadLatest": { + "message": "Neueste Version herunterladen" + }, + "tray.menu.installVersion": { + "message": "Version {version} installieren" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "NetBird beenden" + }, + "notify.daemonOutdated.title": { + "message": "NetBird-Dienst ist veraltet" + }, + "notify.daemonOutdated.body": { + "message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden." + }, + "notify.update.title": { + "message": "NetBird-Update verfügbar" + }, + "notify.update.body": { + "message": "NetBird {version} ist verfügbar." + }, + "notify.update.enforcedSuffix": { + "message": " Ihr Administrator verlangt dieses Update." + }, + "notify.error.title": { + "message": "Fehler" + }, + "notify.error.connect": { + "message": "Verbindung fehlgeschlagen" + }, + "notify.error.disconnect": { + "message": "Trennen fehlgeschlagen" + }, + "notify.error.switchProfile": { + "message": "Wechsel zu {profile} fehlgeschlagen" + }, + "notify.error.exitNode": { + "message": "Exit Node {name} konnte nicht aktualisiert werden" + }, + "notify.sessionExpired.title": { + "message": "NetBird-Sitzung abgelaufen" + }, + "notify.sessionExpired.body": { + "message": "Ihre NetBird-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an." + }, + "notify.sessionWarning.title": { + "message": "Sitzung läuft bald ab" + }, + "notify.sessionWarning.body": { + "message": "Ihre NetBird-Sitzung läuft in {remaining} ab. Klicken Sie auf Jetzt verlängern, um zu erneuern." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Ihre NetBird-Sitzung läuft bald ab. Klicken Sie auf Jetzt verlängern, um zu erneuern." + }, + "notify.sessionWarning.extend": { + "message": "Jetzt verlängern" + }, + "notify.sessionWarning.dismiss": { + "message": "Verwerfen" + }, + "notify.sessionWarning.failed": { + "message": "NetBird-Sitzung konnte nicht verlängert werden" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird-Sitzung verlängert" + }, + "notify.sessionWarning.successBody": { + "message": "Ihre Sitzung wurde erneuert." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Ungültige Sitzungsablaufzeit" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Der Server hat eine ungültige Sitzungsablaufzeit übermittelt. Bitte melden Sie sich erneut an." + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird-Einstellungen aktualisiert" + }, + "notify.mdm.policyApplied.body": { + "message": "Ihre NetBird-Konfiguration wurde durch Ihre IT-Richtlinie aktualisiert." + }, + "common.cancel": { + "message": "Abbrechen" + }, + "common.save": { + "message": "Speichern" + }, + "common.saveChanges": { + "message": "Änderungen speichern" + }, + "common.saving": { + "message": "Speichert…" + }, + "common.close": { + "message": "Schließen" + }, + "common.copy": { + "message": "Kopieren" + }, + "common.togglePasswordVisibility": { + "message": "Passwortsichtbarkeit umschalten" + }, + "common.increase": { + "message": "Erhöhen" + }, + "common.decrease": { + "message": "Verringern" + }, + "common.delete": { + "message": "Löschen" + }, + "common.create": { + "message": "Erstellen" + }, + "common.add": { + "message": "Hinzufügen" + }, + "common.remove": { + "message": "Entfernen" + }, + "common.refresh": { + "message": "Aktualisieren" + }, + "common.loading": { + "message": "Lädt…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Keine Ergebnisse gefunden" + }, + "common.noResults.description": { + "message": "Es konnten keine Ergebnisse gefunden werden. Bitte versuchen Sie es mit einem anderen Suchbegriff oder ändern Sie Ihre Filter." + }, + "notConnected.title": { + "message": "Nicht verbunden" + }, + "notConnected.description": { + "message": "Verbinden Sie sich zuerst mit NetBird, um detaillierte Informationen zu Ihren Peers, Netzwerkressourcen und Exit Nodes einzusehen." + }, + "connect.status.disconnected": { + "message": "Nicht verbunden" + }, + "connect.status.connecting": { + "message": "Wird verbunden…" + }, + "connect.status.connected": { + "message": "Verbunden" + }, + "connect.status.disconnecting": { + "message": "Wird getrennt…" + }, + "connect.status.daemonUnavailable": { + "message": "Daemon nicht verfügbar" + }, + "connect.status.loginRequired": { + "message": "Anmeldung erforderlich" + }, + "connect.error.loginTitle": { + "message": "Anmeldung fehlgeschlagen" + }, + "connect.error.connectTitle": { + "message": "Verbindung fehlgeschlagen" + }, + "connect.error.disconnectTitle": { + "message": "Trennen fehlgeschlagen" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} von {total} verbunden" + }, + "nav.resources.title": { + "message": "Ressourcen" + }, + "nav.resources.description": { + "message": "{active} von {total} aktiv" + }, + "nav.exitNode.title": { + "message": "Exit Nodes" + }, + "nav.exitNode.none": { + "message": "Nicht aktiv" + }, + "nav.exitNode.using": { + "message": "Über {name}" + }, + "header.openSettings": { + "message": "Einstellungen öffnen" + }, + "header.togglePanel": { + "message": "Seitenleiste umschalten" + }, + "profile.selector.loading": { + "message": "Lädt…" + }, + "profile.selector.noProfile": { + "message": "Kein Profil" + }, + "profile.selector.searchPlaceholder": { + "message": "Profil nach Namen suchen…" + }, + "profile.selector.emptyTitle": { + "message": "Keine Profile gefunden" + }, + "profile.selector.emptyDescription": { + "message": "Versuchen Sie einen anderen Suchbegriff oder erstellen Sie ein neues Profil." + }, + "profile.selector.newProfile": { + "message": "Neues Profil" + }, + "profile.selector.moreOptions": { + "message": "Weitere Optionen" + }, + "profile.selector.deregister": { + "message": "Abmelden" + }, + "profile.selector.delete": { + "message": "Profil löschen" + }, + "profile.selector.switchTo": { + "message": "Zu diesem Profil wechseln" + }, + "profile.selector.edit": { + "message": "Bearbeiten" + }, + "profile.edit.title": { + "message": "Profil bearbeiten" + }, + "profile.edit.submit": { + "message": "Änderungen speichern" + }, + "profile.dialog.title": { + "message": "Neues Profil" + }, + "profile.dialog.nameLabel": { + "message": "Profilname" + }, + "profile.dialog.description": { + "message": "Legen Sie einen leicht erkennbaren Namen für Ihr Profil fest." + }, + "profile.dialog.placeholder": { + "message": "z. B. Arbeit" + }, + "profile.dialog.submit": { + "message": "Profil hinzufügen" + }, + "profile.dialog.required": { + "message": "Bitte geben Sie einen Profilnamen ein, z. B. Arbeit, Privat" + }, + "profile.dialog.managementHelp": { + "message": "NetBird Cloud oder Ihr eigener Server." + }, + "profile.dialog.urlUnreachable": { + "message": "Server nicht erreichbar. Überprüfen Sie die URL, oder fügen Sie das Profil trotzdem hinzu, wenn Sie sicher sind, dass sie korrekt ist." + }, + "header.menu.settings": { + "message": "Einstellungen …" + }, + "header.menu.defaultView": { + "message": "Standardansicht" + }, + "header.menu.advancedView": { + "message": "Erweiterte Ansicht" + }, + "header.menu.updateAvailable": { + "message": "Update verfügbar" + }, + "header.menu.open": { + "message": "Menü öffnen" + }, + "header.profile.switch": { + "message": "Profil wechseln" + }, + "connect.toggle.label": { + "message": "NetBird-Verbindung umschalten" + }, + "connect.localIp.label": { + "message": "Lokale IP-Adressen" + }, + "common.search": { + "message": "Suchen" + }, + "common.filter": { + "message": "Filtern" + }, + "exitNodes.dropdown.trigger": { + "message": "Exit-Node auswählen" + }, + "peers.row.label": { + "message": "Details öffnen für {name}, {status}" + }, + "peers.dialog.title": { + "message": "Peer-Details" + }, + "networks.row.toggle": { + "message": "{name} umschalten" + }, + "networks.bulk.label": { + "message": "Alle sichtbaren Ressourcen umschalten" + }, + "settings.nav.label": { + "message": "Einstellungsbereiche" + }, + "profile.switch.title": { + "message": "Zu Profil \"{name}\" wechseln?" + }, + "profile.switch.message": { + "message": "Sind Sie sicher, dass Sie das Profil wechseln möchten?\nIhr aktuelles Profil wird getrennt." + }, + "profile.switch.confirm": { + "message": "Bestätigen" + }, + "profile.deregister.title": { + "message": "Profil \"{name}\" abmelden?" + }, + "profile.deregister.message": { + "message": "Sind Sie sicher, dass Sie dieses Profil abmelden möchten?\nSie müssen sich erneut anmelden, um es zu nutzen." + }, + "profile.deregister.confirm": { + "message": "Abmelden" + }, + "profile.delete.title": { + "message": "Profil \"{name}\" löschen?" + }, + "profile.delete.message": { + "message": "Sind Sie sicher, dass Sie dieses Profil löschen möchten?\nDiese Aktion kann nicht rückgängig gemacht werden." + }, + "profile.delete.disabledActive": { + "message": "Aktive Profile können nicht gelöscht werden. Wechseln Sie zu einem anderen Profil, bevor Sie dieses löschen." + }, + "profile.delete.disabledDefault": { + "message": "Das Standardprofil kann nicht gelöscht werden." + }, + "profile.error.switchTitle": { + "message": "Profilwechsel fehlgeschlagen" + }, + "profile.error.deregisterTitle": { + "message": "Abmeldung fehlgeschlagen" + }, + "profile.error.deleteTitle": { + "message": "Löschen des Profils fehlgeschlagen" + }, + "profile.error.createTitle": { + "message": "Erstellen des Profils fehlgeschlagen" + }, + "profile.error.editTitle": { + "message": "Bearbeiten des Profils fehlgeschlagen" + }, + "profile.error.loadTitle": { + "message": "Laden der Profile fehlgeschlagen" + }, + "profile.dropdown.activeProfile": { + "message": "Aktives Profil" + }, + "profile.dropdown.switchProfile": { + "message": "Profil wechseln" + }, + "profile.dropdown.noEmail": { + "message": "Andere" + }, + "profile.dropdown.addProfile": { + "message": "Profil hinzufügen" + }, + "profile.dropdown.manageProfiles": { + "message": "Profile verwalten" + }, + "profile.dropdown.settings": { + "message": "Einstellungen" + }, + "settings.profiles.section.profiles": { + "message": "Profile" + }, + "settings.profiles.intro": { + "message": "Verwalten Sie mehrere NetBird-Profile parallel, zum Beispiel berufliche und private Konten oder verschiedene Management-Server. Fügen Sie unten Profile hinzu, melden Sie sie ab oder löschen Sie sie." + }, + "settings.profiles.addProfile": { + "message": "Profil hinzufügen" + }, + "settings.profiles.active": { + "message": "Aktiv" + }, + "settings.profiles.emptyTitle": { + "message": "Keine Profile" + }, + "settings.profiles.emptyDescription": { + "message": "Erstellen Sie ein Profil, um sich mit einem NetBird-Management-Server zu verbinden." + }, + "settings.error.loadTitle": { + "message": "Laden der Einstellungen fehlgeschlagen" + }, + "settings.error.saveTitle": { + "message": "Speichern der Einstellungen fehlgeschlagen" + }, + "settings.error.debugBundleTitle": { + "message": "Debug-Paket fehlgeschlagen" + }, + "settings.tabs.general": { + "message": "Allgemein" + }, + "settings.tabs.network": { + "message": "Netzwerk" + }, + "settings.tabs.security": { + "message": "Sicherheit" + }, + "settings.tabs.profiles": { + "message": "Profile" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Erweitert" + }, + "settings.tabs.troubleshooting": { + "message": "Fehlerbehebung" + }, + "settings.tabs.about": { + "message": "Über" + }, + "settings.tabs.updateAvailable": { + "message": "Update verfügbar" + }, + "settings.general.section.general": { + "message": "Allgemein" + }, + "settings.general.section.connection": { + "message": "Verbindung" + }, + "settings.general.connectOnStartup.label": { + "message": "Beim Start verbinden" + }, + "settings.general.connectOnStartup.help": { + "message": "Beim Start des Dienstes automatisch eine Verbindung herstellen." + }, + "settings.general.notifications.label": { + "message": "Desktop-Benachrichtigungen" + }, + "settings.general.notifications.help": { + "message": "Desktop-Benachrichtigungen für neue Updates und Verbindungsereignisse anzeigen." + }, + "settings.general.autostart.label": { + "message": "NetBird-UI beim Anmelden starten" + }, + "settings.general.autostart.help": { + "message": "Die NetBird-Oberfläche beim Anmelden automatisch starten. Dies betrifft nur die grafische Oberfläche, nicht den Hintergrunddienst." + }, + "settings.general.autostart.errorTitle": { + "message": "Ändern des Autostarts fehlgeschlagen" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Nach dem Beenden verbunden bleiben", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Anzeigesprache" + }, + "settings.general.language.help": { + "message": "Wählen Sie die Sprache der NetBird-Oberfläche." + }, + "settings.general.language.search": { + "message": "Sprache suchen…" + }, + "settings.general.language.empty": { + "message": "Keine Sprachen gefunden." + }, + "settings.general.management.label": { + "message": "Management-Server" + }, + "settings.general.management.help": { + "message": "Mit NetBird Cloud oder Ihrem eigenen self-hosted Management-Server verbinden. Änderungen lösen eine Neuverbindung des Clients aus." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Self-hosted" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Server nicht erreichbar. Überprüfen Sie die URL, oder speichern Sie trotzdem, wenn Sie sicher sind, dass sie korrekt ist." + }, + "settings.general.management.switchCloudTitle": { + "message": "Zu NetBird Cloud wechseln?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Dies trennt die Verbindung zu Ihrem self-hosted Server.\nMöglicherweise müssen Sie sich erneut anmelden." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Zu Cloud wechseln" + }, + "settings.network.section.connectivity": { + "message": "Konnektivität" + }, + "settings.network.section.routingDns": { + "message": "Routing & DNS" + }, + "settings.network.monitor.label": { + "message": "Bei Netzwerkwechsel neu verbinden" + }, + "settings.network.monitor.help": { + "message": "Das Netzwerk überwachen und bei Änderungen (z. B. WLAN-Wechsel, Ethernet-Änderungen oder Rückkehr aus dem Ruhezustand) automatisch neu verbinden." + }, + "settings.network.dns.label": { + "message": "DNS aktivieren" + }, + "settings.network.dns.help": { + "message": "NetBird-verwaltete DNS-Einstellungen auf den Host-Resolver anwenden." + }, + "settings.network.clientRoutes.label": { + "message": "Client-Routen aktivieren" + }, + "settings.network.clientRoutes.help": { + "message": "Routen von anderen Peers übernehmen, um deren Netzwerke zu erreichen." + }, + "settings.network.serverRoutes.label": { + "message": "Server-Routen aktivieren" + }, + "settings.network.serverRoutes.help": { + "message": "Lokale Routen dieses Hosts an andere Peers ankündigen." + }, + "settings.network.ipv6.label": { + "message": "IPv6 aktivieren" + }, + "settings.network.ipv6.help": { + "message": "IPv6-Adressierung für das NetBird-Overlay-Netzwerk verwenden." + }, + "settings.security.section.firewall": { + "message": "Firewall" + }, + "settings.security.section.encryption": { + "message": "Verschlüsselung" + }, + "settings.security.blockInbound.label": { + "message": "Eingehenden Verkehr blockieren" + }, + "settings.security.blockInbound.help": { + "message": "Unaufgeforderte Verbindungen von Peers zu diesem Gerät und den von ihm gerouteten Netzwerken ablehnen. Ausgehender Verkehr ist nicht betroffen." + }, + "settings.security.blockLan.label": { + "message": "LAN-Zugriff blockieren" + }, + "settings.security.blockLan.help": { + "message": "Verhindert, dass Peers Ihr lokales Netzwerk oder dessen Geräte erreichen, wenn dieses Gerät deren Verkehr routet." + }, + "settings.security.rosenpass.label": { + "message": "Quantenresistenz aktivieren" + }, + "settings.security.rosenpass.help": { + "message": "Einen Post-Quanten-Schlüsselaustausch über Rosenpass zusätzlich zu WireGuard® hinzufügen." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Permissiven Modus aktivieren" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Verbindungen zu Peers ohne Quantenresistenz-Unterstützung erlauben." + }, + "settings.ssh.section.server": { + "message": "Server" + }, + "settings.ssh.section.capabilities": { + "message": "Funktionen" + }, + "settings.ssh.section.authentication": { + "message": "Authentifizierung" + }, + "settings.ssh.server.label": { + "message": "SSH-Server aktivieren" + }, + "settings.ssh.server.help": { + "message": "Den NetBird SSH-Server auf diesem Host ausführen, damit andere Peers sich verbinden können." + }, + "settings.ssh.root.label": { + "message": "Root-Login erlauben" + }, + "settings.ssh.root.help": { + "message": "Peers dürfen sich als root anmelden. Deaktivieren, um ein nicht-privilegiertes Konto zu erfordern." + }, + "settings.ssh.sftp.label": { + "message": "SFTP erlauben" + }, + "settings.ssh.sftp.help": { + "message": "Dateien sicher über native SFTP- oder SCP-Clients übertragen." + }, + "settings.ssh.localForward.label": { + "message": "Lokale Portweiterleitung" + }, + "settings.ssh.localForward.help": { + "message": "Verbundene Peers können lokale Ports zu von diesem Host erreichbaren Diensten tunneln." + }, + "settings.ssh.remoteForward.label": { + "message": "Remote-Portweiterleitung" + }, + "settings.ssh.remoteForward.help": { + "message": "Verbundene Peers können Ports dieses Hosts an ihren eigenen Rechner weitergeben." + }, + "settings.ssh.jwt.label": { + "message": "JWT-Authentifizierung aktivieren" + }, + "settings.ssh.jwt.help": { + "message": "Jede SSH-Sitzung gegen Ihren IdP für Identität und Audit prüfen. Deaktivieren, um sich nur auf Netzwerk-ACL-Richtlinien zu verlassen — sinnvoll, wenn kein IdP verfügbar ist." + }, + "settings.ssh.jwtTtl.label": { + "message": "JWT-Cache-TTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "Wie lange dieser Client ein JWT zwischenspeichert, bevor bei ausgehenden SSH-Verbindungen erneut nachgefragt wird. Auf 0 setzen, um den Cache zu deaktivieren und bei jeder Verbindung zu authentifizieren." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "Sekunde(n)" + }, + "settings.advanced.section.interface": { + "message": "Schnittstelle" + }, + "settings.advanced.section.security": { + "message": "Sicherheit" + }, + "settings.advanced.interfaceName.label": { + "message": "Name" + }, + "settings.advanced.interfaceName.error": { + "message": "Verwenden Sie 1–15 Buchstaben, Ziffern, Punkte, Bindestriche oder Unterstriche." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Muss mit „utun“ und einer Zahl beginnen (z. B. utun100)." + }, + "settings.advanced.port.label": { + "message": "Port" + }, + "settings.advanced.port.error": { + "message": "Geben Sie einen Port zwischen {min} und {max} ein." + }, + "settings.advanced.port.help": { + "message": "Wenn auf 0 gesetzt, wird ein zufälliger freier Port verwendet." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Geben Sie einen MTU-Wert zwischen {min} und {max} ein." + }, + "settings.advanced.psk.label": { + "message": "Pre-shared Key" + }, + "settings.advanced.psk.help": { + "message": "Optionaler WireGuard-PSK für zusätzliche symmetrische Verschlüsselung. Nicht identisch mit einem NetBird Setup-Key. Sie kommunizieren nur mit Peers, die denselben Pre-shared Key verwenden." + }, + "settings.troubleshooting.section.title": { + "message": "Debug-Paket" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Sensible Informationen anonymisieren" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Verbirgt IP-Adressen, Domains und andere sensible Werte." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Der Standardmodus lässt interne IPv4-Adressen und Peer-Namen für den Support lesbar. Der strikte Modus anonymisiert zusätzlich private (RFC 1918), CGNAT- und Link-Local-IP-Adressen, Peer-Namen und öffentliche WireGuard-Schlüssel. Wiederkehrende Werte erhalten denselben Platzhalter, sodass Peers unterscheidbar bleiben. Verwenden Sie den strikten Modus, wenn Sie das Debug-Paket außerhalb Ihrer Organisation weitergeben." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Keine" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Standard" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Strikt" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Systeminformationen einschließen" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "OS, Kernel, Netzwerkschnittstellen und Routing-Tabellen einschließen." + }, + "settings.troubleshooting.upload.label": { + "message": "Paket an NetBird-Server hochladen" + }, + "settings.troubleshooting.upload.help": { + "message": "Gibt einen Upload-Schlüssel zurück, den Sie mit dem NetBird-Support teilen können." + }, + "settings.troubleshooting.trace.label": { + "message": "Trace-Logs aktivieren" + }, + "settings.troubleshooting.trace.help": { + "message": "Hebt das Log-Level auf TRACE an und stellt es danach wieder her." + }, + "settings.troubleshooting.capture.label": { + "message": "Aufzeichnungssitzung" + }, + "settings.troubleshooting.capture.help": { + "message": "Stellt die Verbindung neu her und wartet, damit Sie das Problem reproduzieren können." + }, + "settings.troubleshooting.packets.label": { + "message": "Netzwerkpakete aufzeichnen" + }, + "settings.troubleshooting.packets.help": { + "message": "Speichert eine .pcap-Datei des Netzwerkverkehrs während der Aufzeichnung." + }, + "settings.troubleshooting.duration.label": { + "message": "Aufzeichnungsdauer" + }, + "settings.troubleshooting.duration.help": { + "message": "Wie lange die Aufzeichnungssitzung läuft." + }, + "settings.troubleshooting.duration.suffix": { + "message": "Minute(n)" + }, + "settings.troubleshooting.create": { + "message": "Debug-Paket erstellen" + }, + "settings.troubleshooting.progress.description": { + "message": "Logs, Systemdetails und Verbindungszustand werden gesammelt. Dies dauert in der Regel einen Moment — lassen Sie dieses Fenster geöffnet, bis es abgeschlossen ist." + }, + "settings.troubleshooting.cancelling": { + "message": "Wird abgebrochen…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Debug-Paket erfolgreich hochgeladen!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Paket gespeichert" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Teilen Sie den unten angezeigten Upload-Schlüssel mit dem NetBird-Support. Eine lokale Kopie wurde ebenfalls auf Ihrem Gerät gespeichert." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Ihr Debug-Paket wurde lokal gespeichert." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Schlüssel kopieren" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Ordner öffnen" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Speicherort öffnen" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Upload fehlgeschlagen: {reason} Das Paket wurde trotzdem lokal gespeichert." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Upload fehlgeschlagen. Das Paket wurde trotzdem lokal gespeichert." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "NetBird wird neu verbunden…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Debug-Logs werden erfasst" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Debug-Paket wird erstellt…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Wird zu NetBird hochgeladen…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Wird abgebrochen…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Entwicklung]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Alle Rechte vorbehalten." + }, + "settings.about.links.imprint": { + "message": "Impressum" + }, + "settings.about.links.privacy": { + "message": "Datenschutz" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Nutzungsbedingungen" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Forum" + }, + "settings.about.community.documentation": { + "message": "Dokumentation" + }, + "settings.about.community.feedback": { + "message": "Feedback" + }, + "update.banner.message": { + "message": "NetBird {version} ist installationsbereit." + }, + "update.banner.later": { + "message": "Später" + }, + "update.banner.installNow": { + "message": "Jetzt installieren" + }, + "update.card.versionAvailableDownload": { + "message": "Version {version} ist zum Herunterladen verfügbar." + }, + "update.card.versionAvailableInstall": { + "message": "Version {version} ist zur Installation verfügbar." + }, + "update.card.whatsNew": { + "message": "Was ist neu?" + }, + "update.card.installNow": { + "message": "Jetzt installieren" + }, + "update.card.getInstaller": { + "message": "Herunterladen" + }, + "update.card.autoCheckInterval": { + "message": "NetBird sucht im Hintergrund nach Updates." + }, + "update.card.changelog": { + "message": "Changelog" + }, + "update.card.onLatestVersion": { + "message": "Sie verwenden die neueste Version" + }, + "update.header.tooltip": { + "message": "Update verfügbar" + }, + "update.overlay.updatingVersion": { + "message": "NetBird wird auf v{version} aktualisiert" + }, + "update.overlay.updating": { + "message": "NetBird wird aktualisiert" + }, + "update.overlay.description": { + "message": "Eine neuere Version ist verfügbar und wird installiert. NetBird startet nach Abschluss des Updates automatisch neu." + }, + "update.overlay.error.timeoutTitle": { + "message": "Update dauert zu lange" + }, + "update.overlay.error.timeoutDescription": { + "message": "Die Installation von {target} hat zu lange gedauert und wurde nicht abgeschlossen." + }, + "update.overlay.error.canceledTitle": { + "message": "Update wurde abgebrochen" + }, + "update.overlay.error.canceledDescription": { + "message": "Das Update auf {target} wurde vor dem Abschluss abgebrochen." + }, + "update.overlay.error.failTitle": { + "message": "Update konnte nicht installiert werden" + }, + "update.overlay.error.failDescription": { + "message": "{target} konnte nicht installiert werden." + }, + "update.overlay.error.unknownMessage": { + "message": "unbekannter Fehler" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "die neue Version" + }, + "update.error.loadStateTitle": { + "message": "Laden des Update-Status fehlgeschlagen" + }, + "update.error.triggerTitle": { + "message": "Update-Start fehlgeschlagen" + }, + "update.page.versionLine": { + "message": "Client wird aktualisiert auf: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Client wird aktualisiert." + }, + "update.page.outdated": { + "message": "Ihre Client-Version ist älter als die im Management eingestellte Auto-Update-Version." + }, + "update.page.status.running": { + "message": "Wird aktualisiert" + }, + "update.page.status.timeout": { + "message": "Zeitüberschreitung beim Update. Bitte erneut versuchen." + }, + "update.page.status.canceled": { + "message": "Update abgebrochen." + }, + "update.page.status.failed": { + "message": "Update fehlgeschlagen: {message}" + }, + "update.page.status.unknownError": { + "message": "unbekannter Update-Fehler" + }, + "update.page.failedTitle": { + "message": "Update fehlgeschlagen" + }, + "update.page.timeoutMessage": { + "message": "Zeitüberschreitung beim Update." + }, + "update.page.dontClose": { + "message": "Bitte schließen Sie dieses Fenster nicht." + }, + "update.page.updating": { + "message": "Wird aktualisiert…" + }, + "update.page.complete": { + "message": "Update abgeschlossen" + }, + "update.page.failed": { + "message": "Update fehlgeschlagen" + }, + "window.title.settings": { + "message": "Einstellungen" + }, + "window.title.signIn": { + "message": "Anmeldung" + }, + "window.title.sessionExpiration": { + "message": "Sitzung läuft ab" + }, + "window.title.updating": { + "message": "Aktualisierung" + }, + "window.title.welcome": { + "message": "Willkommen bei NetBird" + }, + "window.title.error": { + "message": "Fehler" + }, + "welcome.title": { + "message": "Suchen Sie NetBird in der Taskleiste" + }, + "welcome.titleMac": { + "message": "Suchen Sie NetBird in der Menüleiste" + }, + "welcome.description": { + "message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen." + }, + "welcome.descriptionMac": { + "message": "NetBird läuft in Ihrer Menüleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen." + }, + "welcome.continue": { + "message": "Weiter" + }, + "welcome.back": { + "message": "Zurück" + }, + "welcome.management.title": { + "message": "NetBird einrichten" + }, + "welcome.management.description": { + "message": "Klicken Sie auf „Weiter“, um loszulegen, oder wählen Sie „Self-hosted“, wenn Sie einen eigenen NetBird-Server haben." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Nutzen Sie unseren gehosteten Dienst. Keine Einrichtung nötig." + }, + "welcome.management.selfHosted.title": { + "message": "Self-hosted" + }, + "welcome.management.selfHosted.description": { + "message": "Verbindung zu Ihrem eigenen Management-Server." + }, + "welcome.management.urlLabel": { + "message": "URL des Management-Servers" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Server nicht erreichbar. Überprüfen Sie die URL oder Ihr Netzwerk und fahren Sie fort, wenn Sie sicher sind, dass sie korrekt ist." + }, + "welcome.management.checking": { + "message": "Wird geprüft …" + }, + "browserLogin.title": { + "message": "Anmeldung im Browser abschließen" + }, + "browserLogin.notSeeing": { + "message": "Sehen Sie den Browser-Tab nicht?" + }, + "browserLogin.tryAgain": { + "message": "Erneut versuchen" + }, + "browserLogin.openFailedTitle": { + "message": "Browser konnte nicht geöffnet werden" + }, + "sessionExpiration.title": { + "message": "Sitzung läuft bald ab" + }, + "sessionExpiration.titleLater": { + "message": "Ihre Sitzung läuft ab" + }, + "sessionExpiration.description": { + "message": "Dieses Gerät wird bald getrennt. Browser-Anmeldung zum Erneuern erforderlich." + }, + "sessionExpiration.descriptionLater": { + "message": "Eine Browser-Anmeldung hält dieses Gerät mit Ihrem Netzwerk verbunden." + }, + "sessionExpiration.stay": { + "message": "Sitzung erneuern" + }, + "sessionExpiration.authenticate": { + "message": "Anmelden" + }, + "sessionExpiration.logout": { + "message": "Abmelden" + }, + "sessionExpiration.expired": { + "message": "Sitzung abgelaufen" + }, + "sessionExpiration.expiredDescription": { + "message": "Gerät getrennt. Mit Browser-Anmeldung authentifizieren, um erneut zu verbinden." + }, + "sessionExpiration.close": { + "message": "Schließen" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Sitzungsverlängerung fehlgeschlagen" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Abmeldung fehlgeschlagen" + }, + "peers.search.placeholder": { + "message": "Nach Name oder IP suchen" + }, + "peers.filter.all": { + "message": "Alle" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Keine Peers verfügbar" + }, + "peers.empty.description": { + "message": "Sie haben entweder keine Peers verfügbar oder keinen Zugriff auf einen davon." + }, + "peers.details.domain": { + "message": "Domain" + }, + "peers.details.netbirdIp": { + "message": "NetBird-IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird-IPv6" + }, + "peers.details.publicKey": { + "message": "Öffentlicher Schlüssel" + }, + "peers.details.connection": { + "message": "Verbindung" + }, + "peers.details.latency": { + "message": "Latenz" + }, + "peers.details.lastHandshake": { + "message": "Letzter Handshake" + }, + "peers.details.statusSince": { + "message": "Letzte Verbindungsaktualisierung" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Gesendet" + }, + "peers.details.bytesReceived": { + "message": "Empfangen" + }, + "peers.details.localIce": { + "message": "Lokales ICE" + }, + "peers.details.remoteIce": { + "message": "Remote ICE" + }, + "peers.details.never": { + "message": "Nie" + }, + "peers.details.justNow": { + "message": "Gerade eben" + }, + "peers.details.refresh": { + "message": "Aktualisieren" + }, + "peers.status.connected": { + "message": "Verbunden" + }, + "peers.status.connecting": { + "message": "Wird verbunden" + }, + "peers.status.disconnected": { + "message": "Nicht verbunden" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Ressourcen" + }, + "peers.details.relayed": { + "message": "Relayed" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass aktiviert" + }, + "networks.search.placeholder": { + "message": "Nach Netzwerk oder Domain suchen" + }, + "networks.filter.all": { + "message": "Alle" + }, + "networks.filter.active": { + "message": "Aktiv" + }, + "networks.filter.overlapping": { + "message": "Überlappend" + }, + "networks.empty.title": { + "message": "Keine Ressourcen verfügbar" + }, + "networks.empty.description": { + "message": "Sie haben entweder keine Netzwerkressourcen verfügbar oder keinen Zugriff auf eine davon." + }, + "networks.selected": { + "message": "Ausgewählt" + }, + "networks.unselected": { + "message": "Nicht ausgewählt" + }, + "networks.ips.heading": { + "message": "Aufgelöste IPs" + }, + "networks.bulk.selectionCount": { + "message": "{selected} von {total} aktiv" + }, + "networks.bulk.enableAll": { + "message": "Alle aktivieren" + }, + "networks.bulk.disableAll": { + "message": "Alle deaktivieren" + }, + "exitNodes.search.placeholder": { + "message": "Exit Nodes suchen" + }, + "exitNodes.none": { + "message": "Keiner" + }, + "exitNodes.empty.title": { + "message": "Keine Exit Nodes verfügbar" + }, + "exitNodes.empty.description": { + "message": "Für diesen Peer wurden keine Exit Nodes freigegeben." + }, + "exitNodes.card.title": { + "message": "Exit Node" + }, + "exitNodes.card.statusActive": { + "message": "Aktiv" + }, + "exitNodes.card.statusInactive": { + "message": "Inaktiv" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Keiner" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Direkte Verbindung ohne Exit Node" + }, + "quickActions.connect": { + "message": "Verbinden" + }, + "quickActions.disconnect": { + "message": "Trennen" + }, + "daemon.unavailable.title": { + "message": "NetBird-Dienst läuft nicht" + }, + "daemon.unavailable.description": { + "message": "Die App stellt automatisch die Verbindung wieder her, sobald der Dienst läuft." + }, + "daemon.unavailable.docsLink": { + "message": "Dokumentation" + }, + "daemon.outdated.title": { + "message": "NetBird Client ist veraltet" + }, + "daemon.outdated.description": { + "message": "Die neue GUI ist nicht mit Ihrem älteren Client kompatibel. Aktualisieren Sie Ihren Client, um die neue Anwendung zu verwenden." + }, + "daemon.outdated.download": { + "message": "Neueste Version herunterladen" + }, + "error.jwt_clock_skew": { + "message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut." + }, + "error.jwt_expired": { + "message": "Ihr Anmeldetoken ist abgelaufen. Bitte melden Sie sich erneut an." + }, + "error.jwt_signature_invalid": { + "message": "Anmeldung fehlgeschlagen: Die Token-Signatur ist ungültig. Bitte wenden Sie sich an Ihren Administrator." + }, + "error.session_expired": { + "message": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an." + }, + "error.invalid_setup_key": { + "message": "Der Setup-Key fehlt oder ist ungültig." + }, + "error.permission_denied": { + "message": "Die Anmeldung wurde vom Server abgelehnt." + }, + "error.daemon_unreachable": { + "message": "Der NetBird-Dienst antwortet nicht. Bitte prüfen Sie, ob der Dienst läuft." + }, + "error.unknown": { + "message": "Vorgang fehlgeschlagen." + }, + "settings.ssh.privilege.hint": { + "message": "Erfordert {actor}. Führen Sie stattdessen dies aus:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Sie können dies deaktivieren, aber zum erneuten Aktivieren sind {actor} erforderlich:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Sie können dies aktivieren, aber zum erneuten Deaktivieren sind {actor} erforderlich:" + } +} diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json new file mode 100644 index 000000000..694444497 --- /dev/null +++ b/client/ui/i18n/locales/en/common.json @@ -0,0 +1,1814 @@ +{ + "tray.tooltip": { + "message": "NetBird", + "description": "Hover tooltip on the system-tray / menu-bar icon. Brand name — do not translate." + }, + "tray.status.disconnected": { + "message": "Disconnected", + "description": "Connection status surfaced through the tray icon: the client is not connected to the network." + }, + "tray.status.daemonUnavailable": { + "message": "Not running", + "description": "Tray status: the background NetBird service (daemon) is not running." + }, + "tray.status.error": { + "message": "Error", + "description": "Tray status: the client is in an error state." + }, + "tray.status.connected": { + "message": "Connected", + "description": "Tray status: connected to the NetBird network." + }, + "tray.status.connecting": { + "message": "Connecting", + "description": "Tray status: a connection is being established." + }, + "tray.status.needsLogin": { + "message": "Login required", + "description": "Tray status: the user must sign in before connecting." + }, + "tray.status.loginFailed": { + "message": "Login failed", + "description": "Tray status: the last sign-in attempt failed." + }, + "tray.status.sessionExpired": { + "message": "Session expired", + "description": "Tray status: the authenticated session expired; the user must sign in again." + }, + "tray.session.expiresIn": { + "message": "Session expires in {remaining}", + "description": "Tray row showing time left before the session expires. {remaining} is a human-readable duration such as '5 minutes', built from the tray.session.unit.* strings. Keep {remaining} unchanged." + }, + "tray.session.unit.lessThanMinute": { + "message": "less than a minute", + "description": "Duration fragment substituted into {remaining} (see tray.session.expiresIn). Used when under one minute remains." + }, + "tray.session.unit.minute": { + "message": "1 minute", + "description": "Duration fragment for exactly one minute, substituted into {remaining}." + }, + "tray.session.unit.minutes": { + "message": "{count} minutes", + "description": "Duration fragment for several minutes, substituted into {remaining}. {count} is the number of minutes; keep {count}." + }, + "tray.session.unit.hour": { + "message": "1 hour", + "description": "Duration fragment for exactly one hour, substituted into {remaining}." + }, + "tray.session.unit.hours": { + "message": "{count} hours", + "description": "Duration fragment for several hours. {count} is the number of hours; keep {count}." + }, + "tray.session.unit.day": { + "message": "1 day", + "description": "Duration fragment for exactly one day, substituted into {remaining}." + }, + "tray.session.unit.days": { + "message": "{count} days", + "description": "Duration fragment for several days. {count} is the number of days; keep {count}." + }, + "tray.menu.open": { + "message": "Open NetBird", + "description": "Tray menu item that opens the main NetBird window. Keep short." + }, + "tray.menu.connect": { + "message": "Connect", + "description": "Tray menu item that connects to the network. Keep short." + }, + "tray.menu.disconnect": { + "message": "Disconnect", + "description": "Tray menu item that disconnects from the network. Keep short." + }, + "tray.menu.exitNode": { + "message": "Exit Node", + "description": "Tray submenu title for choosing an exit node (route all traffic through another peer)." + }, + "tray.menu.networks": { + "message": "Resources", + "description": "Tray submenu title listing network resources the user can reach. Labelled 'Resources' in the UI even though the key says networks." + }, + "tray.menu.profiles": { + "message": "Profiles", + "description": "Tray submenu title listing connection profiles." + }, + "tray.menu.manageProfiles": { + "message": "Manage Profiles", + "description": "Tray menu item that opens Settings → Profiles." + }, + "tray.menu.settings": { + "message": "Settings...", + "description": "Tray menu item that opens the Settings window. The trailing '...' signals that a window opens; keep it." + }, + "tray.menu.debugBundle": { + "message": "Create Debug Bundle", + "description": "Tray menu item that generates a diagnostic log bundle for support." + }, + "tray.menu.about": { + "message": "Help & Support", + "description": "Tray submenu title for help and support links." + }, + "tray.menu.github": { + "message": "GitHub", + "description": "Tray menu link to the GitHub repository. Brand name — do not translate." + }, + "tray.menu.documentation": { + "message": "Documentation", + "description": "Tray menu link to the online documentation." + }, + "tray.menu.troubleshoot": { + "message": "Troubleshoot", + "description": "Tray menu link to troubleshooting help." + }, + "tray.menu.downloadLatest": { + "message": "Download latest version", + "description": "Tray menu item to download the latest available version." + }, + "tray.menu.installVersion": { + "message": "Install version {version}", + "description": "Tray menu item to install a specific update. {version} is a version number like 0.30.1; keep {version}." + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}", + "description": "Tray menu row showing the installed UI version. 'GUI' = the graphical app. {version} is a version number; keep it." + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}", + "description": "Tray menu row showing the background-service version. 'Daemon' = the background NetBird service. {version} is a version number; keep it." + }, + "tray.menu.versionUnknown": { + "message": "—", + "description": "Placeholder shown in version rows when the version can't be determined. It is an em dash — leave as-is." + }, + "tray.menu.quit": { + "message": "Quit NetBird", + "description": "Tray menu item that fully exits the app and removes the tray icon. Keep short." + }, + "notify.daemonOutdated.title": { + "message": "NetBird service is outdated", + "description": "Title of the OS desktop notification shown when the running daemon is too old for this UI." + }, + "notify.daemonOutdated.body": { + "message": "Update the NetBird service to use this app.", + "description": "Body of the desktop notification telling the user to upgrade the daemon." + }, + "notify.update.title": { + "message": "NetBird update available", + "description": "Title of the OS desktop notification shown when an app update is available." + }, + "notify.update.body": { + "message": "NetBird {version} is available.", + "description": "Body of the update-available desktop notification. {version} is the new version number; keep it." + }, + "notify.update.enforcedSuffix": { + "message": " Your administrator requires this update.", + "description": "Sentence appended to the update notification body when the admin has made the update mandatory. Note the leading space — keep it." + }, + "notify.error.title": { + "message": "Error", + "description": "Title of a generic error desktop notification." + }, + "notify.error.connect": { + "message": "Failed to connect", + "description": "Error notification body shown when connecting failed." + }, + "notify.error.disconnect": { + "message": "Failed to disconnect", + "description": "Error notification body shown when disconnecting failed." + }, + "notify.error.switchProfile": { + "message": "Failed to switch to {profile}", + "description": "Error notification shown when switching profiles failed. {profile} is the target profile name; keep it." + }, + "notify.error.exitNode": { + "message": "Failed to update exit node {name}", + "description": "Error notification shown when updating the exit node failed. {name} is the exit node name; keep it." + }, + "notify.sessionExpired.title": { + "message": "NetBird session expired", + "description": "Title of the desktop notification shown when the session has expired." + }, + "notify.sessionExpired.body": { + "message": "Your NetBird session has expired. Please log in again.", + "description": "Body of the session-expired desktop notification." + }, + "notify.sessionWarning.title": { + "message": "Session expires soon", + "description": "Title of the desktop notification warning that the session will expire soon." + }, + "notify.sessionWarning.body": { + "message": "Your NetBird session expires in {remaining}. Click Extend now to renew.", + "description": "Body of the session-expiry warning notification. {remaining} is a human-readable duration (see tray.session.unit.*); keep it. 'Extend now' refers to the action button notify.sessionWarning.extend." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Your NetBird session is about to expire. Click Extend now to renew.", + "description": "Generic session-expiry warning body used when the exact remaining time isn't known." + }, + "notify.sessionWarning.extend": { + "message": "Extend now", + "description": "Action button on the session-expiry notification that renews the session. Keep short." + }, + "notify.sessionWarning.dismiss": { + "message": "Dismiss", + "description": "Action button on the session-expiry notification that dismisses it. Keep short." + }, + "notify.sessionWarning.failed": { + "message": "Failed to extend NetBird session", + "description": "Notification shown when renewing the session failed." + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird session extended", + "description": "Title of the notification confirming the session was renewed." + }, + "notify.sessionWarning.successBody": { + "message": "Your session has been refreshed.", + "description": "Body of the notification confirming the session was renewed." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Session deadline rejected", + "description": "Title of the notification shown when the server sent an invalid session deadline." + }, + "notify.sessionDeadlineRejected.body": { + "message": "The server sent an invalid session deadline. Please sign in again.", + "description": "Body explaining the server sent an invalid session deadline and the user must sign in again." + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird settings updated", + "description": "Title of the desktop notification shown when an MDM (IT-managed) policy changed the daemon configuration at runtime." + }, + "notify.mdm.policyApplied.body": { + "message": "Your NetBird configuration was updated by your IT policy.", + "description": "Body of the MDM policy-applied notification, telling the user their settings were changed by their organization's device-management policy." + }, + "common.cancel": { + "message": "Cancel", + "description": "Generic Cancel button label, reused across dialogs. Keep short." + }, + "common.save": { + "message": "Save", + "description": "Generic Save button label. Keep short." + }, + "common.saveChanges": { + "message": "Save Changes", + "description": "Button label to save edited settings. Keep short." + }, + "common.saving": { + "message": "Saving…", + "description": "Button label / status shown while a save is in progress. Ends with an ellipsis." + }, + "common.close": { + "message": "Close", + "description": "Generic Close button label. Keep short." + }, + "common.copy": { + "message": "Copy", + "description": "Generic Copy button label (copy to clipboard). Keep short." + }, + "common.togglePasswordVisibility": { + "message": "Toggle password visibility", + "description": "Accessibility label for the show/hide button inside a password field." + }, + "common.increase": { + "message": "Increase", + "description": "Accessibility label for the increment (+) button on a numeric stepper input." + }, + "common.decrease": { + "message": "Decrease", + "description": "Accessibility label for the decrement (−) button on a numeric stepper input." + }, + "common.delete": { + "message": "Delete", + "description": "Generic Delete button label. Keep short." + }, + "common.create": { + "message": "Create", + "description": "Generic Create button label. Keep short." + }, + "common.add": { + "message": "Add", + "description": "Generic Add button label. Keep short." + }, + "common.remove": { + "message": "Remove", + "description": "Generic Remove button label. Keep short." + }, + "common.refresh": { + "message": "Refresh", + "description": "Generic Refresh button label. Keep short." + }, + "common.loading": { + "message": "Loading…", + "description": "Generic loading indicator text. Ends with an ellipsis." + }, + "common.netbird": { + "message": "NetBird", + "description": "The product name. Brand — do not translate." + }, + "common.noResults.title": { + "message": "Could not find any results", + "description": "Title of the empty state shown when a search or filter returns nothing." + }, + "common.noResults.description": { + "message": "We couldn't find any results. Please try a different search term or change your filters.", + "description": "Body of the no-results empty state, suggesting a different search term or filters." + }, + "notConnected.title": { + "message": "Disconnected", + "description": "Title of the placeholder shown on data screens while disconnected." + }, + "notConnected.description": { + "message": "Connect to NetBird first to view detailed information about your peers, network resources, and exit nodes.", + "description": "Body explaining the user must connect to NetBird first to see peer, resource, and exit-node details." + }, + "connect.status.disconnected": { + "message": "Disconnected", + "description": "Label on the main-window connection toggle: not connected." + }, + "connect.status.connecting": { + "message": "Connecting...", + "description": "Connection toggle label while connecting. Ends with an ellipsis." + }, + "connect.status.connected": { + "message": "Connected", + "description": "Connection toggle label when connected." + }, + "connect.status.disconnecting": { + "message": "Disconnecting...", + "description": "Connection toggle label while disconnecting. Ends with an ellipsis." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon unavailable", + "description": "Connection toggle label when the background service is unavailable. 'Daemon' = background service." + }, + "connect.status.loginRequired": { + "message": "Login required", + "description": "Connection toggle label when sign-in is required before connecting." + }, + "connect.error.loginTitle": { + "message": "Login Failed", + "description": "Error-dialog title shown when sign-in fails. The action-named '… Failed' style is intentional — keep it." + }, + "connect.error.connectTitle": { + "message": "Connect Failed", + "description": "Error-dialog title shown when connecting fails." + }, + "connect.error.disconnectTitle": { + "message": "Disconnect Failed", + "description": "Error-dialog title shown when disconnecting fails." + }, + "nav.peers.title": { + "message": "Peers", + "description": "Navigation label for the Peers section (other devices in the network)." + }, + "nav.peers.description": { + "message": "{connected} of {total} connected", + "description": "Sub-label under Peers showing how many are connected. {connected} and {total} are numbers; keep both." + }, + "nav.resources.title": { + "message": "Resources", + "description": "Navigation label for the Resources section (routed networks)." + }, + "nav.resources.description": { + "message": "{active} of {total} active", + "description": "Sub-label under Resources showing how many are active. {active} and {total} are numbers; keep both." + }, + "nav.exitNode.title": { + "message": "Exit Nodes", + "description": "Navigation label for the Exit Nodes section." + }, + "nav.exitNode.none": { + "message": "Not active", + "description": "Sub-label under Exit Nodes when no exit node is in use." + }, + "nav.exitNode.using": { + "message": "Via {name}", + "description": "Sub-label under Exit Nodes when one is active. {name} is the exit node's name; keep it." + }, + "header.openSettings": { + "message": "Open settings", + "description": "Accessibility label / tooltip for the gear icon that opens Settings." + }, + "header.togglePanel": { + "message": "Toggle side panel", + "description": "Accessibility label / tooltip for the button that shows or hides the side panel." + }, + "profile.selector.loading": { + "message": "Loading...", + "description": "Shown in the profile picker while profiles load. Ends with an ellipsis." + }, + "profile.selector.noProfile": { + "message": "No profile", + "description": "Shown in the profile picker when no profile is selected." + }, + "profile.selector.searchPlaceholder": { + "message": "Search profile by name...", + "description": "Placeholder text in the profile picker's search field." + }, + "profile.selector.emptyTitle": { + "message": "No Profiles Found", + "description": "Title shown in the profile picker when a search matches no profiles." + }, + "profile.selector.emptyDescription": { + "message": "Try a different search term or create a new profile.", + "description": "Body shown when no profiles match the search, suggesting a different term or creating one." + }, + "profile.selector.newProfile": { + "message": "New Profile", + "description": "Button in the profile picker to create a new profile. Keep short." + }, + "profile.selector.moreOptions": { + "message": "More options", + "description": "Accessibility label for the per-profile kebab (⋯) menu." + }, + "profile.selector.deregister": { + "message": "Deregister", + "description": "Per-profile menu action: deregister (sign out of the profile but keep it)." + }, + "profile.selector.delete": { + "message": "Delete", + "description": "Per-profile menu action: delete the profile." + }, + "profile.selector.switchTo": { + "message": "Switch to this profile", + "description": "Tooltip / label for the action that switches to a profile." + }, + "profile.selector.edit": { + "message": "Edit", + "description": "Per-profile menu action: open the edit dialog to rename or change the management server." + }, + "profile.edit.title": { + "message": "Edit Profile", + "description": "Title of the dialog for editing an existing profile." + }, + "profile.edit.submit": { + "message": "Save Changes", + "description": "Submit button on the edit-profile dialog. Keep short." + }, + "profile.dialog.title": { + "message": "Enter Profile Name", + "description": "Title of the dialog for naming a new profile." + }, + "profile.dialog.nameLabel": { + "message": "Profile Name", + "description": "Field label for the profile name." + }, + "profile.dialog.description": { + "message": "Set an easily identifiable name for your profile.", + "description": "Helper text under the profile-name field." + }, + "profile.dialog.placeholder": { + "message": "e.g. Work", + "description": "Example placeholder shown in the profile-name field. 'Work' is a sample value; translate it to a natural example." + }, + "profile.dialog.submit": { + "message": "Add Profile", + "description": "Submit button on the add-profile dialog. Keep short." + }, + "profile.dialog.required": { + "message": "Please enter a profile name, e.g. work, home", + "description": "Validation message shown when the profile name is empty. The examples (work, home) may be localized." + }, + "profile.dialog.managementHelp": { + "message": "Use NetBird Cloud or your own server.", + "description": "Helper text noting the profile can use NetBird Cloud or a self-hosted server." + }, + "profile.dialog.urlUnreachable": { + "message": "Couldn't reach this server. Check the URL, or add the profile anyway if you're sure it's correct.", + "description": "Soft warning when the entered server URL couldn't be reached; the user may add the profile anyway." + }, + "header.menu.settings": { + "message": "Settings...", + "description": "'More' menu item in the header that opens Settings. The trailing '...' signals a window opens." + }, + "header.menu.defaultView": { + "message": "Default View", + "description": "'More' menu item that switches the main window to the compact default view." + }, + "header.menu.advancedView": { + "message": "Advanced View", + "description": "'More' menu item that switches the main window to the wider advanced view." + }, + "header.menu.updateAvailable": { + "message": "Update Available", + "description": "'More' menu item / badge shown when an update is available." + }, + "header.menu.open": { + "message": "Open menu", + "description": "Accessibility label for the header's more (⋮) button that opens the menu." + }, + "header.profile.switch": { + "message": "Switch profile", + "description": "Accessibility label for the header's profile selector button." + }, + "connect.toggle.label": { + "message": "Toggle NetBird connection", + "description": "Accessibility label for the large connect/disconnect toggle on the main page." + }, + "connect.localIp.label": { + "message": "Local IP addresses", + "description": "Accessibility label for the local IP selector that toggles between IPv4 and IPv6 on the main page." + }, + "common.search": { + "message": "Search", + "description": "Accessibility label for a generic search input." + }, + "common.filter": { + "message": "Filter", + "description": "Accessibility label for a generic filter control." + }, + "exitNodes.dropdown.trigger": { + "message": "Select exit node", + "description": "Accessibility label for the exit-node picker button at the bottom of the main page." + }, + "peers.row.label": { + "message": "Open details for {name}, {status}", + "description": "Accessibility label for a peer row in the list. {name} is the peer name and {status} is the connection status; keep both placeholders." + }, + "peers.dialog.title": { + "message": "Peer details", + "description": "Accessibility title (announced to screen readers) for the peer details dialog/panel." + }, + "networks.row.toggle": { + "message": "Toggle {name}", + "description": "Accessibility label for the row-wide toggle on a network/resource. {name} is the resource id; keep the placeholder." + }, + "networks.bulk.label": { + "message": "Toggle all visible resources", + "description": "Accessibility label for the bulk enable/disable button at the bottom of the resources list." + }, + "profile.switch.title": { + "message": "Switch Profile to \"{name}\"?", + "description": "Confirmation-dialog title for switching profiles. {name} is the target profile name, shown in quotes; keep {name} and the surrounding quotes." + }, + "profile.switch.message": { + "message": "Are you sure you want to switch profiles?\nYour current profile will be disconnected.", + "description": "Confirmation body for switching profiles. Contains a line break (\\n) — keep it." + }, + "profile.switch.confirm": { + "message": "Confirm", + "description": "Confirm button on the switch-profile dialog. Keep short." + }, + "profile.deregister.title": { + "message": "Deregister Profile \"{name}\"?", + "description": "Confirmation-dialog title for deregistering a profile. {name} is the profile name; keep it and the quotes." + }, + "profile.deregister.message": { + "message": "Are you sure you want to deregister this profile?\nYou will need to log in again to use it.", + "description": "Confirmation body for deregistering; warns the user must sign in again. Contains a line break (\\n) — keep it." + }, + "profile.deregister.confirm": { + "message": "Deregister", + "description": "Confirm button on the deregister dialog. Keep short." + }, + "profile.delete.title": { + "message": "Delete Profile \"{name}\"?", + "description": "Confirmation-dialog title for deleting a profile. {name} is the profile name; keep it and the quotes." + }, + "profile.delete.message": { + "message": "Are you sure you want to delete this profile?\nThis action cannot be undone.", + "description": "Confirmation body for deleting; warns the action can't be undone. Contains a line break (\\n) — keep it." + }, + "profile.delete.disabledActive": { + "message": "Active profiles cannot be deleted. Switch to a different one before deleting this profile.", + "description": "Tooltip explaining the active profile can't be deleted until the user switches away." + }, + "profile.delete.disabledDefault": { + "message": "The default profile cannot be deleted.", + "description": "Tooltip explaining the default profile can't be deleted." + }, + "profile.error.switchTitle": { + "message": "Switch Profile Failed", + "description": "Error-dialog title when switching profiles fails." + }, + "profile.error.deregisterTitle": { + "message": "Deregister Profile Failed", + "description": "Error-dialog title when deregistering a profile fails." + }, + "profile.error.deleteTitle": { + "message": "Delete Profile Failed", + "description": "Error-dialog title when deleting a profile fails." + }, + "profile.error.createTitle": { + "message": "Create Profile Failed", + "description": "Error-dialog title when creating a profile fails." + }, + "profile.error.editTitle": { + "message": "Edit Profile Failed", + "description": "Error-dialog title when editing a profile (rename or management URL change) fails." + }, + "profile.error.loadTitle": { + "message": "Load Profiles Failed", + "description": "Error-dialog title when loading profiles fails." + }, + "profile.dropdown.activeProfile": { + "message": "Active profile", + "description": "Section heading in the header profile dropdown for the current profile." + }, + "profile.dropdown.switchProfile": { + "message": "Switch Profile", + "description": "Section heading / action in the profile dropdown to switch profiles." + }, + "profile.dropdown.noEmail": { + "message": "Other", + "description": "Label shown for a profile that has no associated email address." + }, + "profile.dropdown.addProfile": { + "message": "Add Profile", + "description": "Profile dropdown action to add a profile." + }, + "profile.dropdown.manageProfiles": { + "message": "Manage Profiles", + "description": "Profile dropdown action that opens Settings → Profiles." + }, + "profile.dropdown.settings": { + "message": "Settings", + "description": "Profile dropdown action that opens Settings." + }, + "settings.profiles.section.profiles": { + "message": "Profiles", + "description": "Section heading on the Profiles settings tab." + }, + "settings.profiles.intro": { + "message": "Keep separate NetBird identities side by side, for example work and personal accounts, or different management servers. Add, deregister, or delete profiles below.", + "description": "Intro paragraph on the Profiles settings tab explaining what profiles are for." + }, + "settings.profiles.addProfile": { + "message": "Add Profile", + "description": "Button on the Profiles settings tab to add a profile." + }, + "settings.profiles.active": { + "message": "Active", + "description": "Badge marking the currently active profile in the profiles table." + }, + "settings.profiles.emptyTitle": { + "message": "No Profiles", + "description": "Title of the empty state when there are no profiles." + }, + "settings.profiles.emptyDescription": { + "message": "Create a profile to connect to a NetBird management server.", + "description": "Body of the no-profiles empty state." + }, + "settings.error.loadTitle": { + "message": "Load Settings Failed", + "description": "Error-dialog title when loading settings fails." + }, + "settings.error.saveTitle": { + "message": "Save Settings Failed", + "description": "Error-dialog title when saving settings fails." + }, + "settings.error.debugBundleTitle": { + "message": "Debug Bundle Failed", + "description": "Error-dialog title when creating the debug bundle fails." + }, + "settings.nav.label": { + "message": "Settings sections", + "description": "Accessibility label for the Settings page's side navigation (list of section tabs)." + }, + "settings.tabs.general": { + "message": "General", + "description": "Settings tab label: General. Keep short." + }, + "settings.tabs.network": { + "message": "Network", + "description": "Settings tab label: Network. Keep short." + }, + "settings.tabs.security": { + "message": "Security", + "description": "Settings tab label: Security. Keep short." + }, + "settings.tabs.profiles": { + "message": "Profiles", + "description": "Settings tab label: Profiles. Keep short." + }, + "settings.tabs.ssh": { + "message": "SSH", + "description": "Settings tab label: SSH. Acronym — keep as-is." + }, + "settings.tabs.advanced": { + "message": "Advanced", + "description": "Settings tab label: Advanced. Keep short." + }, + "settings.tabs.troubleshooting": { + "message": "Troubleshoot", + "description": "Settings tab label: Troubleshoot. Keep short." + }, + "settings.tabs.about": { + "message": "About", + "description": "Settings tab label: About. Keep short." + }, + "settings.tabs.updateAvailable": { + "message": "Update Available", + "description": "Settings tab label / badge shown when an update is available." + }, + "settings.general.section.general": { + "message": "General", + "description": "Section heading on the General settings tab." + }, + "settings.general.section.connection": { + "message": "Connection", + "description": "Section heading for connection-related options on the General tab." + }, + "settings.general.connectOnStartup.label": { + "message": "Connect on Startup", + "description": "Toggle label: connect automatically when the service starts." + }, + "settings.general.connectOnStartup.help": { + "message": "Automatically establish a connection when the service starts.", + "description": "Helper text for the connect-on-startup toggle." + }, + "settings.general.notifications.label": { + "message": "Desktop Notifications", + "description": "Toggle label: enable desktop notifications." + }, + "settings.general.notifications.help": { + "message": "Show desktop notifications for new updates and connection events.", + "description": "Helper text for the desktop-notifications toggle." + }, + "settings.general.autostart.label": { + "message": "Launch NetBird UI at Login", + "description": "Toggle label: launch the NetBird UI at login." + }, + "settings.general.autostart.help": { + "message": "Start the NetBird interface automatically when you log in. This affects the graphical interface only, not the background service.", + "description": "Helper text clarifying autostart affects only the UI, not the background service." + }, + "settings.general.autostart.errorTitle": { + "message": "Autostart Change Failed", + "description": "Error-dialog title when changing the autostart setting fails." + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Stay Connected After Quitting", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "The connection stays up in the background after you close NetBird. It only stops when you disconnect it yourself.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Display Language", + "description": "Label for the display-language picker." + }, + "settings.general.language.help": { + "message": "Choose the language for the NetBird interface.", + "description": "Helper text for the language picker." + }, + "settings.general.language.search": { + "message": "Search language…", + "description": "Placeholder in the language picker's search field." + }, + "settings.general.language.empty": { + "message": "No languages match.", + "description": "Shown when no languages match the search." + }, + "settings.general.management.label": { + "message": "Management Server", + "description": "Label for the management-server selector." + }, + "settings.general.management.help": { + "message": "Connect to NetBird Cloud or your own self-hosted management server. Changes will reconnect the client.", + "description": "Helper text explaining Cloud vs self-hosted management server; warns that changes reconnect the client." + }, + "settings.general.management.cloud": { + "message": "Cloud", + "description": "Option label for using NetBird Cloud as the management server." + }, + "settings.general.management.selfHosted": { + "message": "Self-hosted", + "description": "Option label for using a self-hosted management server. 'Self-hosted' is a common technical term." + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443", + "description": "Example URL placeholder for the self-hosted server field. It is a sample URL — do not translate the URL itself." + }, + "settings.general.management.urlError": { + "message": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443", + "description": "Validation message for an invalid management-server URL. The example URL stays as-is." + }, + "settings.general.management.urlUnreachable": { + "message": "Couldn't reach this server. Check the URL, or save anyway if you're sure it's correct.", + "description": "Soft warning when the management URL couldn't be reached; the user may save anyway." + }, + "settings.general.management.switchCloudTitle": { + "message": "Switch to NetBird Cloud?", + "description": "Confirmation-dialog title for switching to NetBird Cloud." + }, + "settings.general.management.switchCloudMessage": { + "message": "This disconnects your self-hosted server.\nYou may need to log in again.", + "description": "Confirmation body warning the self-hosted server will be disconnected. Contains a line break (\\n) — keep it." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Switch to Cloud", + "description": "Confirm button for switching to Cloud. Keep short." + }, + "settings.network.section.connectivity": { + "message": "Connectivity", + "description": "Section heading for connectivity options on the Network tab." + }, + "settings.network.section.routingDns": { + "message": "Routing & DNS", + "description": "Section heading for routing and DNS options. 'DNS' is an acronym — keep it." + }, + "settings.network.monitor.label": { + "message": "Reconnect on Network Change", + "description": "Toggle label: reconnect automatically on network change." + }, + "settings.network.monitor.help": { + "message": "Monitor the network and automatically reconnect on changes such as Wi-Fi switching, Ethernet changes, or resume from sleep.", + "description": "Helper text for the network-change reconnect toggle." + }, + "settings.network.dns.label": { + "message": "Enable DNS", + "description": "Toggle label: enable DNS. 'DNS' — keep acronym." + }, + "settings.network.dns.help": { + "message": "Apply NetBird-managed DNS settings to the host resolver.", + "description": "Helper text for the enable-DNS toggle." + }, + "settings.network.clientRoutes.label": { + "message": "Enable Client Routes", + "description": "Toggle label: enable client routes." + }, + "settings.network.clientRoutes.help": { + "message": "Accept routes from other peers to reach their networks.", + "description": "Helper text for client routes (accept routes from other peers)." + }, + "settings.network.serverRoutes.label": { + "message": "Enable Server Routes", + "description": "Toggle label: enable server routes." + }, + "settings.network.serverRoutes.help": { + "message": "Advertise this host's local routes to other peers.", + "description": "Helper text for server routes (advertise this host's local routes to other peers)." + }, + "settings.network.ipv6.label": { + "message": "Enable IPv6", + "description": "Toggle label: enable IPv6. 'IPv6' — keep as-is." + }, + "settings.network.ipv6.help": { + "message": "Use IPv6 addressing for the NetBird overlay network.", + "description": "Helper text for the IPv6 toggle." + }, + "settings.security.section.firewall": { + "message": "Firewall", + "description": "Section heading: Firewall." + }, + "settings.security.section.encryption": { + "message": "Encryption", + "description": "Section heading: Encryption." + }, + "settings.security.blockInbound.label": { + "message": "Block Inbound Traffic", + "description": "Toggle label: block inbound traffic." + }, + "settings.security.blockInbound.help": { + "message": "Reject unsolicited connections from peers to this device and any networks it routes. Outbound traffic is unaffected.", + "description": "Helper text for blocking inbound traffic." + }, + "settings.security.blockLan.label": { + "message": "Block LAN Access", + "description": "Toggle label: block LAN access. 'LAN' — keep acronym." + }, + "settings.security.blockLan.help": { + "message": "Prevent peers from reaching your local network or its devices when this device routes their traffic.", + "description": "Helper text for blocking LAN access." + }, + "settings.security.rosenpass.label": { + "message": "Enable Quantum-Resistance", + "description": "Toggle label: enable quantum-resistance." + }, + "settings.security.rosenpass.help": { + "message": "Add a post-quantum key exchange via Rosenpass on top of WireGuard®.", + "description": "Helper text: adds a post-quantum key exchange via Rosenpass on top of WireGuard®. 'Rosenpass' and 'WireGuard®' are product names — do not translate; keep the ® symbol." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Enable Permissive Mode", + "description": "Toggle label: enable permissive mode (for quantum-resistance)." + }, + "settings.security.rosenpassPermissive.help": { + "message": "Allow connections to peers without quantum-resistance support.", + "description": "Helper text for permissive mode (allow peers without quantum-resistance support)." + }, + "settings.ssh.section.server": { + "message": "Server", + "description": "Section heading: Server (SSH settings)." + }, + "settings.ssh.section.capabilities": { + "message": "Capabilities", + "description": "Section heading: Capabilities (SSH features)." + }, + "settings.ssh.section.authentication": { + "message": "Authentication", + "description": "Section heading: Authentication (SSH)." + }, + "settings.ssh.server.label": { + "message": "Enable SSH Server", + "description": "Toggle label: enable the SSH server." + }, + "settings.ssh.server.help": { + "message": "Run the NetBird SSH server on this host so other peers can connect to it.", + "description": "Helper text for the SSH server toggle." + }, + "settings.ssh.root.label": { + "message": "Allow Root Login", + "description": "Toggle label: allow root login over SSH. 'root' is the Unix superuser account — keep as-is." + }, + "settings.ssh.root.help": { + "message": "Let peers sign in as the root user. Disable to require a non-privileged account.", + "description": "Helper text for allowing root login." + }, + "settings.ssh.sftp.label": { + "message": "Allow SFTP", + "description": "Toggle label: allow SFTP. 'SFTP' — keep acronym." + }, + "settings.ssh.sftp.help": { + "message": "Transfer files securely using native SFTP or SCP clients.", + "description": "Helper text about SFTP/SCP file transfer. Keep the acronyms." + }, + "settings.ssh.localForward.label": { + "message": "Local Port Forwarding", + "description": "Toggle label: local port forwarding." + }, + "settings.ssh.localForward.help": { + "message": "Let connecting peers tunnel local ports to services reachable from this host.", + "description": "Helper text for local port forwarding." + }, + "settings.ssh.remoteForward.label": { + "message": "Remote Port Forwarding", + "description": "Toggle label: remote port forwarding." + }, + "settings.ssh.remoteForward.help": { + "message": "Let connecting peers expose ports on this host back to their own machine.", + "description": "Helper text for remote port forwarding." + }, + "settings.ssh.jwt.label": { + "message": "Enable JWT Authentication", + "description": "Toggle label: enable JWT authentication. 'JWT' — keep acronym." + }, + "settings.ssh.jwt.help": { + "message": "Verify each SSH session against your IdP for user identity and audit. Disable to rely on network ACL policies only, useful when no IdP is available.", + "description": "Helper text for JWT auth. 'IdP' (identity provider) and 'ACL' are acronyms — keep them." + }, + "settings.ssh.jwtTtl.label": { + "message": "JWT Cache TTL", + "description": "Label for the JWT cache time-to-live field. 'JWT' and 'TTL' — keep acronyms." + }, + "settings.ssh.jwtTtl.help": { + "message": "How long this client caches a JWT before prompting again on outgoing SSH connections. Set to 0 to disable caching and authenticate on every connection.", + "description": "Helper text for the JWT cache TTL; mentions setting 0 to disable caching." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "Second(s)", + "description": "Unit suffix shown after the JWT TTL number field. The '(s)' marks an optional plural." + }, + "settings.advanced.section.interface": { + "message": "Interface", + "description": "Section heading: Interface (network-interface settings)." + }, + "settings.advanced.section.security": { + "message": "Security", + "description": "Section heading: Security (advanced)." + }, + "settings.advanced.interfaceName.label": { + "message": "Name", + "description": "Field label for the WireGuard interface name." + }, + "settings.advanced.interfaceName.error": { + "message": "Use 1-15 letters, digits, dots, hyphens, or underscores.", + "description": "Validation message for the interface name (allowed characters)." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Must start with \"utun\" followed by a number (e.g. utun100).", + "description": "Validation message specific to macOS, where the name must start with 'utun' followed by a number. Keep 'utun' and the example." + }, + "settings.advanced.port.label": { + "message": "Port", + "description": "Field label: Port." + }, + "settings.advanced.port.error": { + "message": "Enter a port between {min} and {max}.", + "description": "Validation message for the port range. {min} and {max} are numbers; keep them." + }, + "settings.advanced.port.help": { + "message": "If set to 0, a random free port will be used.", + "description": "Helper text: 0 means a random free port is used." + }, + "settings.advanced.mtu.label": { + "message": "MTU", + "description": "Field label: MTU. 'MTU' — keep acronym." + }, + "settings.advanced.mtu.error": { + "message": "Enter an MTU value between {min} and {max}.", + "description": "Validation message for the MTU range. {min} and {max} are numbers; keep them." + }, + "settings.advanced.psk.label": { + "message": "Pre-shared Key", + "description": "Field label: Pre-shared Key." + }, + "settings.advanced.psk.help": { + "message": "Optional WireGuard PSK for extra symmetric encryption. Not the same as a NetBird Setup Key. You will only communicate with peers that use the same pre-shared key.", + "description": "Helper text for the WireGuard PSK. 'WireGuard', 'PSK', and 'NetBird Setup Key' are product/technical terms — keep them." + }, + "settings.troubleshooting.section.title": { + "message": "Debug bundle", + "description": "Section heading: Debug bundle." + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonymize Sensitive Information", + "description": "Label for the anonymization level dropdown (None, Default, Strict)." + }, + "settings.troubleshooting.anonymize.help": { + "message": "Hides IP addresses, domains, and other sensitive values.", + "description": "Helper text under the anonymization dropdown. The level details live in the info tooltip." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Default keeps internal IPv4 addresses and peer names readable for support. Strict additionally anonymizes private (RFC 1918), CGNAT, and link-local IP addresses, peer names, and WireGuard public keys. Recurring values map to the same placeholder, so peers stay distinguishable. Use Strict when sharing the bundle outside your organization.", + "description": "Info tooltip explaining the anonymization levels. 'RFC 1918', 'CGNAT', 'link-local', and 'WireGuard' are technical terms — keep them." + }, + "settings.troubleshooting.anonymize.none": { + "message": "None", + "description": "Dropdown option: no anonymization." + }, + "settings.troubleshooting.anonymize.default": { + "message": "Default", + "description": "Dropdown option: default anonymization level." + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Strict", + "description": "Dropdown option: strict anonymization level." + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Include System Information", + "description": "Toggle label: include system information in the bundle." + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Include OS, kernel, network interfaces, and routing tables.", + "description": "Helper text listing the system info included (OS, kernel, interfaces, routing tables)." + }, + "settings.troubleshooting.upload.label": { + "message": "Upload Bundle to NetBird Servers", + "description": "Toggle label: upload the bundle to NetBird servers." + }, + "settings.troubleshooting.upload.help": { + "message": "Returns an upload key to share with NetBird support.", + "description": "Helper text for uploading the bundle." + }, + "settings.troubleshooting.trace.label": { + "message": "Enable Trace Logs", + "description": "Toggle label: raise daemon log level to TRACE while the bundle is built. 'TRACE' is a log level." + }, + "settings.troubleshooting.trace.help": { + "message": "Raises the log level to TRACE and restores it after.", + "description": "Helper text for the trace toggle. 'TRACE' is a log level — keep as-is." + }, + "settings.troubleshooting.capture.label": { + "message": "Capture Session", + "description": "Toggle label: open a capture session — reconnect NetBird, wait a duration, optionally record packets." + }, + "settings.troubleshooting.capture.help": { + "message": "Reconnects and waits so you can reproduce the issue.", + "description": "Helper text for the master Capture Session toggle." + }, + "settings.troubleshooting.packets.label": { + "message": "Capture Network Packets", + "description": "Toggle label: capture packets to a .pcap during the capture session." + }, + "settings.troubleshooting.packets.help": { + "message": "Saves a .pcap of network traffic during the capture window.", + "description": "Helper text for the packet recording toggle. '.pcap' is a file extension — keep it." + }, + "settings.troubleshooting.duration.label": { + "message": "Capture Duration", + "description": "Label for the trace-capture duration field." + }, + "settings.troubleshooting.duration.help": { + "message": "How long the capture session runs.", + "description": "Helper text for the capture duration." + }, + "settings.troubleshooting.duration.suffix": { + "message": "Minute(s)", + "description": "Unit suffix after the duration field. The '(s)' marks an optional plural." + }, + "settings.troubleshooting.create": { + "message": "Create Bundle", + "description": "Button to create the debug bundle. Keep short." + }, + "settings.troubleshooting.progress.description": { + "message": "Collecting logs, system details, and connection state. This usually takes a moment. You can keep using NetBird or close Settings while it finishes.", + "description": "Status text shown while the debug bundle is being collected; reassures the user they can keep using NetBird or close Settings meanwhile." + }, + "settings.troubleshooting.cancelling": { + "message": "Canceling…", + "description": "Status shown while cancelling bundle creation. Ends with an ellipsis." + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Debug bundle successfully uploaded!", + "description": "Success title after the bundle was uploaded." + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Bundle saved", + "description": "Title shown when the bundle was saved locally (not uploaded)." + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Share the upload key below with NetBird support. A local copy was also saved on your device.", + "description": "Body after upload, telling the user to share the upload key with support. '' wraps the inline link to the NetBird support docs — keep the tags exactly and wrap the phrase that means 'NetBird support'." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Your debug bundle has been saved locally.", + "description": "Body shown when the bundle was only saved locally." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copy Key", + "description": "Button to copy the upload key. Keep short." + }, + "settings.troubleshooting.done.openFolder": { + "message": "Open Folder", + "description": "Button to open the folder containing the bundle. Keep short." + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Open file location", + "description": "Button to reveal the bundle file in the OS file manager." + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Upload failed: {reason} The bundle is still saved locally.", + "description": "Shown when upload failed but the bundle was saved locally. {reason} is the server error text; keep it." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Upload failed. The bundle is still saved locally.", + "description": "Shown when upload failed (no specific reason) but the bundle was saved locally." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconnecting NetBird…", + "description": "Progress stage: reconnecting NetBird. Ends with an ellipsis." + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturing debug logs", + "description": "Progress stage: capturing logs. {elapsed} and {total} are time values (e.g. 0:30 / 2:00); keep both." + }, + "settings.troubleshooting.stage.bundling": { + "message": "Generating debug bundle…", + "description": "Progress stage: generating the debug bundle. Ends with an ellipsis." + }, + "settings.troubleshooting.stage.uploading": { + "message": "Uploading to NetBird…", + "description": "Progress stage: uploading to NetBird. Ends with an ellipsis." + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Canceling…", + "description": "Progress stage: cancelling. Ends with an ellipsis." + }, + "settings.about.client": { + "message": "NetBird Client v{version}", + "description": "About tab: client product name and version. {version} is a version number; keep it. 'NetBird Client' — keep brand." + }, + "settings.about.clientName": { + "message": "NetBird Client", + "description": "About tab: the client product name shown when no version is available. Brand — do not translate." + }, + "settings.about.development": { + "message": "[Development]", + "description": "Badge shown next to the version on development builds. Keep the square brackets." + }, + "settings.about.gui": { + "message": "GUI v{version}", + "description": "About tab: UI component name and version. {version} is a version number; keep it. 'GUI' = the graphical app." + }, + "settings.about.guiName": { + "message": "GUI", + "description": "About tab: the UI component name shown without a version. 'GUI' = the graphical app." + }, + "settings.about.copyright": { + "message": "© {year} NetBird. All Rights Reserved.", + "description": "Copyright line. {year} is the current year; keep it. 'NetBird' — brand." + }, + "settings.about.links.imprint": { + "message": "Imprint", + "description": "Footer link: Imprint (legal notice / Impressum)." + }, + "settings.about.links.privacy": { + "message": "Privacy", + "description": "Footer link: Privacy policy." + }, + "settings.about.links.cla": { + "message": "CLA", + "description": "Footer link: CLA (Contributor License Agreement). Acronym — keep as-is." + }, + "settings.about.links.terms": { + "message": "Terms of Service", + "description": "Footer link: Terms of Service." + }, + "settings.about.community.github": { + "message": "GitHub", + "description": "Community link: GitHub. Brand — do not translate." + }, + "settings.about.community.slack": { + "message": "Slack", + "description": "Community link: Slack. Brand — do not translate." + }, + "settings.about.community.forum": { + "message": "Forum", + "description": "Community link: Forum." + }, + "settings.about.community.documentation": { + "message": "Documentation", + "description": "Community link: Documentation." + }, + "settings.about.community.feedback": { + "message": "Feedback", + "description": "Community link: Feedback." + }, + "update.banner.message": { + "message": "NetBird {version} is ready to install.", + "description": "Update banner text when a new version is ready to install. {version} is the version number; keep it." + }, + "update.banner.later": { + "message": "Later", + "description": "Banner button to postpone the update. Keep short." + }, + "update.banner.installNow": { + "message": "Install now", + "description": "Banner button to install the update now. Keep short." + }, + "update.card.versionAvailableDownload": { + "message": "Version {version} is available for download.", + "description": "Update card text when a version is available to download. {version} is the version number; keep it." + }, + "update.card.versionAvailableInstall": { + "message": "Version {version} is available for install.", + "description": "Update card text when a version is available to install. {version} is the version number; keep it." + }, + "update.card.whatsNew": { + "message": "What's new?", + "description": "Link / label that opens the release notes." + }, + "update.card.installNow": { + "message": "Install Now", + "description": "Update card button: install now. Keep short." + }, + "update.card.getInstaller": { + "message": "Download", + "description": "Update card button: download the installer." + }, + "update.card.autoCheckInterval": { + "message": "NetBird checks for updates in the background.", + "description": "Note that NetBird checks for updates in the background." + }, + "update.card.changelog": { + "message": "Changelog", + "description": "Link / label: Changelog." + }, + "update.card.onLatestVersion": { + "message": "You're on the latest version", + "description": "Shown when the client is already up to date." + }, + "update.header.tooltip": { + "message": "Update Available", + "description": "Tooltip on the header update badge." + }, + "update.overlay.updatingVersion": { + "message": "Updating NetBird to v{version}", + "description": "Heading in the install-progress window while updating to a specific version. {version} is the version number; keep it. The 'v' prefix is part of the version display." + }, + "update.overlay.updating": { + "message": "Updating NetBird", + "description": "Heading in the install-progress window while updating when the target version isn't known." + }, + "update.overlay.description": { + "message": "A newer version is available and is being installed. NetBird will restart automatically once the update is finished.", + "description": "Install-progress window body explaining NetBird will restart automatically after the update." + }, + "update.overlay.error.timeoutTitle": { + "message": "Update Is Taking Too Long", + "description": "Install-progress window error title: the update took too long." + }, + "update.overlay.error.timeoutDescription": { + "message": "Installing {target} took too long and didn't finish.", + "description": "Install-progress window error body for a timeout. {target} is the target-version label (see update.overlay.error.targetVersion / targetFallback); keep it." + }, + "update.overlay.error.canceledTitle": { + "message": "Update Was Stopped", + "description": "Install-progress window error title: the update was stopped / canceled." + }, + "update.overlay.error.canceledDescription": { + "message": "The update to {target} was canceled before it finished.", + "description": "Install-progress window error body for a canceled update. {target} is the target-version label; keep it." + }, + "update.overlay.error.failTitle": { + "message": "Couldn't Install the Update", + "description": "Install-progress window error title: the update couldn't be installed." + }, + "update.overlay.error.failDescription": { + "message": "{target} couldn't be installed.", + "description": "Install-progress window error body for a failed install. {target} is the target-version label; keep it." + }, + "update.overlay.error.unknownMessage": { + "message": "unknown error", + "description": "Fallback text for an unspecified error, used inside other messages." + }, + "update.overlay.error.targetVersion": { + "message": "v{version}", + "description": "The target-version label substituted into {target}. {version} is a number; keep the 'v' prefix." + }, + "update.overlay.error.targetFallback": { + "message": "the new version", + "description": "Fallback target label used when the version isn't known, substituted into {target}." + }, + "update.error.loadStateTitle": { + "message": "Load Update State Failed", + "description": "Error-dialog title when loading update state fails." + }, + "update.error.triggerTitle": { + "message": "Start Update Failed", + "description": "Error-dialog title when starting the update fails." + }, + "update.page.versionLine": { + "message": "Updating client to: {version}.", + "description": "Install-progress window text naming the target version. {version} is the version number; keep it." + }, + "update.page.versionLineGeneric": { + "message": "Updating client.", + "description": "Install-progress text used when the target version isn't known." + }, + "update.page.outdated": { + "message": "Your client version is older than the auto-update version set in Management.", + "description": "Note that the client is older than the auto-update version set in Management. 'Management' = the NetBird management server / console." + }, + "update.page.status.running": { + "message": "Updating", + "description": "Install status: updating." + }, + "update.page.status.timeout": { + "message": "Update timed out. Please try again.", + "description": "Install status: timed out; asks the user to retry." + }, + "update.page.status.canceled": { + "message": "Update canceled.", + "description": "Install status: canceled." + }, + "update.page.status.failed": { + "message": "Update failed: {message}", + "description": "Install status: failed. {message} is the error detail; keep it." + }, + "update.page.status.unknownError": { + "message": "unknown update error", + "description": "Fallback text for an unknown update error, used inside update.page.status.failed." + }, + "update.page.failedTitle": { + "message": "Update Failed", + "description": "Heading shown when the install failed." + }, + "update.page.timeoutMessage": { + "message": "Update timed out.", + "description": "Message shown when the install timed out." + }, + "update.page.dontClose": { + "message": "Please don't close this window.", + "description": "Warning asking the user not to close the install window." + }, + "update.page.updating": { + "message": "Updating…", + "description": "Short status: updating. Ends with an ellipsis." + }, + "update.page.complete": { + "message": "Update complete", + "description": "Short status: update complete." + }, + "update.page.failed": { + "message": "Update failed", + "description": "Short status: update failed." + }, + "window.title.settings": { + "message": "Settings", + "description": "OS window-chrome title for the Settings window. The app prefixes it with 'NetBird - '." + }, + "window.title.signIn": { + "message": "Sign-in", + "description": "OS window-chrome title for the sign-in window." + }, + "window.title.sessionExpiration": { + "message": "Session Expiring", + "description": "OS window-chrome title for the session-expiration window." + }, + "window.title.updating": { + "message": "Updating", + "description": "OS window-chrome title for the update / install window." + }, + "window.title.welcome": { + "message": "Welcome to NetBird", + "description": "OS window-chrome title for the first-launch welcome window. 'NetBird' — brand." + }, + "window.title.error": { + "message": "Error", + "description": "OS window-chrome title for the error window." + }, + "welcome.title": { + "message": "Look for NetBird in your tray", + "description": "Heading on the first onboarding step, pointing the user to the tray icon. Shown on Windows and Linux; macOS uses welcome.titleMac." + }, + "welcome.titleMac": { + "message": "Look for NetBird in your menu bar", + "description": "Heading on the first onboarding step on macOS, pointing the user to the menu bar icon. Use your language's Apple term for the macOS menu bar." + }, + "welcome.description": { + "message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.", + "description": "Body of the first onboarding step explaining the tray icon. Shown on Windows and Linux; macOS uses welcome.descriptionMac." + }, + "welcome.descriptionMac": { + "message": "NetBird lives in your menu bar. Click the icon to connect, switch profiles, or open settings.", + "description": "Body of the first onboarding step on macOS explaining the menu bar icon. Use your language's Apple term for the macOS menu bar." + }, + "welcome.continue": { + "message": "Continue", + "description": "Primary button to advance the onboarding. Keep short." + }, + "welcome.back": { + "message": "Back", + "description": "Button to go back a step in onboarding. Keep short." + }, + "welcome.management.title": { + "message": "Set up NetBird", + "description": "Heading on the onboarding step for choosing a management server." + }, + "welcome.management.description": { + "message": "Click Continue to get started, or pick Self-hosted if you have your own NetBird server.", + "description": "Body of the management step; mentions choosing Self-hosted if the user runs their own server." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud", + "description": "Option title: NetBird Cloud. Brand — do not translate 'NetBird Cloud'." + }, + "welcome.management.cloud.description": { + "message": "Use our hosted service. No setup required.", + "description": "Option body for NetBird Cloud (hosted service, no setup required)." + }, + "welcome.management.selfHosted.title": { + "message": "Self-hosted", + "description": "Option title: Self-hosted." + }, + "welcome.management.selfHosted.description": { + "message": "Connect to your own management server.", + "description": "Option body for connecting to your own management server." + }, + "welcome.management.urlLabel": { + "message": "Management server URL", + "description": "Field label for the management-server URL." + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443", + "description": "Example URL placeholder. Sample URL — do not translate it." + }, + "welcome.management.urlInvalid": { + "message": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443", + "description": "Validation message for an invalid URL. The example URL stays as-is." + }, + "welcome.management.urlUnreachable": { + "message": "Couldn't reach this server. Check the URL or your network, then continue if you're sure it's correct.", + "description": "Soft warning when the server couldn't be reached during onboarding; the user may continue anyway." + }, + "welcome.management.checking": { + "message": "Checking…", + "description": "Status shown while checking the server URL. Ends with an ellipsis." + }, + "browserLogin.title": { + "message": "Complete login in your browser", + "description": "Heading telling the user to finish signing in via their browser." + }, + "browserLogin.notSeeing": { + "message": "We opened a browser tab so you can finish signing in. Not seeing it?", + "description": "Prompt asking whether the user doesn't see the opened browser tab." + }, + "browserLogin.tryAgain": { + "message": "Try again", + "description": "Button to re-open the browser sign-in page. Keep short." + }, + "browserLogin.openFailedTitle": { + "message": "Open Browser Failed", + "description": "Error-dialog title when the browser couldn't be opened." + }, + "sessionExpiration.title": { + "message": "Session expiring soon", + "description": "Heading warning the session will expire soon." + }, + "sessionExpiration.titleLater": { + "message": "Your session will expire", + "description": "Alternate, less-urgent heading: the session will expire." + }, + "sessionExpiration.description": { + "message": "This device will disconnect soon. Renew with a browser sign-in.", + "description": "Body warning the device will disconnect soon; renew via a browser sign-in." + }, + "sessionExpiration.descriptionLater": { + "message": "A browser sign-in keeps this device connected to your network.", + "description": "Body for the less-urgent variant; a browser sign-in keeps the device connected." + }, + "sessionExpiration.stay": { + "message": "Renew session", + "description": "Button to renew the session (keep connected). Keep short." + }, + "sessionExpiration.authenticate": { + "message": "Authenticate", + "description": "Button to authenticate / sign in. Keep short." + }, + "sessionExpiration.logout": { + "message": "Logout", + "description": "Button to log out. Keep short." + }, + "sessionExpiration.expired": { + "message": "Session expired", + "description": "Heading shown once the session has actually expired." + }, + "sessionExpiration.expiredDescription": { + "message": "Device disconnected. Authenticate with a browser sign-in to reconnect.", + "description": "Body shown after expiry; authenticate via a browser sign-in to reconnect." + }, + "sessionExpiration.close": { + "message": "Close", + "description": "Close button on the session window. Keep short." + }, + "sessionExpiration.extendFailedTitle": { + "message": "Extend Session Failed", + "description": "Error-dialog title when extending the session fails." + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Logout Failed", + "description": "Error-dialog title when logging out fails." + }, + "peers.search.placeholder": { + "message": "Search by name or IP", + "description": "Placeholder in the peers search field." + }, + "peers.filter.all": { + "message": "All", + "description": "Peers filter option: All. Keep short." + }, + "peers.filter.online": { + "message": "Online", + "description": "Peers filter option: Online. Keep short." + }, + "peers.filter.offline": { + "message": "Offline", + "description": "Peers filter option: Offline. Keep short." + }, + "peers.empty.title": { + "message": "No peers available", + "description": "Title of the empty state when no peers are available." + }, + "peers.empty.description": { + "message": "You either don't have any peers available or have no access to any of them.", + "description": "Body of the no-peers empty state." + }, + "peers.details.domain": { + "message": "Domain", + "description": "Peer detail label: Domain." + }, + "peers.details.netbirdIp": { + "message": "NetBird IP", + "description": "Peer detail label: NetBird IP address. 'NetBird' — brand; 'IP' — keep acronym." + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6", + "description": "Peer detail label: NetBird IPv6 address. Keep 'NetBird' and 'IPv6'." + }, + "peers.details.publicKey": { + "message": "Public key", + "description": "Peer detail label: WireGuard public key." + }, + "peers.details.connection": { + "message": "Connection", + "description": "Peer detail label: Connection." + }, + "peers.details.latency": { + "message": "Latency", + "description": "Peer detail label: Latency." + }, + "peers.details.lastHandshake": { + "message": "Last handshake", + "description": "Peer detail label: time of the last WireGuard handshake." + }, + "peers.details.statusSince": { + "message": "Last connection update", + "description": "Peer detail label: time since the last connection-status update." + }, + "peers.details.bytes": { + "message": "Bytes", + "description": "Peer detail label: Bytes (data transferred)." + }, + "peers.details.bytesSent": { + "message": "Sent", + "description": "Peer detail label: bytes sent." + }, + "peers.details.bytesReceived": { + "message": "Received", + "description": "Peer detail label: bytes received." + }, + "peers.details.localIce": { + "message": "Local ICE", + "description": "Peer detail label: local ICE candidate. 'ICE' — keep acronym." + }, + "peers.details.remoteIce": { + "message": "Remote ICE", + "description": "Peer detail label: remote ICE candidate. 'ICE' — keep acronym." + }, + "peers.details.never": { + "message": "Never", + "description": "Value shown when an event has never happened (e.g. last handshake = Never)." + }, + "peers.details.justNow": { + "message": "Just now", + "description": "Relative-time value meaning a moment ago." + }, + "peers.details.refresh": { + "message": "Refresh", + "description": "Button to refresh peer details. Keep short." + }, + "peers.status.connected": { + "message": "Connected", + "description": "Peer status: connected." + }, + "peers.status.connecting": { + "message": "Connecting", + "description": "Peer status: connecting." + }, + "peers.status.disconnected": { + "message": "Disconnected", + "description": "Peer status: disconnected." + }, + "peers.details.relayAddress": { + "message": "Relay", + "description": "Peer detail label: Relay (relay-server address)." + }, + "peers.details.networks": { + "message": "Resources", + "description": "Peer detail label for the peer's network resources. Labelled 'Resources' in the UI." + }, + "peers.details.relayed": { + "message": "Relayed", + "description": "Connection-type value: the connection is relayed (not direct). Technical term." + }, + "peers.details.p2p": { + "message": "P2P", + "description": "Connection-type value: peer-to-peer (direct). 'P2P' — keep as-is." + }, + "peers.details.rosenpass": { + "message": "Rosenpass enabled", + "description": "Peer detail indicating Rosenpass (quantum-resistance) is enabled. 'Rosenpass' — product name, do not translate." + }, + "networks.search.placeholder": { + "message": "Search by network or domain", + "description": "Placeholder in the resources search field." + }, + "networks.filter.all": { + "message": "All", + "description": "Resources filter: All. Keep short." + }, + "networks.filter.active": { + "message": "Active", + "description": "Resources filter: Active. Keep short." + }, + "networks.filter.overlapping": { + "message": "Overlapping", + "description": "Resources filter: Overlapping (overlapping IP ranges). Keep short." + }, + "networks.empty.title": { + "message": "No resources available", + "description": "Title of the empty state when no resources are available." + }, + "networks.empty.description": { + "message": "You either don't have any network resources available or have no access to any of them.", + "description": "Body of the no-resources empty state." + }, + "networks.selected": { + "message": "Selected", + "description": "Label / badge: the resource is selected (enabled)." + }, + "networks.unselected": { + "message": "Not selected", + "description": "Label / badge: the resource is not selected." + }, + "networks.ips.heading": { + "message": "Resolved IPs", + "description": "Heading for the list of a resource's resolved IP addresses." + }, + "networks.bulk.selectionCount": { + "message": "{selected} of {total} Active", + "description": "Header showing how many resources are active. {selected} and {total} are numbers; keep both." + }, + "networks.bulk.enableAll": { + "message": "Enable all", + "description": "Button to enable all resources. Keep short." + }, + "networks.bulk.disableAll": { + "message": "Disable all", + "description": "Button to disable all resources. Keep short." + }, + "exitNodes.search.placeholder": { + "message": "Search exit nodes", + "description": "Placeholder in the exit-nodes search field." + }, + "exitNodes.none": { + "message": "None", + "description": "Option meaning no exit node. Keep short." + }, + "exitNodes.empty.title": { + "message": "No exit nodes available", + "description": "Title of the empty state when no exit nodes are available." + }, + "exitNodes.empty.description": { + "message": "No exit nodes have been shared with this peer.", + "description": "Body of the no-exit-nodes empty state." + }, + "exitNodes.card.title": { + "message": "Exit Node", + "description": "Card heading: Exit Node." + }, + "exitNodes.card.statusActive": { + "message": "Active", + "description": "Exit node card status: Active." + }, + "exitNodes.card.statusInactive": { + "message": "Inactive", + "description": "Exit node card status: Inactive." + }, + "exitNodes.dropdown.noneTitle": { + "message": "None", + "description": "Dropdown option title: None (no exit node)." + }, + "exitNodes.dropdown.noneDescription": { + "message": "Direct connection without an exit node", + "description": "Dropdown option body for None: direct connection without an exit node." + }, + "quickActions.connect": { + "message": "Connect", + "description": "Quick-action button: Connect. Keep short." + }, + "quickActions.disconnect": { + "message": "Disconnect", + "description": "Quick-action button: Disconnect. Keep short." + }, + "daemon.unavailable.title": { + "message": "NetBird Service Is Not Running", + "description": "Title of the overlay shown when the NetBird background service isn't running." + }, + "daemon.unavailable.description": { + "message": "The app will reconnect automatically once the service is running.", + "description": "Body reassuring the user the app will reconnect once the service is running." + }, + "daemon.unavailable.docsLink": { + "message": "Documentation", + "description": "Documentation link on the daemon-unavailable overlay." + }, + "daemon.outdated.title": { + "message": "NetBird Client Is Outdated", + "description": "Title of the overlay shown when the NetBird client (daemon) is too old to drive this UI." + }, + "daemon.outdated.description": { + "message": "The new GUI isn't compatible with the older NetBird client. Update your client to use the new application.", + "description": "Body of the daemon-outdated overlay explaining that the GUI is newer than the client and the client must be updated." + }, + "daemon.outdated.download": { + "message": "Download Latest", + "description": "Button on the daemon-outdated overlay that opens the download page for the latest release." + }, + "error.jwt_clock_skew": { + "message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.", + "description": "Sign-in error shown to the user: the device clock is out of sync with the server." + }, + "error.jwt_expired": { + "message": "Your sign-in token has expired. Please sign in again.", + "description": "Sign-in error: the sign-in token expired; sign in again." + }, + "error.jwt_signature_invalid": { + "message": "Sign-in failed: the token signature is invalid. Please contact your administrator.", + "description": "Sign-in error: the token signature is invalid; contact the administrator." + }, + "error.session_expired": { + "message": "Your session has expired. Please sign in again.", + "description": "Error: the session expired; sign in again." + }, + "error.invalid_setup_key": { + "message": "The setup key is missing or invalid.", + "description": "Error: the setup key is missing or invalid. 'setup key' is a NetBird term." + }, + "error.permission_denied": { + "message": "Sign-in was rejected by the server.", + "description": "Error: the server rejected the sign-in." + }, + "error.daemon_unreachable": { + "message": "The NetBird daemon is not responding. Please check that the service is running.", + "description": "Error: the NetBird background service isn't responding. 'daemon' = the background service." + }, + "error.unknown": { + "message": "Operation failed.", + "description": "Generic fallback error message used when no specific error applies." + }, + "settings.ssh.privilege.hint": { + "message": "Requires {actor}. Run this instead:", + "description": "Help text under an SSH setting the user cannot change: it needs elevated privileges. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command." + }, + "settings.ssh.privilege.oneWay": { + "message": "You can switch this off, but switching it back on needs {actor}:", + "description": "Warning under an SSH setting an unprivileged user may disable but not re-enable. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "You can switch this on, but switching it back off needs {actor}:", + "description": "Warning under the SSH authentication setting, which an unprivileged user may re-enable but not disable again. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command." + } +} diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json new file mode 100644 index 000000000..6dc4ffd0b --- /dev/null +++ b/client/ui/i18n/locales/es/common.json @@ -0,0 +1,1363 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Desconectado" + }, + "tray.status.daemonUnavailable": { + "message": "No está en ejecución" + }, + "tray.status.error": { + "message": "Error" + }, + "tray.status.connected": { + "message": "Conectado" + }, + "tray.status.connecting": { + "message": "Conectando" + }, + "tray.status.needsLogin": { + "message": "Inicio de sesión requerido" + }, + "tray.status.loginFailed": { + "message": "Error al iniciar sesión" + }, + "tray.status.sessionExpired": { + "message": "Sesión expirada" + }, + "tray.session.expiresIn": { + "message": "La sesión expira en {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "menos de un minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minutos" + }, + "tray.session.unit.hour": { + "message": "1 hora" + }, + "tray.session.unit.hours": { + "message": "{count} horas" + }, + "tray.session.unit.day": { + "message": "1 día" + }, + "tray.session.unit.days": { + "message": "{count} días" + }, + "tray.menu.open": { + "message": "Abrir NetBird" + }, + "tray.menu.connect": { + "message": "Conectar" + }, + "tray.menu.disconnect": { + "message": "Desconectar" + }, + "tray.menu.exitNode": { + "message": "Nodo de salida" + }, + "tray.menu.networks": { + "message": "Recursos" + }, + "tray.menu.profiles": { + "message": "Perfiles" + }, + "tray.menu.manageProfiles": { + "message": "Gestionar perfiles" + }, + "tray.menu.settings": { + "message": "Configuración..." + }, + "tray.menu.debugBundle": { + "message": "Crear paquete de diagnóstico" + }, + "tray.menu.about": { + "message": "Ayuda y soporte" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentación" + }, + "tray.menu.troubleshoot": { + "message": "Solucionar problemas" + }, + "tray.menu.downloadLatest": { + "message": "Descargar la última versión" + }, + "tray.menu.installVersion": { + "message": "Instalar la versión {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Salir de NetBird" + }, + "notify.daemonOutdated.title": { + "message": "El servicio de NetBird está desactualizado" + }, + "notify.daemonOutdated.body": { + "message": "Actualice el servicio de NetBird para usar esta aplicación." + }, + "notify.update.title": { + "message": "Actualización de NetBird disponible" + }, + "notify.update.body": { + "message": "NetBird {version} está disponible." + }, + "notify.update.enforcedSuffix": { + "message": " Su administrador requiere esta actualización." + }, + "notify.error.title": { + "message": "Error" + }, + "notify.error.connect": { + "message": "Error al conectar" + }, + "notify.error.disconnect": { + "message": "Error al desconectar" + }, + "notify.error.switchProfile": { + "message": "Error al cambiar a {profile}" + }, + "notify.error.exitNode": { + "message": "Error al actualizar el nodo de salida {name}" + }, + "notify.sessionExpired.title": { + "message": "Sesión de NetBird expirada" + }, + "notify.sessionExpired.body": { + "message": "Su sesión de NetBird ha expirado. Inicie sesión de nuevo." + }, + "notify.sessionWarning.title": { + "message": "La sesión expira pronto" + }, + "notify.sessionWarning.body": { + "message": "Su sesión de NetBird expira en {remaining}. Haga clic en Renovar ahora para renovarla." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Su sesión de NetBird está a punto de expirar. Haga clic en Renovar ahora para renovarla." + }, + "notify.sessionWarning.extend": { + "message": "Renovar ahora" + }, + "notify.sessionWarning.dismiss": { + "message": "Descartar" + }, + "notify.sessionWarning.failed": { + "message": "Error al renovar la sesión de NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sesión de NetBird renovada" + }, + "notify.sessionWarning.successBody": { + "message": "Su sesión se ha renovado." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Plazo de sesión rechazado" + }, + "notify.sessionDeadlineRejected.body": { + "message": "El servidor envió un plazo de sesión no válido. Inicie sesión de nuevo." + }, + "notify.mdm.policyApplied.title": { + "message": "Configuración de NetBird actualizada" + }, + "notify.mdm.policyApplied.body": { + "message": "Su configuración de NetBird fue actualizada por su política de TI." + }, + "common.cancel": { + "message": "Cancelar" + }, + "common.save": { + "message": "Guardar" + }, + "common.saveChanges": { + "message": "Guardar cambios" + }, + "common.saving": { + "message": "Guardando…" + }, + "common.close": { + "message": "Cerrar" + }, + "common.copy": { + "message": "Copiar" + }, + "common.togglePasswordVisibility": { + "message": "Mostrar u ocultar la contraseña" + }, + "common.increase": { + "message": "Aumentar" + }, + "common.decrease": { + "message": "Disminuir" + }, + "common.delete": { + "message": "Eliminar" + }, + "common.create": { + "message": "Crear" + }, + "common.add": { + "message": "Añadir" + }, + "common.remove": { + "message": "Quitar" + }, + "common.refresh": { + "message": "Actualizar" + }, + "common.loading": { + "message": "Cargando…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "No se encontraron resultados" + }, + "common.noResults.description": { + "message": "No se encontraron resultados. Pruebe con otro término de búsqueda o cambie los filtros." + }, + "notConnected.title": { + "message": "Desconectado" + }, + "notConnected.description": { + "message": "Conéctese primero a NetBird para ver información detallada sobre sus peers, recursos de red y nodos de salida." + }, + "connect.status.disconnected": { + "message": "Desconectado" + }, + "connect.status.connecting": { + "message": "Conectando..." + }, + "connect.status.connected": { + "message": "Conectado" + }, + "connect.status.disconnecting": { + "message": "Desconectando..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon no disponible" + }, + "connect.status.loginRequired": { + "message": "Inicio de sesión requerido" + }, + "connect.error.loginTitle": { + "message": "Error al iniciar sesión" + }, + "connect.error.connectTitle": { + "message": "Error al conectar" + }, + "connect.error.disconnectTitle": { + "message": "Error al desconectar" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} de {total} conectados" + }, + "nav.resources.title": { + "message": "Recursos" + }, + "nav.resources.description": { + "message": "{active} de {total} activos" + }, + "nav.exitNode.title": { + "message": "Nodos de salida" + }, + "nav.exitNode.none": { + "message": "Inactivo" + }, + "nav.exitNode.using": { + "message": "Vía {name}" + }, + "header.openSettings": { + "message": "Abrir configuración" + }, + "header.togglePanel": { + "message": "Mostrar u ocultar el panel lateral" + }, + "profile.selector.loading": { + "message": "Cargando..." + }, + "profile.selector.noProfile": { + "message": "Sin perfil" + }, + "profile.selector.searchPlaceholder": { + "message": "Buscar perfil por nombre..." + }, + "profile.selector.emptyTitle": { + "message": "No se encontraron perfiles" + }, + "profile.selector.emptyDescription": { + "message": "Pruebe con otro término de búsqueda o cree un perfil nuevo." + }, + "profile.selector.newProfile": { + "message": "Nuevo perfil" + }, + "profile.selector.moreOptions": { + "message": "Más opciones" + }, + "profile.selector.deregister": { + "message": "Anular registro" + }, + "profile.selector.delete": { + "message": "Eliminar" + }, + "profile.selector.switchTo": { + "message": "Cambiar a este perfil" + }, + "profile.selector.edit": { + "message": "Editar" + }, + "profile.edit.title": { + "message": "Editar perfil" + }, + "profile.edit.submit": { + "message": "Guardar cambios" + }, + "profile.dialog.title": { + "message": "Introduzca el nombre del perfil" + }, + "profile.dialog.nameLabel": { + "message": "Nombre del perfil" + }, + "profile.dialog.description": { + "message": "Asigne un nombre fácil de identificar a su perfil." + }, + "profile.dialog.placeholder": { + "message": "p. ej. trabajo" + }, + "profile.dialog.submit": { + "message": "Añadir perfil" + }, + "profile.dialog.required": { + "message": "Introduzca un nombre de perfil, p. ej. trabajo, casa" + }, + "profile.dialog.managementHelp": { + "message": "Use NetBird Cloud o su propio servidor." + }, + "profile.dialog.urlUnreachable": { + "message": "No se pudo contactar con este servidor. Compruebe la URL o añada el perfil de todos modos si está seguro de que es correcta." + }, + "header.menu.settings": { + "message": "Configuración..." + }, + "header.menu.defaultView": { + "message": "Vista predeterminada" + }, + "header.menu.advancedView": { + "message": "Vista avanzada" + }, + "header.menu.updateAvailable": { + "message": "Actualización disponible" + }, + "header.menu.open": { + "message": "Abrir menú" + }, + "header.profile.switch": { + "message": "Cambiar de perfil" + }, + "connect.toggle.label": { + "message": "Conmutar conexión NetBird" + }, + "connect.localIp.label": { + "message": "Direcciones IP locales" + }, + "common.search": { + "message": "Buscar" + }, + "common.filter": { + "message": "Filtrar" + }, + "exitNodes.dropdown.trigger": { + "message": "Seleccionar nodo de salida" + }, + "peers.row.label": { + "message": "Abrir detalles de {name}, {status}" + }, + "peers.dialog.title": { + "message": "Detalles del par" + }, + "networks.row.toggle": { + "message": "Conmutar {name}" + }, + "networks.bulk.label": { + "message": "Conmutar todos los recursos visibles" + }, + "settings.nav.label": { + "message": "Secciones de configuración" + }, + "profile.switch.title": { + "message": "¿Cambiar el perfil a «{name}»?" + }, + "profile.switch.message": { + "message": "¿Seguro que desea cambiar de perfil?\nSu perfil actual se desconectará." + }, + "profile.switch.confirm": { + "message": "Confirmar" + }, + "profile.deregister.title": { + "message": "¿Anular el registro del perfil «{name}»?" + }, + "profile.deregister.message": { + "message": "¿Seguro que desea anular el registro de este perfil?\nDeberá iniciar sesión de nuevo para usarlo." + }, + "profile.deregister.confirm": { + "message": "Anular registro" + }, + "profile.delete.title": { + "message": "¿Eliminar el perfil «{name}»?" + }, + "profile.delete.message": { + "message": "¿Seguro que desea eliminar este perfil?\nEsta acción no se puede deshacer." + }, + "profile.delete.disabledActive": { + "message": "Los perfiles activos no se pueden eliminar. Cambie a otro antes de eliminar este perfil." + }, + "profile.delete.disabledDefault": { + "message": "El perfil predeterminado no se puede eliminar." + }, + "profile.error.switchTitle": { + "message": "Error al cambiar de perfil" + }, + "profile.error.deregisterTitle": { + "message": "Error al anular el registro del perfil" + }, + "profile.error.deleteTitle": { + "message": "Error al eliminar el perfil" + }, + "profile.error.createTitle": { + "message": "Error al crear el perfil" + }, + "profile.error.editTitle": { + "message": "Error al editar el perfil" + }, + "profile.error.loadTitle": { + "message": "Error al cargar los perfiles" + }, + "profile.dropdown.activeProfile": { + "message": "Perfil activo" + }, + "profile.dropdown.switchProfile": { + "message": "Cambiar de perfil" + }, + "profile.dropdown.noEmail": { + "message": "Otro" + }, + "profile.dropdown.addProfile": { + "message": "Añadir perfil" + }, + "profile.dropdown.manageProfiles": { + "message": "Gestionar perfiles" + }, + "profile.dropdown.settings": { + "message": "Configuración" + }, + "settings.profiles.section.profiles": { + "message": "Perfiles" + }, + "settings.profiles.intro": { + "message": "Mantenga identidades de NetBird independientes en paralelo, por ejemplo cuentas de trabajo y personales, o distintos servidores de gestión. Añada, anule el registro o elimine perfiles a continuación." + }, + "settings.profiles.addProfile": { + "message": "Añadir perfil" + }, + "settings.profiles.active": { + "message": "Activo" + }, + "settings.profiles.emptyTitle": { + "message": "Sin perfiles" + }, + "settings.profiles.emptyDescription": { + "message": "Cree un perfil para conectarse a un servidor de gestión de NetBird." + }, + "settings.error.loadTitle": { + "message": "Error al cargar la configuración" + }, + "settings.error.saveTitle": { + "message": "Error al guardar la configuración" + }, + "settings.error.debugBundleTitle": { + "message": "Error en el paquete de diagnóstico" + }, + "settings.tabs.general": { + "message": "General" + }, + "settings.tabs.network": { + "message": "Red" + }, + "settings.tabs.security": { + "message": "Seguridad" + }, + "settings.tabs.profiles": { + "message": "Perfiles" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avanzado" + }, + "settings.tabs.troubleshooting": { + "message": "Solución de problemas" + }, + "settings.tabs.about": { + "message": "Acerca de" + }, + "settings.tabs.updateAvailable": { + "message": "Actualización disponible" + }, + "settings.general.section.general": { + "message": "General" + }, + "settings.general.section.connection": { + "message": "Conexión" + }, + "settings.general.connectOnStartup.label": { + "message": "Conectar al iniciar" + }, + "settings.general.connectOnStartup.help": { + "message": "Establece una conexión automáticamente cuando se inicia el servicio." + }, + "settings.general.notifications.label": { + "message": "Notificaciones de escritorio" + }, + "settings.general.notifications.help": { + "message": "Muestra notificaciones de escritorio para nuevas actualizaciones y eventos de conexión." + }, + "settings.general.autostart.label": { + "message": "Iniciar la interfaz de NetBird al iniciar sesión" + }, + "settings.general.autostart.help": { + "message": "Inicia la interfaz de NetBird automáticamente al iniciar sesión. Esto afecta solo a la interfaz gráfica, no al servicio en segundo plano." + }, + "settings.general.autostart.errorTitle": { + "message": "Error al cambiar el inicio automático" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Permanecer conectado al salir", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Idioma de la interfaz" + }, + "settings.general.language.help": { + "message": "Elija el idioma de la interfaz de NetBird." + }, + "settings.general.language.search": { + "message": "Buscar idioma…" + }, + "settings.general.language.empty": { + "message": "Ningún idioma coincide." + }, + "settings.general.management.label": { + "message": "Servidor de gestión" + }, + "settings.general.management.help": { + "message": "Conéctese a NetBird Cloud o a su propio servidor de gestión autoalojado. Los cambios reconectarán el cliente." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Autoalojado" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Introduzca una URL válida, p. ej., https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "No se pudo contactar con este servidor. Compruebe la URL o guarde de todos modos si está seguro de que es correcta." + }, + "settings.general.management.switchCloudTitle": { + "message": "¿Cambiar a NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Esto desconecta su servidor autoalojado.\nEs posible que deba iniciar sesión de nuevo." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Cambiar a Cloud" + }, + "settings.network.section.connectivity": { + "message": "Conectividad" + }, + "settings.network.section.routingDns": { + "message": "Enrutamiento y DNS" + }, + "settings.network.monitor.label": { + "message": "Reconectar al cambiar de red" + }, + "settings.network.monitor.help": { + "message": "Monitoriza la red y reconecta automáticamente ante cambios como cambios de Wi-Fi, cambios de Ethernet o la reanudación tras la suspensión." + }, + "settings.network.dns.label": { + "message": "Habilitar DNS" + }, + "settings.network.dns.help": { + "message": "Aplica la configuración de DNS gestionada por NetBird al resolutor del host." + }, + "settings.network.clientRoutes.label": { + "message": "Habilitar rutas de cliente" + }, + "settings.network.clientRoutes.help": { + "message": "Acepta rutas de otros peers para alcanzar sus redes." + }, + "settings.network.serverRoutes.label": { + "message": "Habilitar rutas de servidor" + }, + "settings.network.serverRoutes.help": { + "message": "Anuncia las rutas locales de este host a otros peers." + }, + "settings.network.ipv6.label": { + "message": "Habilitar IPv6" + }, + "settings.network.ipv6.help": { + "message": "Usa direccionamiento IPv6 para la red superpuesta de NetBird." + }, + "settings.security.section.firewall": { + "message": "Cortafuegos" + }, + "settings.security.section.encryption": { + "message": "Cifrado" + }, + "settings.security.blockInbound.label": { + "message": "Bloquear tráfico entrante" + }, + "settings.security.blockInbound.help": { + "message": "Rechaza conexiones no solicitadas de peers hacia este dispositivo y cualquier red que enrute. El tráfico saliente no se ve afectado." + }, + "settings.security.blockLan.label": { + "message": "Bloquear acceso a la LAN" + }, + "settings.security.blockLan.help": { + "message": "Impide que los peers alcancen su red local o sus dispositivos cuando este dispositivo enruta su tráfico." + }, + "settings.security.rosenpass.label": { + "message": "Habilitar resistencia cuántica" + }, + "settings.security.rosenpass.help": { + "message": "Añade un intercambio de claves poscuántico mediante Rosenpass sobre WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Habilitar modo permisivo" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Permite conexiones con peers sin compatibilidad con resistencia cuántica." + }, + "settings.ssh.section.server": { + "message": "Servidor" + }, + "settings.ssh.section.capabilities": { + "message": "Capacidades" + }, + "settings.ssh.section.authentication": { + "message": "Autenticación" + }, + "settings.ssh.server.label": { + "message": "Habilitar el servidor SSH" + }, + "settings.ssh.server.help": { + "message": "Ejecuta el servidor SSH de NetBird en este host para que otros peers puedan conectarse a él." + }, + "settings.ssh.root.label": { + "message": "Permitir inicio de sesión como root" + }, + "settings.ssh.root.help": { + "message": "Permite que los peers inicien sesión como usuario root. Desactívelo para exigir una cuenta sin privilegios." + }, + "settings.ssh.sftp.label": { + "message": "Permitir SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transfiere archivos de forma segura mediante clientes SFTP o SCP nativos." + }, + "settings.ssh.localForward.label": { + "message": "Reenvío de puertos local" + }, + "settings.ssh.localForward.help": { + "message": "Permite que los peers conectados tunelicen puertos locales hacia servicios accesibles desde este host." + }, + "settings.ssh.remoteForward.label": { + "message": "Reenvío de puertos remoto" + }, + "settings.ssh.remoteForward.help": { + "message": "Permite que los peers conectados expongan puertos de este host de vuelta a su propia máquina." + }, + "settings.ssh.jwt.label": { + "message": "Habilitar autenticación JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verifica cada sesión SSH contra su IdP para la identidad del usuario y la auditoría. Desactívelo para basarse solo en las políticas de ACL de red, útil cuando no hay ningún IdP disponible." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL de la caché JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Cuánto tiempo almacena en caché este cliente un JWT antes de volver a solicitarlo en conexiones SSH salientes. Establézcalo en 0 para desactivar la caché y autenticar en cada conexión." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "s" + }, + "settings.advanced.section.interface": { + "message": "Interfaz" + }, + "settings.advanced.section.security": { + "message": "Seguridad" + }, + "settings.advanced.interfaceName.label": { + "message": "Nombre" + }, + "settings.advanced.interfaceName.error": { + "message": "Use de 1 a 15 letras, dígitos, puntos, guiones o guiones bajos." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Debe empezar por \"utun\" seguido de un número (p. ej. utun100)." + }, + "settings.advanced.port.label": { + "message": "Puerto" + }, + "settings.advanced.port.error": { + "message": "Introduzca un puerto entre {min} y {max}." + }, + "settings.advanced.port.help": { + "message": "Si se establece en 0, se usará un puerto libre aleatorio." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Introduzca un valor de MTU entre {min} y {max}." + }, + "settings.advanced.psk.label": { + "message": "Clave precompartida" + }, + "settings.advanced.psk.help": { + "message": "PSK de WireGuard opcional para cifrado simétrico adicional. No es lo mismo que una clave de instalación de NetBird. Solo se comunicará con peers que usen la misma clave precompartida." + }, + "settings.troubleshooting.section.title": { + "message": "Paquete de diagnóstico" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizar información sensible" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Oculta direcciones IP, dominios y otros valores sensibles." + }, + "settings.troubleshooting.anonymize.info": { + "message": "El modo predeterminado mantiene legibles las direcciones IPv4 internas y los nombres de los peers para el soporte. El modo estricto anonimiza además las direcciones IP privadas (RFC 1918), CGNAT y de enlace local, los nombres de los peers y las claves públicas de WireGuard. Los valores recurrentes se asignan al mismo marcador de posición, por lo que los peers siguen siendo distinguibles. Use el modo estricto cuando comparta el paquete de diagnóstico fuera de su organización." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Ninguno" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Predeterminado" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Estricto" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Incluir información del sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Incluye el OS, el kernel, las interfaces de red y las tablas de enrutamiento." + }, + "settings.troubleshooting.upload.label": { + "message": "Subir el paquete a los servidores de NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Devuelve una clave de subida para compartir con el soporte de NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Activar registros de seguimiento" + }, + "settings.troubleshooting.trace.help": { + "message": "Eleva el nivel de registro a TRACE y lo restaura después." + }, + "settings.troubleshooting.capture.label": { + "message": "Sesión de captura" + }, + "settings.troubleshooting.capture.help": { + "message": "Vuelve a conectar y espera para que pueda reproducir el problema." + }, + "settings.troubleshooting.packets.label": { + "message": "Capturar paquetes de red" + }, + "settings.troubleshooting.packets.help": { + "message": "Guarda un .pcap del tráfico de red durante la sesión de captura." + }, + "settings.troubleshooting.duration.label": { + "message": "Duración de la captura" + }, + "settings.troubleshooting.duration.help": { + "message": "Cuánto tiempo se ejecuta la sesión de captura." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min" + }, + "settings.troubleshooting.create": { + "message": "Crear paquete" + }, + "settings.troubleshooting.progress.description": { + "message": "Recopilando registros, detalles del sistema y estado de la conexión. Esto suele tardar un momento; mantenga esta ventana abierta hasta que termine." + }, + "settings.troubleshooting.cancelling": { + "message": "Cancelando…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "¡Paquete de diagnóstico subido correctamente!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Paquete guardado" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Comparta la clave de subida de abajo con el soporte de NetBird. También se guardó una copia local en su dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Su paquete de diagnóstico se ha guardado localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copiar clave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Abrir carpeta" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Abrir ubicación del archivo" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Error en la subida: {reason} El paquete sigue guardado localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Error en la subida. El paquete sigue guardado localmente." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconectando NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturando registros de depuración" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Generando el paquete de diagnóstico…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Subiendo a NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Cancelando…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Desarrollo]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Todos los derechos reservados." + }, + "settings.about.links.imprint": { + "message": "Aviso legal" + }, + "settings.about.links.privacy": { + "message": "Privacidad" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Términos del servicio" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Foro" + }, + "settings.about.community.documentation": { + "message": "Documentación" + }, + "settings.about.community.feedback": { + "message": "Comentarios" + }, + "update.banner.message": { + "message": "NetBird {version} está listo para instalar." + }, + "update.banner.later": { + "message": "Más tarde" + }, + "update.banner.installNow": { + "message": "Instalar ahora" + }, + "update.card.versionAvailableDownload": { + "message": "La versión {version} está disponible para descargar." + }, + "update.card.versionAvailableInstall": { + "message": "La versión {version} está disponible para instalar." + }, + "update.card.whatsNew": { + "message": "¿Qué hay de nuevo?" + }, + "update.card.installNow": { + "message": "Instalar ahora" + }, + "update.card.getInstaller": { + "message": "Descargar" + }, + "update.card.autoCheckInterval": { + "message": "NetBird busca actualizaciones en segundo plano." + }, + "update.card.changelog": { + "message": "Registro de cambios" + }, + "update.card.onLatestVersion": { + "message": "Tiene la última versión" + }, + "update.header.tooltip": { + "message": "Actualización disponible" + }, + "update.overlay.updatingVersion": { + "message": "Actualizando NetBird a v{version}" + }, + "update.overlay.updating": { + "message": "Actualizando NetBird" + }, + "update.overlay.description": { + "message": "Hay una versión más reciente disponible y se está instalando. NetBird se reiniciará automáticamente cuando finalice la actualización." + }, + "update.overlay.error.timeoutTitle": { + "message": "La actualización está tardando demasiado" + }, + "update.overlay.error.timeoutDescription": { + "message": "La instalación de {target} tardó demasiado y no finalizó." + }, + "update.overlay.error.canceledTitle": { + "message": "La actualización se detuvo" + }, + "update.overlay.error.canceledDescription": { + "message": "La actualización a {target} se canceló antes de finalizar." + }, + "update.overlay.error.failTitle": { + "message": "No se pudo instalar la actualización" + }, + "update.overlay.error.failDescription": { + "message": "No se pudo instalar {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "error desconocido" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "la nueva versión" + }, + "update.error.loadStateTitle": { + "message": "Error al cargar el estado de la actualización" + }, + "update.error.triggerTitle": { + "message": "Error al iniciar la actualización" + }, + "update.page.versionLine": { + "message": "Actualizando el cliente a: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Actualizando el cliente." + }, + "update.page.outdated": { + "message": "La versión de su cliente es anterior a la versión de actualización automática configurada en Management." + }, + "update.page.status.running": { + "message": "Actualizando" + }, + "update.page.status.timeout": { + "message": "Se agotó el tiempo de la actualización. Inténtelo de nuevo." + }, + "update.page.status.canceled": { + "message": "Actualización cancelada." + }, + "update.page.status.failed": { + "message": "Error en la actualización: {message}" + }, + "update.page.status.unknownError": { + "message": "error de actualización desconocido" + }, + "update.page.failedTitle": { + "message": "Error en la actualización" + }, + "update.page.timeoutMessage": { + "message": "Se agotó el tiempo de la actualización." + }, + "update.page.dontClose": { + "message": "No cierre esta ventana." + }, + "update.page.updating": { + "message": "Actualizando…" + }, + "update.page.complete": { + "message": "Actualización completada" + }, + "update.page.failed": { + "message": "Error en la actualización" + }, + "window.title.settings": { + "message": "Configuración" + }, + "window.title.signIn": { + "message": "Inicio de sesión" + }, + "window.title.sessionExpiration": { + "message": "Sesión a punto de expirar" + }, + "window.title.updating": { + "message": "Actualizando" + }, + "window.title.welcome": { + "message": "Bienvenido a NetBird" + }, + "window.title.error": { + "message": "Error" + }, + "welcome.title": { + "message": "Busque NetBird en su bandeja del sistema" + }, + "welcome.titleMac": { + "message": "Busque NetBird en su barra de menús" + }, + "welcome.description": { + "message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración." + }, + "welcome.descriptionMac": { + "message": "NetBird reside en su barra de menús. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración." + }, + "welcome.continue": { + "message": "Continuar" + }, + "welcome.back": { + "message": "Atrás" + }, + "welcome.management.title": { + "message": "Configurar NetBird" + }, + "welcome.management.description": { + "message": "Haga clic en Continuar para empezar, o elija Autoalojado si tiene su propio servidor de NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Use nuestro servicio alojado. No requiere configuración." + }, + "welcome.management.selfHosted.title": { + "message": "Autoalojado" + }, + "welcome.management.selfHosted.description": { + "message": "Conéctese a su propio servidor de gestión." + }, + "welcome.management.urlLabel": { + "message": "URL del servidor de gestión" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Introduzca una URL válida, p. ej., https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "No se pudo contactar con este servidor. Compruebe la URL o su red y, a continuación, continúe si está seguro de que es correcta." + }, + "welcome.management.checking": { + "message": "Comprobando…" + }, + "browserLogin.title": { + "message": "Continúe en su navegador para completar el inicio de sesión" + }, + "browserLogin.notSeeing": { + "message": "¿No ve la pestaña del navegador?" + }, + "browserLogin.tryAgain": { + "message": "Reintentar" + }, + "browserLogin.openFailedTitle": { + "message": "Error al abrir el navegador" + }, + "sessionExpiration.title": { + "message": "La sesión expira pronto" + }, + "sessionExpiration.titleLater": { + "message": "Su sesión expirará" + }, + "sessionExpiration.description": { + "message": "Este dispositivo se desconectará pronto. Renuévela iniciando sesión en el navegador." + }, + "sessionExpiration.descriptionLater": { + "message": "Iniciar sesión en el navegador mantiene este dispositivo conectado a su red." + }, + "sessionExpiration.stay": { + "message": "Renovar sesión" + }, + "sessionExpiration.authenticate": { + "message": "Autenticar" + }, + "sessionExpiration.logout": { + "message": "Cerrar sesión" + }, + "sessionExpiration.expired": { + "message": "Sesión expirada" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo desconectado. Autentíquese iniciando sesión en el navegador para reconectar." + }, + "sessionExpiration.close": { + "message": "Cerrar" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Error al renovar la sesión" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Error al cerrar sesión" + }, + "peers.search.placeholder": { + "message": "Buscar por nombre o IP" + }, + "peers.filter.all": { + "message": "Todos" + }, + "peers.filter.online": { + "message": "En línea" + }, + "peers.filter.offline": { + "message": "Sin conexión" + }, + "peers.empty.title": { + "message": "No hay peers disponibles" + }, + "peers.empty.description": { + "message": "No tiene ningún peer disponible o no tiene acceso a ninguno de ellos." + }, + "peers.details.domain": { + "message": "Dominio" + }, + "peers.details.netbirdIp": { + "message": "IP de NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 de NetBird" + }, + "peers.details.publicKey": { + "message": "Clave pública" + }, + "peers.details.connection": { + "message": "Conexión" + }, + "peers.details.latency": { + "message": "Latencia" + }, + "peers.details.lastHandshake": { + "message": "Último handshake" + }, + "peers.details.statusSince": { + "message": "Última actualización de la conexión" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Enviados" + }, + "peers.details.bytesReceived": { + "message": "Recibidos" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Nunca" + }, + "peers.details.justNow": { + "message": "Justo ahora" + }, + "peers.details.refresh": { + "message": "Actualizar" + }, + "peers.status.connected": { + "message": "Conectado" + }, + "peers.status.connecting": { + "message": "Conectando" + }, + "peers.status.disconnected": { + "message": "Desconectado" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Recursos" + }, + "peers.details.relayed": { + "message": "Retransmitida" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass habilitado" + }, + "networks.search.placeholder": { + "message": "Buscar por red o dominio" + }, + "networks.filter.all": { + "message": "Todos" + }, + "networks.filter.active": { + "message": "Activos" + }, + "networks.filter.overlapping": { + "message": "Solapados" + }, + "networks.empty.title": { + "message": "No hay recursos disponibles" + }, + "networks.empty.description": { + "message": "No tiene ningún recurso de red disponible o no tiene acceso a ninguno de ellos." + }, + "networks.selected": { + "message": "Seleccionado" + }, + "networks.unselected": { + "message": "No seleccionado" + }, + "networks.ips.heading": { + "message": "IP resueltas" + }, + "networks.bulk.selectionCount": { + "message": "{selected} de {total} activos" + }, + "networks.bulk.enableAll": { + "message": "Habilitar todos" + }, + "networks.bulk.disableAll": { + "message": "Deshabilitar todos" + }, + "exitNodes.search.placeholder": { + "message": "Buscar nodos de salida" + }, + "exitNodes.none": { + "message": "Ninguno" + }, + "exitNodes.empty.title": { + "message": "No hay nodos de salida disponibles" + }, + "exitNodes.empty.description": { + "message": "No se ha compartido ningún nodo de salida con este peer." + }, + "exitNodes.card.title": { + "message": "Nodo de salida" + }, + "exitNodes.card.statusActive": { + "message": "Activo" + }, + "exitNodes.card.statusInactive": { + "message": "Inactivo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Ninguno" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Conexión directa sin nodo de salida" + }, + "quickActions.connect": { + "message": "Conectar" + }, + "quickActions.disconnect": { + "message": "Desconectar" + }, + "daemon.unavailable.title": { + "message": "El servicio de NetBird no está en ejecución" + }, + "daemon.unavailable.description": { + "message": "La aplicación se reconectará automáticamente cuando el servicio esté en ejecución." + }, + "daemon.unavailable.docsLink": { + "message": "Documentación" + }, + "daemon.outdated.title": { + "message": "NetBird Client está desactualizado" + }, + "daemon.outdated.description": { + "message": "La nueva GUI no es compatible con su cliente anterior. Actualice su cliente para usar la nueva aplicación." + }, + "daemon.outdated.download": { + "message": "Descargar la última versión" + }, + "error.jwt_clock_skew": { + "message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo." + }, + "error.jwt_expired": { + "message": "Su token de inicio de sesión ha expirado. Inicie sesión de nuevo." + }, + "error.jwt_signature_invalid": { + "message": "Error al iniciar sesión: la firma del token no es válida. Póngase en contacto con su administrador." + }, + "error.session_expired": { + "message": "Su sesión ha expirado. Inicie sesión de nuevo." + }, + "error.invalid_setup_key": { + "message": "La clave de instalación falta o no es válida." + }, + "error.permission_denied": { + "message": "El servidor rechazó el inicio de sesión." + }, + "error.daemon_unreachable": { + "message": "El daemon de NetBird no responde. Compruebe que el servicio esté en ejecución." + }, + "error.unknown": { + "message": "La operación falló." + }, + "settings.ssh.privilege.hint": { + "message": "Requiere {actor}. Ejecute esto en su lugar:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Puede desactivarlo, pero volver a activarlo requiere {actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Puede activarlo, pero volver a desactivarlo requiere {actor}:" + } +} diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json new file mode 100644 index 000000000..d3e54440c --- /dev/null +++ b/client/ui/i18n/locales/fr/common.json @@ -0,0 +1,1363 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Déconnecté" + }, + "tray.status.daemonUnavailable": { + "message": "Non démarré" + }, + "tray.status.error": { + "message": "Erreur" + }, + "tray.status.connected": { + "message": "Connecté" + }, + "tray.status.connecting": { + "message": "Connexion" + }, + "tray.status.needsLogin": { + "message": "Connexion requise" + }, + "tray.status.loginFailed": { + "message": "Échec de la connexion" + }, + "tray.status.sessionExpired": { + "message": "Session expirée" + }, + "tray.session.expiresIn": { + "message": "La session expire dans {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "moins d’une minute" + }, + "tray.session.unit.minute": { + "message": "1 minute" + }, + "tray.session.unit.minutes": { + "message": "{count} minutes" + }, + "tray.session.unit.hour": { + "message": "1 heure" + }, + "tray.session.unit.hours": { + "message": "{count} heures" + }, + "tray.session.unit.day": { + "message": "1 jour" + }, + "tray.session.unit.days": { + "message": "{count} jours" + }, + "tray.menu.open": { + "message": "Ouvrir NetBird" + }, + "tray.menu.connect": { + "message": "Se connecter" + }, + "tray.menu.disconnect": { + "message": "Se déconnecter" + }, + "tray.menu.exitNode": { + "message": "Nœud de sortie" + }, + "tray.menu.networks": { + "message": "Ressources" + }, + "tray.menu.profiles": { + "message": "Profils" + }, + "tray.menu.manageProfiles": { + "message": "Gérer les profils" + }, + "tray.menu.settings": { + "message": "Paramètres..." + }, + "tray.menu.debugBundle": { + "message": "Créer un lot de diagnostic" + }, + "tray.menu.about": { + "message": "Aide et assistance" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentation" + }, + "tray.menu.troubleshoot": { + "message": "Dépannage" + }, + "tray.menu.downloadLatest": { + "message": "Télécharger la dernière version" + }, + "tray.menu.installVersion": { + "message": "Installer la version {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI : {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon : {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Quitter NetBird" + }, + "notify.daemonOutdated.title": { + "message": "Le service NetBird est obsolète" + }, + "notify.daemonOutdated.body": { + "message": "Mettez à jour le service NetBird pour utiliser cette application." + }, + "notify.update.title": { + "message": "Mise à jour de NetBird disponible" + }, + "notify.update.body": { + "message": "NetBird {version} est disponible." + }, + "notify.update.enforcedSuffix": { + "message": " Votre administrateur impose cette mise à jour." + }, + "notify.error.title": { + "message": "Erreur" + }, + "notify.error.connect": { + "message": "Échec de la connexion" + }, + "notify.error.disconnect": { + "message": "Échec de la déconnexion" + }, + "notify.error.switchProfile": { + "message": "Échec du passage à {profile}" + }, + "notify.error.exitNode": { + "message": "Échec de la mise à jour du nœud de sortie {name}" + }, + "notify.sessionExpired.title": { + "message": "Session NetBird expirée" + }, + "notify.sessionExpired.body": { + "message": "Votre session NetBird a expiré. Veuillez vous reconnecter." + }, + "notify.sessionWarning.title": { + "message": "La session expire bientôt" + }, + "notify.sessionWarning.body": { + "message": "Votre session NetBird expire dans {remaining}. Cliquez sur Prolonger pour la renouveler." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Votre session NetBird est sur le point d’expirer. Cliquez sur Prolonger pour la renouveler." + }, + "notify.sessionWarning.extend": { + "message": "Prolonger" + }, + "notify.sessionWarning.dismiss": { + "message": "Ignorer" + }, + "notify.sessionWarning.failed": { + "message": "Échec de la prolongation de la session NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Session NetBird prolongée" + }, + "notify.sessionWarning.successBody": { + "message": "Votre session a été renouvelée." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Échéance de session rejetée" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Le serveur a envoyé une échéance de session invalide. Veuillez vous reconnecter." + }, + "notify.mdm.policyApplied.title": { + "message": "Paramètres NetBird mis à jour" + }, + "notify.mdm.policyApplied.body": { + "message": "Votre configuration NetBird a été mise à jour par votre politique informatique." + }, + "common.cancel": { + "message": "Annuler" + }, + "common.save": { + "message": "Enregistrer" + }, + "common.saveChanges": { + "message": "Enregistrer les modifications" + }, + "common.saving": { + "message": "Enregistrement…" + }, + "common.close": { + "message": "Fermer" + }, + "common.copy": { + "message": "Copier" + }, + "common.togglePasswordVisibility": { + "message": "Afficher ou masquer le mot de passe" + }, + "common.increase": { + "message": "Augmenter" + }, + "common.decrease": { + "message": "Diminuer" + }, + "common.delete": { + "message": "Supprimer" + }, + "common.create": { + "message": "Créer" + }, + "common.add": { + "message": "Ajouter" + }, + "common.remove": { + "message": "Retirer" + }, + "common.refresh": { + "message": "Actualiser" + }, + "common.loading": { + "message": "Chargement…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Aucun résultat trouvé" + }, + "common.noResults.description": { + "message": "Nous n’avons trouvé aucun résultat. Essayez un autre terme de recherche ou modifiez vos filtres." + }, + "notConnected.title": { + "message": "Déconnecté" + }, + "notConnected.description": { + "message": "Connectez-vous d’abord à NetBird pour consulter les informations détaillées sur vos pairs, vos ressources réseau et vos nœuds de sortie." + }, + "connect.status.disconnected": { + "message": "Déconnecté" + }, + "connect.status.connecting": { + "message": "Connexion..." + }, + "connect.status.connected": { + "message": "Connecté" + }, + "connect.status.disconnecting": { + "message": "Déconnexion..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon indisponible" + }, + "connect.status.loginRequired": { + "message": "Connexion requise" + }, + "connect.error.loginTitle": { + "message": "Échec de la connexion" + }, + "connect.error.connectTitle": { + "message": "Échec de la connexion" + }, + "connect.error.disconnectTitle": { + "message": "Échec de la déconnexion" + }, + "nav.peers.title": { + "message": "Pairs" + }, + "nav.peers.description": { + "message": "{connected} sur {total} connectés" + }, + "nav.resources.title": { + "message": "Ressources" + }, + "nav.resources.description": { + "message": "{active} sur {total} actives" + }, + "nav.exitNode.title": { + "message": "Nœuds de sortie" + }, + "nav.exitNode.none": { + "message": "Inactif" + }, + "nav.exitNode.using": { + "message": "Via {name}" + }, + "header.openSettings": { + "message": "Ouvrir les paramètres" + }, + "header.togglePanel": { + "message": "Afficher ou masquer le panneau latéral" + }, + "profile.selector.loading": { + "message": "Chargement..." + }, + "profile.selector.noProfile": { + "message": "Aucun profil" + }, + "profile.selector.searchPlaceholder": { + "message": "Rechercher un profil par nom..." + }, + "profile.selector.emptyTitle": { + "message": "Aucun profil trouvé" + }, + "profile.selector.emptyDescription": { + "message": "Essayez un autre terme de recherche ou créez un nouveau profil." + }, + "profile.selector.newProfile": { + "message": "Nouveau profil" + }, + "profile.selector.moreOptions": { + "message": "Plus d’options" + }, + "profile.selector.deregister": { + "message": "Désinscrire" + }, + "profile.selector.delete": { + "message": "Supprimer" + }, + "profile.selector.switchTo": { + "message": "Basculer vers ce profil" + }, + "profile.selector.edit": { + "message": "Modifier" + }, + "profile.edit.title": { + "message": "Modifier le profil" + }, + "profile.edit.submit": { + "message": "Enregistrer les modifications" + }, + "profile.dialog.title": { + "message": "Saisir le nom du profil" + }, + "profile.dialog.nameLabel": { + "message": "Nom du profil" + }, + "profile.dialog.description": { + "message": "Choisissez un nom facilement identifiable pour votre profil." + }, + "profile.dialog.placeholder": { + "message": "ex. travail" + }, + "profile.dialog.submit": { + "message": "Ajouter un profil" + }, + "profile.dialog.required": { + "message": "Veuillez saisir un nom de profil, ex. travail, domicile" + }, + "profile.dialog.managementHelp": { + "message": "Utilisez NetBird Cloud ou votre propre serveur." + }, + "profile.dialog.urlUnreachable": { + "message": "Impossible de joindre ce serveur. Vérifiez l’URL, ou ajoutez le profil quand même si vous êtes sûr qu’elle est correcte." + }, + "header.menu.settings": { + "message": "Paramètres..." + }, + "header.menu.defaultView": { + "message": "Vue par défaut" + }, + "header.menu.advancedView": { + "message": "Vue avancée" + }, + "header.menu.updateAvailable": { + "message": "Mise à jour disponible" + }, + "header.menu.open": { + "message": "Ouvrir le menu" + }, + "header.profile.switch": { + "message": "Changer de profil" + }, + "connect.toggle.label": { + "message": "Activer/désactiver la connexion NetBird" + }, + "connect.localIp.label": { + "message": "Adresses IP locales" + }, + "common.search": { + "message": "Rechercher" + }, + "common.filter": { + "message": "Filtrer" + }, + "exitNodes.dropdown.trigger": { + "message": "Sélectionner un nœud de sortie" + }, + "peers.row.label": { + "message": "Ouvrir les détails pour {name}, {status}" + }, + "peers.dialog.title": { + "message": "Détails du pair" + }, + "networks.row.toggle": { + "message": "Activer/désactiver {name}" + }, + "networks.bulk.label": { + "message": "Activer/désactiver toutes les ressources visibles" + }, + "settings.nav.label": { + "message": "Sections des paramètres" + }, + "profile.switch.title": { + "message": "Basculer vers le profil « {name} » ?" + }, + "profile.switch.message": { + "message": "Voulez-vous vraiment changer de profil ?\nVotre profil actuel sera déconnecté." + }, + "profile.switch.confirm": { + "message": "Confirmer" + }, + "profile.deregister.title": { + "message": "Désinscrire le profil « {name} » ?" + }, + "profile.deregister.message": { + "message": "Voulez-vous vraiment désinscrire ce profil ?\nVous devrez vous reconnecter pour l’utiliser." + }, + "profile.deregister.confirm": { + "message": "Désinscrire" + }, + "profile.delete.title": { + "message": "Supprimer le profil « {name} » ?" + }, + "profile.delete.message": { + "message": "Voulez-vous vraiment supprimer ce profil ?\nCette action est irréversible." + }, + "profile.delete.disabledActive": { + "message": "Les profils actifs ne peuvent pas être supprimés. Basculez vers un autre profil avant de supprimer celui-ci." + }, + "profile.delete.disabledDefault": { + "message": "Le profil par défaut ne peut pas être supprimé." + }, + "profile.error.switchTitle": { + "message": "Échec du changement de profil" + }, + "profile.error.deregisterTitle": { + "message": "Échec de la désinscription du profil" + }, + "profile.error.deleteTitle": { + "message": "Échec de la suppression du profil" + }, + "profile.error.createTitle": { + "message": "Échec de la création du profil" + }, + "profile.error.editTitle": { + "message": "Échec de la modification du profil" + }, + "profile.error.loadTitle": { + "message": "Échec du chargement des profils" + }, + "profile.dropdown.activeProfile": { + "message": "Profil actif" + }, + "profile.dropdown.switchProfile": { + "message": "Changer de profil" + }, + "profile.dropdown.noEmail": { + "message": "Autre" + }, + "profile.dropdown.addProfile": { + "message": "Ajouter un profil" + }, + "profile.dropdown.manageProfiles": { + "message": "Gérer les profils" + }, + "profile.dropdown.settings": { + "message": "Paramètres" + }, + "settings.profiles.section.profiles": { + "message": "Profils" + }, + "settings.profiles.intro": { + "message": "Conservez plusieurs identités NetBird côte à côte, par exemple des comptes professionnels et personnels, ou différents serveurs de gestion. Ajoutez, désinscrivez ou supprimez des profils ci-dessous." + }, + "settings.profiles.addProfile": { + "message": "Ajouter un profil" + }, + "settings.profiles.active": { + "message": "Actif" + }, + "settings.profiles.emptyTitle": { + "message": "Aucun profil" + }, + "settings.profiles.emptyDescription": { + "message": "Créez un profil pour vous connecter à un serveur de gestion NetBird." + }, + "settings.error.loadTitle": { + "message": "Échec du chargement des paramètres" + }, + "settings.error.saveTitle": { + "message": "Échec de l’enregistrement des paramètres" + }, + "settings.error.debugBundleTitle": { + "message": "Échec du lot de diagnostic" + }, + "settings.tabs.general": { + "message": "Général" + }, + "settings.tabs.network": { + "message": "Réseau" + }, + "settings.tabs.security": { + "message": "Sécurité" + }, + "settings.tabs.profiles": { + "message": "Profils" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avancé" + }, + "settings.tabs.troubleshooting": { + "message": "Dépannage" + }, + "settings.tabs.about": { + "message": "À propos" + }, + "settings.tabs.updateAvailable": { + "message": "Mise à jour disponible" + }, + "settings.general.section.general": { + "message": "Général" + }, + "settings.general.section.connection": { + "message": "Connexion" + }, + "settings.general.connectOnStartup.label": { + "message": "Se connecter au démarrage" + }, + "settings.general.connectOnStartup.help": { + "message": "Établir automatiquement une connexion au démarrage du service." + }, + "settings.general.notifications.label": { + "message": "Notifications du bureau" + }, + "settings.general.notifications.help": { + "message": "Afficher des notifications du bureau pour les nouvelles mises à jour et les événements de connexion." + }, + "settings.general.autostart.label": { + "message": "Lancer l’interface NetBird à l’ouverture de session" + }, + "settings.general.autostart.help": { + "message": "Démarrer automatiquement l’interface NetBird à l’ouverture de votre session. Cela n’affecte que l’interface graphique, pas le service en arrière-plan." + }, + "settings.general.autostart.errorTitle": { + "message": "Échec de la modification du démarrage automatique" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Rester connecté après la fermeture", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Langue d’affichage" + }, + "settings.general.language.help": { + "message": "Choisissez la langue de l’interface NetBird." + }, + "settings.general.language.search": { + "message": "Rechercher une langue…" + }, + "settings.general.language.empty": { + "message": "Aucune langue ne correspond." + }, + "settings.general.management.label": { + "message": "Serveur de gestion" + }, + "settings.general.management.help": { + "message": "Connectez-vous à NetBird Cloud ou à votre propre serveur de gestion auto-hébergé. Les modifications reconnecteront le client." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Auto-hébergé" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Veuillez saisir une URL valide, ex. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Impossible de joindre ce serveur. Vérifiez l’URL, ou enregistrez quand même si vous êtes sûr qu’elle est correcte." + }, + "settings.general.management.switchCloudTitle": { + "message": "Basculer vers NetBird Cloud ?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Cela déconnecte votre serveur auto-hébergé.\nVous devrez peut-être vous reconnecter." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Basculer vers Cloud" + }, + "settings.network.section.connectivity": { + "message": "Connectivité" + }, + "settings.network.section.routingDns": { + "message": "Routage et DNS" + }, + "settings.network.monitor.label": { + "message": "Reconnecter en cas de changement de réseau" + }, + "settings.network.monitor.help": { + "message": "Surveiller le réseau et se reconnecter automatiquement lors de changements tels qu’un basculement Wi-Fi, une modification Ethernet ou une reprise après la mise en veille." + }, + "settings.network.dns.label": { + "message": "Activer le DNS" + }, + "settings.network.dns.help": { + "message": "Appliquer les paramètres DNS gérés par NetBird au résolveur de l’hôte." + }, + "settings.network.clientRoutes.label": { + "message": "Activer les routes client" + }, + "settings.network.clientRoutes.help": { + "message": "Accepter les routes d’autres pairs pour atteindre leurs réseaux." + }, + "settings.network.serverRoutes.label": { + "message": "Activer les routes serveur" + }, + "settings.network.serverRoutes.help": { + "message": "Annoncer les routes locales de cet hôte aux autres pairs." + }, + "settings.network.ipv6.label": { + "message": "Activer IPv6" + }, + "settings.network.ipv6.help": { + "message": "Utiliser l’adressage IPv6 pour le réseau overlay NetBird." + }, + "settings.security.section.firewall": { + "message": "Pare-feu" + }, + "settings.security.section.encryption": { + "message": "Chiffrement" + }, + "settings.security.blockInbound.label": { + "message": "Bloquer le trafic entrant" + }, + "settings.security.blockInbound.help": { + "message": "Rejeter les connexions non sollicitées des pairs vers cet appareil et les réseaux qu’il route. Le trafic sortant n’est pas affecté." + }, + "settings.security.blockLan.label": { + "message": "Bloquer l’accès au LAN" + }, + "settings.security.blockLan.help": { + "message": "Empêcher les pairs d’atteindre votre réseau local ou ses appareils lorsque cet appareil route leur trafic." + }, + "settings.security.rosenpass.label": { + "message": "Activer la résistance quantique" + }, + "settings.security.rosenpass.help": { + "message": "Ajouter un échange de clés post-quantique via Rosenpass au-dessus de WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Activer le mode permissif" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Autoriser les connexions vers des pairs sans prise en charge de la résistance quantique." + }, + "settings.ssh.section.server": { + "message": "Serveur" + }, + "settings.ssh.section.capabilities": { + "message": "Fonctionnalités" + }, + "settings.ssh.section.authentication": { + "message": "Authentification" + }, + "settings.ssh.server.label": { + "message": "Activer le serveur SSH" + }, + "settings.ssh.server.help": { + "message": "Exécuter le serveur SSH NetBird sur cet hôte afin que d’autres pairs puissent s’y connecter." + }, + "settings.ssh.root.label": { + "message": "Autoriser la connexion en root" + }, + "settings.ssh.root.help": { + "message": "Permettre aux pairs de se connecter en tant qu’utilisateur root. Désactivez pour exiger un compte non privilégié." + }, + "settings.ssh.sftp.label": { + "message": "Autoriser SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transférer des fichiers en toute sécurité à l’aide de clients SFTP ou SCP natifs." + }, + "settings.ssh.localForward.label": { + "message": "Redirection de port locale" + }, + "settings.ssh.localForward.help": { + "message": "Permettre aux pairs connectés de tunneliser des ports locaux vers des services accessibles depuis cet hôte." + }, + "settings.ssh.remoteForward.label": { + "message": "Redirection de port distante" + }, + "settings.ssh.remoteForward.help": { + "message": "Permettre aux pairs connectés d’exposer des ports de cet hôte vers leur propre machine." + }, + "settings.ssh.jwt.label": { + "message": "Activer l’authentification JWT" + }, + "settings.ssh.jwt.help": { + "message": "Vérifier chaque session SSH auprès de votre IdP pour l’identité de l’utilisateur et l’audit. Désactivez pour vous appuyer uniquement sur les politiques ACL réseau, utile lorsqu’aucun IdP n’est disponible." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL du cache JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Durée pendant laquelle ce client met en cache un JWT avant de redemander lors des connexions SSH sortantes. Définissez 0 pour désactiver la mise en cache et vous authentifier à chaque connexion." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "s" + }, + "settings.advanced.section.interface": { + "message": "Interface" + }, + "settings.advanced.section.security": { + "message": "Sécurité" + }, + "settings.advanced.interfaceName.label": { + "message": "Nom" + }, + "settings.advanced.interfaceName.error": { + "message": "Utilisez 1 à 15 lettres, chiffres, points, tirets ou traits de soulignement." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Doit commencer par « utun » suivi d’un nombre (ex. utun100)." + }, + "settings.advanced.port.label": { + "message": "Port" + }, + "settings.advanced.port.error": { + "message": "Saisissez un port compris entre {min} et {max}." + }, + "settings.advanced.port.help": { + "message": "Si la valeur est 0, un port libre aléatoire sera utilisé." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Saisissez une valeur MTU comprise entre {min} et {max}." + }, + "settings.advanced.psk.label": { + "message": "Clé pré-partagée" + }, + "settings.advanced.psk.help": { + "message": "PSK WireGuard facultative pour un chiffrement symétrique supplémentaire. Différente d’une clé d’installation NetBird. Vous ne communiquerez qu’avec les pairs utilisant la même clé pré-partagée." + }, + "settings.troubleshooting.section.title": { + "message": "Lot de diagnostic" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonymiser les informations sensibles" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Masque les adresses IP, les domaines et d'autres valeurs sensibles." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Le mode par défaut garde les adresses IPv4 internes et les noms des pairs lisibles pour le support. Le mode strict anonymise en plus les adresses IP privées (RFC 1918), CGNAT et de lien local, les noms des pairs et les clés publiques WireGuard. Les valeurs récurrentes reçoivent le même espace réservé, les pairs restent donc distinguables. Utilisez le mode strict lorsque vous partagez le lot de diagnostic en dehors de votre organisation." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Aucune" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Par défaut" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Strict" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Inclure les informations système" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Inclure l’OS, le noyau, les interfaces réseau et les tables de routage." + }, + "settings.troubleshooting.upload.label": { + "message": "Envoyer le lot aux serveurs NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Renvoie une clé d’envoi à partager avec l’assistance NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Activer les journaux trace" + }, + "settings.troubleshooting.trace.help": { + "message": "Élève le niveau de journalisation à TRACE, puis le rétablit ensuite." + }, + "settings.troubleshooting.capture.label": { + "message": "Session de capture" + }, + "settings.troubleshooting.capture.help": { + "message": "Se reconnecte et attend pour que vous puissiez reproduire le problème." + }, + "settings.troubleshooting.packets.label": { + "message": "Capturer les paquets réseau" + }, + "settings.troubleshooting.packets.help": { + "message": "Enregistre un .pcap du trafic réseau pendant la session de capture." + }, + "settings.troubleshooting.duration.label": { + "message": "Durée de capture" + }, + "settings.troubleshooting.duration.help": { + "message": "Durée d’exécution de la session de capture." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min" + }, + "settings.troubleshooting.create": { + "message": "Créer le lot" + }, + "settings.troubleshooting.progress.description": { + "message": "Collecte des journaux, des détails système et de l’état de connexion. Cela prend généralement un instant — gardez cette fenêtre ouverte jusqu’à la fin." + }, + "settings.troubleshooting.cancelling": { + "message": "Annulation…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Lot de diagnostic envoyé avec succès !" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Lot enregistré" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Partagez la clé d’envoi ci-dessous avec l’assistance NetBird. Une copie locale a également été enregistrée sur votre appareil." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Votre lot de diagnostic a été enregistré localement." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copier la clé" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Ouvrir le dossier" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Ouvrir l’emplacement du fichier" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Échec de l’envoi : {reason} Le lot reste enregistré localement." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Échec de l’envoi. Le lot reste enregistré localement." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconnexion de NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capture des journaux de débogage" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Génération du lot de diagnostic…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Envoi vers NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Annulation…" + }, + "settings.about.client": { + "message": "Client NetBird v{version}" + }, + "settings.about.clientName": { + "message": "Client NetBird" + }, + "settings.about.development": { + "message": "[Développement]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Tous droits réservés." + }, + "settings.about.links.imprint": { + "message": "Mentions légales" + }, + "settings.about.links.privacy": { + "message": "Confidentialité" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Conditions d’utilisation" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Forum" + }, + "settings.about.community.documentation": { + "message": "Documentation" + }, + "settings.about.community.feedback": { + "message": "Retour d’expérience" + }, + "update.banner.message": { + "message": "NetBird {version} est prêt à être installé." + }, + "update.banner.later": { + "message": "Plus tard" + }, + "update.banner.installNow": { + "message": "Installer maintenant" + }, + "update.card.versionAvailableDownload": { + "message": "La version {version} est disponible au téléchargement." + }, + "update.card.versionAvailableInstall": { + "message": "La version {version} est disponible à l’installation." + }, + "update.card.whatsNew": { + "message": "Quoi de neuf ?" + }, + "update.card.installNow": { + "message": "Installer maintenant" + }, + "update.card.getInstaller": { + "message": "Télécharger" + }, + "update.card.autoCheckInterval": { + "message": "NetBird vérifie les mises à jour en arrière-plan." + }, + "update.card.changelog": { + "message": "Journal des modifications" + }, + "update.card.onLatestVersion": { + "message": "Vous utilisez la dernière version" + }, + "update.header.tooltip": { + "message": "Mise à jour disponible" + }, + "update.overlay.updatingVersion": { + "message": "Mise à jour de NetBird vers v{version}" + }, + "update.overlay.updating": { + "message": "Mise à jour de NetBird" + }, + "update.overlay.description": { + "message": "Une version plus récente est disponible et en cours d’installation. NetBird redémarrera automatiquement une fois la mise à jour terminée." + }, + "update.overlay.error.timeoutTitle": { + "message": "La mise à jour prend trop de temps" + }, + "update.overlay.error.timeoutDescription": { + "message": "L’installation de {target} a pris trop de temps et ne s’est pas terminée." + }, + "update.overlay.error.canceledTitle": { + "message": "La mise à jour a été interrompue" + }, + "update.overlay.error.canceledDescription": { + "message": "La mise à jour vers {target} a été annulée avant de se terminer." + }, + "update.overlay.error.failTitle": { + "message": "Impossible d’installer la mise à jour" + }, + "update.overlay.error.failDescription": { + "message": "Impossible d’installer {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "erreur inconnue" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "la nouvelle version" + }, + "update.error.loadStateTitle": { + "message": "Échec du chargement de l’état de mise à jour" + }, + "update.error.triggerTitle": { + "message": "Échec du démarrage de la mise à jour" + }, + "update.page.versionLine": { + "message": "Mise à jour du client vers : {version}." + }, + "update.page.versionLineGeneric": { + "message": "Mise à jour du client." + }, + "update.page.outdated": { + "message": "La version de votre client est antérieure à la version de mise à jour automatique définie dans Management." + }, + "update.page.status.running": { + "message": "Mise à jour en cours" + }, + "update.page.status.timeout": { + "message": "La mise à jour a expiré. Veuillez réessayer." + }, + "update.page.status.canceled": { + "message": "Mise à jour annulée." + }, + "update.page.status.failed": { + "message": "Échec de la mise à jour : {message}" + }, + "update.page.status.unknownError": { + "message": "erreur de mise à jour inconnue" + }, + "update.page.failedTitle": { + "message": "Échec de la mise à jour" + }, + "update.page.timeoutMessage": { + "message": "La mise à jour a expiré." + }, + "update.page.dontClose": { + "message": "Veuillez ne pas fermer cette fenêtre." + }, + "update.page.updating": { + "message": "Mise à jour…" + }, + "update.page.complete": { + "message": "Mise à jour terminée" + }, + "update.page.failed": { + "message": "Échec de la mise à jour" + }, + "window.title.settings": { + "message": "Paramètres" + }, + "window.title.signIn": { + "message": "Connexion" + }, + "window.title.sessionExpiration": { + "message": "Expiration de session" + }, + "window.title.updating": { + "message": "Mise à jour" + }, + "window.title.welcome": { + "message": "Bienvenue dans NetBird" + }, + "window.title.error": { + "message": "Erreur" + }, + "welcome.title": { + "message": "Cherchez NetBird dans votre barre d’état système" + }, + "welcome.titleMac": { + "message": "Cherchez NetBird dans votre barre des menus" + }, + "welcome.description": { + "message": "NetBird se trouve dans votre barre d’état système. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres." + }, + "welcome.descriptionMac": { + "message": "NetBird se trouve dans votre barre des menus. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres." + }, + "welcome.continue": { + "message": "Continuer" + }, + "welcome.back": { + "message": "Retour" + }, + "welcome.management.title": { + "message": "Configurer NetBird" + }, + "welcome.management.description": { + "message": "Cliquez sur Continuer pour commencer, ou choisissez Auto-hébergé si vous disposez de votre propre serveur NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Utilisez notre service hébergé. Aucune configuration requise." + }, + "welcome.management.selfHosted.title": { + "message": "Auto-hébergé" + }, + "welcome.management.selfHosted.description": { + "message": "Connectez-vous à votre propre serveur de gestion." + }, + "welcome.management.urlLabel": { + "message": "URL du serveur de gestion" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Veuillez saisir une URL valide, ex. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Impossible de joindre ce serveur. Vérifiez l’URL ou votre réseau, puis continuez si vous êtes sûr qu’elle est correcte." + }, + "welcome.management.checking": { + "message": "Vérification…" + }, + "browserLogin.title": { + "message": "Continuez dans votre navigateur pour terminer la connexion" + }, + "browserLogin.notSeeing": { + "message": "Vous ne voyez pas l’onglet du navigateur ?" + }, + "browserLogin.tryAgain": { + "message": "Réessayer" + }, + "browserLogin.openFailedTitle": { + "message": "Échec de l’ouverture du navigateur" + }, + "sessionExpiration.title": { + "message": "La session expire bientôt" + }, + "sessionExpiration.titleLater": { + "message": "Votre session va expirer" + }, + "sessionExpiration.description": { + "message": "Cet appareil sera bientôt déconnecté. Renouvelez via une connexion dans le navigateur." + }, + "sessionExpiration.descriptionLater": { + "message": "Une connexion dans le navigateur maintient cet appareil connecté à votre réseau." + }, + "sessionExpiration.stay": { + "message": "Renouveler la session" + }, + "sessionExpiration.authenticate": { + "message": "S’authentifier" + }, + "sessionExpiration.logout": { + "message": "Se déconnecter" + }, + "sessionExpiration.expired": { + "message": "Session expirée" + }, + "sessionExpiration.expiredDescription": { + "message": "Appareil déconnecté. Authentifiez-vous via une connexion dans le navigateur pour vous reconnecter." + }, + "sessionExpiration.close": { + "message": "Fermer" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Échec de la prolongation de la session" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Échec de la déconnexion" + }, + "peers.search.placeholder": { + "message": "Rechercher par nom ou IP" + }, + "peers.filter.all": { + "message": "Tous" + }, + "peers.filter.online": { + "message": "En ligne" + }, + "peers.filter.offline": { + "message": "Hors ligne" + }, + "peers.empty.title": { + "message": "Aucun pair disponible" + }, + "peers.empty.description": { + "message": "Soit vous n’avez aucun pair disponible, soit vous n’y avez pas accès." + }, + "peers.details.domain": { + "message": "Domaine" + }, + "peers.details.netbirdIp": { + "message": "IP NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 NetBird" + }, + "peers.details.publicKey": { + "message": "Clé publique" + }, + "peers.details.connection": { + "message": "Connexion" + }, + "peers.details.latency": { + "message": "Latence" + }, + "peers.details.lastHandshake": { + "message": "Dernier handshake" + }, + "peers.details.statusSince": { + "message": "Dernière mise à jour de connexion" + }, + "peers.details.bytes": { + "message": "Octets" + }, + "peers.details.bytesSent": { + "message": "Envoyés" + }, + "peers.details.bytesReceived": { + "message": "Reçus" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE distant" + }, + "peers.details.never": { + "message": "Jamais" + }, + "peers.details.justNow": { + "message": "À l’instant" + }, + "peers.details.refresh": { + "message": "Actualiser" + }, + "peers.status.connected": { + "message": "Connecté" + }, + "peers.status.connecting": { + "message": "Connexion" + }, + "peers.status.disconnected": { + "message": "Déconnecté" + }, + "peers.details.relayAddress": { + "message": "Relais" + }, + "peers.details.networks": { + "message": "Ressources" + }, + "peers.details.relayed": { + "message": "Relayée" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass activé" + }, + "networks.search.placeholder": { + "message": "Rechercher par réseau ou domaine" + }, + "networks.filter.all": { + "message": "Toutes" + }, + "networks.filter.active": { + "message": "Actives" + }, + "networks.filter.overlapping": { + "message": "Chevauchantes" + }, + "networks.empty.title": { + "message": "Aucune ressource disponible" + }, + "networks.empty.description": { + "message": "Soit vous n’avez aucune ressource réseau disponible, soit vous n’y avez pas accès." + }, + "networks.selected": { + "message": "Sélectionnée" + }, + "networks.unselected": { + "message": "Non sélectionnée" + }, + "networks.ips.heading": { + "message": "IP résolues" + }, + "networks.bulk.selectionCount": { + "message": "{selected} sur {total} actives" + }, + "networks.bulk.enableAll": { + "message": "Tout activer" + }, + "networks.bulk.disableAll": { + "message": "Tout désactiver" + }, + "exitNodes.search.placeholder": { + "message": "Rechercher des nœuds de sortie" + }, + "exitNodes.none": { + "message": "Aucun" + }, + "exitNodes.empty.title": { + "message": "Aucun nœud de sortie disponible" + }, + "exitNodes.empty.description": { + "message": "Aucun nœud de sortie n’a été partagé avec ce pair." + }, + "exitNodes.card.title": { + "message": "Nœud de sortie" + }, + "exitNodes.card.statusActive": { + "message": "Actif" + }, + "exitNodes.card.statusInactive": { + "message": "Inactif" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Aucun" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Connexion directe sans nœud de sortie" + }, + "quickActions.connect": { + "message": "Se connecter" + }, + "quickActions.disconnect": { + "message": "Se déconnecter" + }, + "daemon.unavailable.title": { + "message": "Le service NetBird n’est pas en cours d’exécution" + }, + "daemon.unavailable.description": { + "message": "L’application se reconnectera automatiquement une fois le service en cours d’exécution." + }, + "daemon.unavailable.docsLink": { + "message": "Documentation" + }, + "daemon.outdated.title": { + "message": "Le Client NetBird est obsolète" + }, + "daemon.outdated.description": { + "message": "La nouvelle GUI n'est pas compatible avec votre ancien client. Mettez à jour votre client pour utiliser la nouvelle application." + }, + "daemon.outdated.download": { + "message": "Télécharger la dernière version" + }, + "error.jwt_clock_skew": { + "message": "Échec de la connexion : l’horloge de cet appareil n’est pas synchronisée avec le serveur. Veuillez synchroniser l’horloge de votre système et réessayer." + }, + "error.jwt_expired": { + "message": "Votre jeton de connexion a expiré. Veuillez vous reconnecter." + }, + "error.jwt_signature_invalid": { + "message": "Échec de la connexion : la signature du jeton est invalide. Veuillez contacter votre administrateur." + }, + "error.session_expired": { + "message": "Votre session a expiré. Veuillez vous reconnecter." + }, + "error.invalid_setup_key": { + "message": "La clé d’installation est manquante ou invalide." + }, + "error.permission_denied": { + "message": "La connexion a été rejetée par le serveur." + }, + "error.daemon_unreachable": { + "message": "Le daemon NetBird ne répond pas. Veuillez vérifier que le service est en cours d’exécution." + }, + "error.unknown": { + "message": "L’opération a échoué." + }, + "settings.ssh.privilege.hint": { + "message": "Nécessite {actor}. Exécutez plutôt ceci :" + }, + "settings.ssh.privilege.oneWay": { + "message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor} :" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Vous pouvez l’activer, mais le désactiver de nouveau nécessite {actor} :" + } +} diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json new file mode 100644 index 000000000..19aede17f --- /dev/null +++ b/client/ui/i18n/locales/hu/common.json @@ -0,0 +1,1363 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Lecsatlakozva" + }, + "tray.status.daemonUnavailable": { + "message": "Nem fut" + }, + "tray.status.error": { + "message": "Hiba" + }, + "tray.status.connected": { + "message": "Csatlakozva" + }, + "tray.status.connecting": { + "message": "Csatlakozás" + }, + "tray.status.needsLogin": { + "message": "Bejelentkezés szükséges" + }, + "tray.status.loginFailed": { + "message": "Sikertelen bejelentkezés" + }, + "tray.status.sessionExpired": { + "message": "Munkamenet lejárt" + }, + "tray.session.expiresIn": { + "message": "Munkamenet lejár {remaining} múlva" + }, + "tray.session.unit.lessThanMinute": { + "message": "egy percnél kevesebb" + }, + "tray.session.unit.minute": { + "message": "1 perc" + }, + "tray.session.unit.minutes": { + "message": "{count} perc" + }, + "tray.session.unit.hour": { + "message": "1 óra" + }, + "tray.session.unit.hours": { + "message": "{count} óra" + }, + "tray.session.unit.day": { + "message": "1 nap" + }, + "tray.session.unit.days": { + "message": "{count} nap" + }, + "tray.menu.open": { + "message": "NetBird megnyitása" + }, + "tray.menu.connect": { + "message": "Csatlakozás" + }, + "tray.menu.disconnect": { + "message": "Bontás" + }, + "tray.menu.exitNode": { + "message": "Exit Node" + }, + "tray.menu.networks": { + "message": "Erőforrások" + }, + "tray.menu.profiles": { + "message": "Profilok" + }, + "tray.menu.manageProfiles": { + "message": "Profilok kezelése" + }, + "tray.menu.settings": { + "message": "Beállítások…" + }, + "tray.menu.debugBundle": { + "message": "Hibakeresési csomag készítése" + }, + "tray.menu.about": { + "message": "Súgó és támogatás" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Dokumentáció" + }, + "tray.menu.troubleshoot": { + "message": "Hibakeresés" + }, + "tray.menu.downloadLatest": { + "message": "Legfrissebb verzió letöltése" + }, + "tray.menu.installVersion": { + "message": "{version} verzió telepítése" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "NetBird bezárása" + }, + "notify.daemonOutdated.title": { + "message": "A NetBird szolgáltatás elavult" + }, + "notify.daemonOutdated.body": { + "message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához." + }, + "notify.update.title": { + "message": "NetBird frissítés elérhető" + }, + "notify.update.body": { + "message": "Elérhető a NetBird {version}." + }, + "notify.update.enforcedSuffix": { + "message": " A rendszergazda kötelezővé tette ezt a frissítést." + }, + "notify.error.title": { + "message": "Hiba" + }, + "notify.error.connect": { + "message": "Csatlakozás sikertelen" + }, + "notify.error.disconnect": { + "message": "Bontás sikertelen" + }, + "notify.error.switchProfile": { + "message": "Átváltás sikertelen erre: {profile}" + }, + "notify.error.exitNode": { + "message": "Az Exit Node frissítése sikertelen: {name}" + }, + "notify.sessionExpired.title": { + "message": "NetBird munkamenet lejárt" + }, + "notify.sessionExpired.body": { + "message": "A NetBird munkamenet lejárt. Kérjük, jelentkezzen be újra." + }, + "notify.sessionWarning.title": { + "message": "Munkamenet hamarosan lejár" + }, + "notify.sessionWarning.body": { + "message": "A NetBird munkamenet {remaining} múlva lejár. Kattintson a Meghosszabbítás gombra a megújításhoz." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "A NetBird munkamenet hamarosan lejár. Kattintson a Meghosszabbítás gombra a megújításhoz." + }, + "notify.sessionWarning.extend": { + "message": "Meghosszabbítás" + }, + "notify.sessionWarning.dismiss": { + "message": "Elvetés" + }, + "notify.sessionWarning.failed": { + "message": "A NetBird munkamenet meghosszabbítása sikertelen" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird munkamenet meghosszabbítva" + }, + "notify.sessionWarning.successBody": { + "message": "A munkamenet frissítve." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Munkamenet-határidő elutasítva" + }, + "notify.sessionDeadlineRejected.body": { + "message": "A szerver érvénytelen munkamenet-határidőt küldött. Kérjük, jelentkezzen be újra." + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird beállítások frissítve" + }, + "notify.mdm.policyApplied.body": { + "message": "A NetBird konfigurációt az IT-szabályzat frissítette." + }, + "common.cancel": { + "message": "Mégse" + }, + "common.save": { + "message": "Mentés" + }, + "common.saveChanges": { + "message": "Módosítások mentése" + }, + "common.saving": { + "message": "Mentés…" + }, + "common.close": { + "message": "Bezárás" + }, + "common.copy": { + "message": "Másolás" + }, + "common.togglePasswordVisibility": { + "message": "Jelszó láthatóságának váltása" + }, + "common.increase": { + "message": "Növelés" + }, + "common.decrease": { + "message": "Csökkentés" + }, + "common.delete": { + "message": "Törlés" + }, + "common.create": { + "message": "Létrehozás" + }, + "common.add": { + "message": "Hozzáadás" + }, + "common.remove": { + "message": "Eltávolítás" + }, + "common.refresh": { + "message": "Frissítés" + }, + "common.loading": { + "message": "Betöltés…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Nincs találat" + }, + "common.noResults.description": { + "message": "Nem találtunk eredményt. Próbáljon meg másik keresési kifejezést, vagy módosítsa a szűrőket." + }, + "notConnected.title": { + "message": "Lecsatlakozva" + }, + "notConnected.description": { + "message": "Csatlakozzon először a NetBirdhöz, hogy részletes információkat láthasson a Peerekről, a hálózati erőforrásokról és az Exit Node-okról." + }, + "connect.status.disconnected": { + "message": "Lecsatlakozva" + }, + "connect.status.connecting": { + "message": "Csatlakozás…" + }, + "connect.status.connected": { + "message": "Csatlakozva" + }, + "connect.status.disconnecting": { + "message": "Lecsatlakozás…" + }, + "connect.status.daemonUnavailable": { + "message": "Daemon nem elérhető" + }, + "connect.status.loginRequired": { + "message": "Bejelentkezés szükséges" + }, + "connect.error.loginTitle": { + "message": "Bejelentkezés sikertelen" + }, + "connect.error.connectTitle": { + "message": "Csatlakozás sikertelen" + }, + "connect.error.disconnectTitle": { + "message": "Bontás sikertelen" + }, + "nav.peers.title": { + "message": "Peerek" + }, + "nav.peers.description": { + "message": "{connected} / {total} csatlakoztatva" + }, + "nav.resources.title": { + "message": "Erőforrások" + }, + "nav.resources.description": { + "message": "{active} / {total} aktív" + }, + "nav.exitNode.title": { + "message": "Exit Node-ok" + }, + "nav.exitNode.none": { + "message": "Nem aktív" + }, + "nav.exitNode.using": { + "message": "Ezen át: {name}" + }, + "header.openSettings": { + "message": "Beállítások megnyitása" + }, + "header.togglePanel": { + "message": "Oldalsó panel váltása" + }, + "profile.selector.loading": { + "message": "Betöltés…" + }, + "profile.selector.noProfile": { + "message": "Nincs profil" + }, + "profile.selector.searchPlaceholder": { + "message": "Profil keresése név alapján…" + }, + "profile.selector.emptyTitle": { + "message": "Nem található profil" + }, + "profile.selector.emptyDescription": { + "message": "Próbáljon más keresőkifejezést, vagy hozzon létre új profilt." + }, + "profile.selector.newProfile": { + "message": "Új profil" + }, + "profile.selector.moreOptions": { + "message": "További műveletek" + }, + "profile.selector.deregister": { + "message": "Leválasztás" + }, + "profile.selector.delete": { + "message": "Profil törlése" + }, + "profile.selector.switchTo": { + "message": "Váltás erre a profilra" + }, + "profile.selector.edit": { + "message": "Szerkesztés" + }, + "profile.edit.title": { + "message": "Profil szerkesztése" + }, + "profile.edit.submit": { + "message": "Módosítások mentése" + }, + "profile.dialog.title": { + "message": "Új profil" + }, + "profile.dialog.nameLabel": { + "message": "Profilnév" + }, + "profile.dialog.description": { + "message": "Adjon profiljának egy könnyen azonosítható nevet." + }, + "profile.dialog.placeholder": { + "message": "pl. Munka" + }, + "profile.dialog.submit": { + "message": "Profil hozzáadása" + }, + "profile.dialog.required": { + "message": "Adjon meg egy profilnevet, pl. Munka, Otthon" + }, + "profile.dialog.managementHelp": { + "message": "NetBird Cloud vagy saját kiszolgáló." + }, + "profile.dialog.urlUnreachable": { + "message": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy adja hozzá a profilt, ha biztos benne, hogy helyes." + }, + "header.menu.settings": { + "message": "Beállítások…" + }, + "header.menu.defaultView": { + "message": "Alapnézet" + }, + "header.menu.advancedView": { + "message": "Speciális nézet" + }, + "header.menu.updateAvailable": { + "message": "Frissítés elérhető" + }, + "header.menu.open": { + "message": "Menü megnyitása" + }, + "header.profile.switch": { + "message": "Profilváltás" + }, + "connect.toggle.label": { + "message": "NetBird-kapcsolat be/ki" + }, + "connect.localIp.label": { + "message": "Helyi IP-címek" + }, + "common.search": { + "message": "Keresés" + }, + "common.filter": { + "message": "Szűrés" + }, + "exitNodes.dropdown.trigger": { + "message": "Kilépőcsomópont kiválasztása" + }, + "peers.row.label": { + "message": "Részletek megnyitása: {name}, {status}" + }, + "peers.dialog.title": { + "message": "Társ részletei" + }, + "networks.row.toggle": { + "message": "{name} be/ki" + }, + "networks.bulk.label": { + "message": "Összes látható erőforrás be/ki" + }, + "settings.nav.label": { + "message": "Beállítások szakaszai" + }, + "profile.switch.title": { + "message": "Váltás a(z) \"{name}\" profilra?" + }, + "profile.switch.message": { + "message": "Biztosan profilt szeretne váltani?\nAz aktuális profilja le lesz választva." + }, + "profile.switch.confirm": { + "message": "Megerősítés" + }, + "profile.deregister.title": { + "message": "\"{name}\" profil leválasztása?" + }, + "profile.deregister.message": { + "message": "Biztosan le szeretné választani ezt a profilt?\nÚjra be kell jelentkeznie a használatához." + }, + "profile.deregister.confirm": { + "message": "Leválasztás" + }, + "profile.delete.title": { + "message": "\"{name}\" profil törlése?" + }, + "profile.delete.message": { + "message": "Biztosan törölni szeretné ezt a profilt?\nEz a művelet nem vonható vissza." + }, + "profile.delete.disabledActive": { + "message": "Aktív profilok nem törölhetők. Váltson másik profilra, mielőtt törölné ezt." + }, + "profile.delete.disabledDefault": { + "message": "Az alapértelmezett profil nem törölhető." + }, + "profile.error.switchTitle": { + "message": "Profilváltás sikertelen" + }, + "profile.error.deregisterTitle": { + "message": "Leválasztás sikertelen" + }, + "profile.error.deleteTitle": { + "message": "Profil törlése sikertelen" + }, + "profile.error.createTitle": { + "message": "Profil létrehozása sikertelen" + }, + "profile.error.editTitle": { + "message": "Profil szerkesztése sikertelen" + }, + "profile.error.loadTitle": { + "message": "Profilok betöltése sikertelen" + }, + "profile.dropdown.activeProfile": { + "message": "Aktív profil" + }, + "profile.dropdown.switchProfile": { + "message": "Profilváltás" + }, + "profile.dropdown.noEmail": { + "message": "Egyéb" + }, + "profile.dropdown.addProfile": { + "message": "Profil hozzáadása" + }, + "profile.dropdown.manageProfiles": { + "message": "Profilok kezelése" + }, + "profile.dropdown.settings": { + "message": "Beállítások" + }, + "settings.profiles.section.profiles": { + "message": "Profilok" + }, + "settings.profiles.intro": { + "message": "Kezeljen több NetBird-profilt párhuzamosan, például munkahelyi és személyes fiókokat, vagy különböző felügyeleti szervereket. Lent hozzáadhat, leválaszthat vagy törölhet profilokat." + }, + "settings.profiles.addProfile": { + "message": "Profil hozzáadása" + }, + "settings.profiles.active": { + "message": "Aktív" + }, + "settings.profiles.emptyTitle": { + "message": "Nincsenek profilok" + }, + "settings.profiles.emptyDescription": { + "message": "Hozzon létre egy profilt a NetBird felügyeleti szerverhez való csatlakozáshoz." + }, + "settings.error.loadTitle": { + "message": "Beállítások betöltése sikertelen" + }, + "settings.error.saveTitle": { + "message": "Beállítások mentése sikertelen" + }, + "settings.error.debugBundleTitle": { + "message": "Hibakeresési csomag sikertelen" + }, + "settings.tabs.general": { + "message": "Általános" + }, + "settings.tabs.network": { + "message": "Hálózat" + }, + "settings.tabs.security": { + "message": "Biztonság" + }, + "settings.tabs.profiles": { + "message": "Profilok" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Speciális" + }, + "settings.tabs.troubleshooting": { + "message": "Hibaelhárítás" + }, + "settings.tabs.about": { + "message": "Névjegy" + }, + "settings.tabs.updateAvailable": { + "message": "Frissítés elérhető" + }, + "settings.general.section.general": { + "message": "Általános" + }, + "settings.general.section.connection": { + "message": "Kapcsolat" + }, + "settings.general.connectOnStartup.label": { + "message": "Csatlakozás indításkor" + }, + "settings.general.connectOnStartup.help": { + "message": "A szolgáltatás indulásakor automatikusan kapcsolatot létesít." + }, + "settings.general.notifications.label": { + "message": "Asztali értesítések" + }, + "settings.general.notifications.help": { + "message": "Asztali értesítések megjelenítése új frissítésekről és kapcsolati eseményekről." + }, + "settings.general.autostart.label": { + "message": "NetBird UI indítása bejelentkezéskor" + }, + "settings.general.autostart.help": { + "message": "A NetBird felület automatikus indítása bejelentkezéskor. Ez csak a grafikus felületet érinti, a háttérszolgáltatást nem." + }, + "settings.general.autostart.errorTitle": { + "message": "Az automatikus indítás módosítása sikertelen" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Kapcsolat megtartása kilépéskor", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Megjelenítési nyelv" + }, + "settings.general.language.help": { + "message": "Válassza ki a NetBird felület nyelvét." + }, + "settings.general.language.search": { + "message": "Nyelv keresése…" + }, + "settings.general.language.empty": { + "message": "Nincs találat." + }, + "settings.general.management.label": { + "message": "Felügyeleti szerver" + }, + "settings.general.management.help": { + "message": "Csatlakozás a NetBird Cloudhoz vagy saját üzemeltetésű felügyeleti szerverhez. A módosítások újracsatlakozást váltanak ki." + }, + "settings.general.management.cloud": { + "message": "Felhő" + }, + "settings.general.management.selfHosted": { + "message": "Saját üzemeltetésű" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy mentse el, ha biztos benne, hogy helyes." + }, + "settings.general.management.switchCloudTitle": { + "message": "Átváltás a NetBird Cloudra?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Ez leválasztja a saját üzemeltetésű kiszolgálót.\nLehet, hogy újra be kell jelentkeznie." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Váltás a felhőre" + }, + "settings.network.section.connectivity": { + "message": "Kapcsolódás" + }, + "settings.network.section.routingDns": { + "message": "Útválasztás és DNS" + }, + "settings.network.monitor.label": { + "message": "Újracsatlakozás hálózatváltáskor" + }, + "settings.network.monitor.help": { + "message": "A hálózat figyelése és automatikus újracsatlakozás változások (pl. Wi-Fi-váltás, Ethernet-változás vagy alvó állapotból való visszatérés) esetén." + }, + "settings.network.dns.label": { + "message": "DNS engedélyezése" + }, + "settings.network.dns.help": { + "message": "A NetBird által kezelt DNS-beállítások alkalmazása a gazda DNS-feloldójára." + }, + "settings.network.clientRoutes.label": { + "message": "Kliens útvonalak engedélyezése" + }, + "settings.network.clientRoutes.help": { + "message": "Más Peerek útvonalainak elfogadása a hálózataik eléréséhez." + }, + "settings.network.serverRoutes.label": { + "message": "Szerver útvonalak engedélyezése" + }, + "settings.network.serverRoutes.help": { + "message": "Ennek a gazdának a helyi útvonalainak meghirdetése más Peerek számára." + }, + "settings.network.ipv6.label": { + "message": "IPv6 engedélyezése" + }, + "settings.network.ipv6.help": { + "message": "IPv6-címzés használata a NetBird overlay hálózathoz." + }, + "settings.security.section.firewall": { + "message": "Tűzfal" + }, + "settings.security.section.encryption": { + "message": "Titkosítás" + }, + "settings.security.blockInbound.label": { + "message": "Bejövő forgalom blokkolása" + }, + "settings.security.blockInbound.help": { + "message": "Visszautasítja a Peerektől érkező nem kért kapcsolatokat ezen eszközhöz és az általa irányított hálózatokhoz. A kimenő forgalmat nem érinti." + }, + "settings.security.blockLan.label": { + "message": "LAN-hozzáférés blokkolása" + }, + "settings.security.blockLan.help": { + "message": "Megakadályozza, hogy a Peerek elérjék a helyi hálózatot vagy annak eszközeit, amikor ez az eszköz irányítja a forgalmukat." + }, + "settings.security.rosenpass.label": { + "message": "Kvantumellenálló titkosítás engedélyezése" + }, + "settings.security.rosenpass.help": { + "message": "Post-kvantum kulcscsere hozzáadása Rosenpass segítségével a WireGuard® tetejére." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Engedékeny mód engedélyezése" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Kapcsolatok engedélyezése kvantumellenálló titkosítás nélküli Peerekkel." + }, + "settings.ssh.section.server": { + "message": "Szerver" + }, + "settings.ssh.section.capabilities": { + "message": "Képességek" + }, + "settings.ssh.section.authentication": { + "message": "Hitelesítés" + }, + "settings.ssh.server.label": { + "message": "SSH szerver engedélyezése" + }, + "settings.ssh.server.help": { + "message": "Futtassa a NetBird SSH szervert ezen a gazdán, hogy más Peerek csatlakozhassanak." + }, + "settings.ssh.root.label": { + "message": "Root bejelentkezés engedélyezése" + }, + "settings.ssh.root.help": { + "message": "A Peerek bejelentkezhetnek root felhasználóként. Tiltsa le, ha nem privilegizált fiók szükséges." + }, + "settings.ssh.sftp.label": { + "message": "SFTP engedélyezése" + }, + "settings.ssh.sftp.help": { + "message": "Fájlok biztonságos átvitele natív SFTP- vagy SCP-kliensekkel." + }, + "settings.ssh.localForward.label": { + "message": "Helyi porttovábbítás" + }, + "settings.ssh.localForward.help": { + "message": "A csatlakozó Peerek helyi portokat alagútba helyezhetnek erről a gazdáról elérhető szolgáltatásokhoz." + }, + "settings.ssh.remoteForward.label": { + "message": "Távoli porttovábbítás" + }, + "settings.ssh.remoteForward.help": { + "message": "A csatlakozó Peerek ezen a gazdán lévő portokat tehetnek elérhetővé a saját gépük számára." + }, + "settings.ssh.jwt.label": { + "message": "JWT-hitelesítés engedélyezése" + }, + "settings.ssh.jwt.help": { + "message": "Minden SSH-munkamenet ellenőrzése az IdP-vel a felhasználói identitás és audit céljából. Tiltsa le, ha csak a hálózati ACL-szabályokra kíván támaszkodni — hasznos, ha nincs elérhető IdP." + }, + "settings.ssh.jwtTtl.label": { + "message": "JWT gyorsítótár TTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "Mennyi ideig őrzi meg a kliens a JWT-t, mielőtt újra kérné a kimenő SSH-kapcsolatoknál. 0 érték esetén a gyorsítótárazás kikapcsol, és minden kapcsolatnál hitelesít." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "másodperc" + }, + "settings.advanced.section.interface": { + "message": "Interfész" + }, + "settings.advanced.section.security": { + "message": "Biztonság" + }, + "settings.advanced.interfaceName.label": { + "message": "Név" + }, + "settings.advanced.interfaceName.error": { + "message": "Használjon 1–15 betűt, számot, pontot, kötőjelet vagy aláhúzást." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "„utun” után számmal kezdődjön (pl. utun100)." + }, + "settings.advanced.port.label": { + "message": "Port" + }, + "settings.advanced.port.error": { + "message": "Adjon meg egy portot {min} és {max} között." + }, + "settings.advanced.port.help": { + "message": "Ha 0-ra állítja, egy véletlenszerű szabad portot használ." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Adjon meg egy MTU értéket {min} és {max} között." + }, + "settings.advanced.psk.label": { + "message": "Pre-shared kulcs" + }, + "settings.advanced.psk.help": { + "message": "Opcionális WireGuard PSK további szimmetrikus titkosításhoz. Nem azonos a NetBird telepítőkulccsal. Csak olyan Peerekkel kommunikál, akik ugyanazt a pre-shared kulcsot használják." + }, + "settings.troubleshooting.section.title": { + "message": "Hibakeresési csomag" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Érzékeny információk anonimizálása" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Elrejti az IP-címeket, a tartományokat és más érzékeny értékeket." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Az Alapértelmezett szint a belső IPv4-címeket és a peer-neveket olvashatóan hagyja a támogatás számára. A Szigorú ezen felül anonimizálja a privát (RFC 1918), CGNAT és link-local IP-címeket, a peer-neveket és a WireGuard nyilvános kulcsokat. Az ismétlődő értékek ugyanazt a helyettesítőt kapják, így a peerek megkülönböztethetők maradnak. Használja a Szigorú szintet, ha a hibakeresési csomagot a szervezetén kívül osztja meg." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Nincs" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Alapértelmezett" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Szigorú" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Rendszerinformációk beillesztése" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Tartalmazza az OS-t, a kernelt, a hálózati interfészeket és az útválasztási táblákat." + }, + "settings.troubleshooting.upload.label": { + "message": "Csomag feltöltése a NetBird szerverekre" + }, + "settings.troubleshooting.upload.help": { + "message": "Egy feltöltési kulcsot ad vissza, amelyet megoszthat a NetBird támogatással." + }, + "settings.troubleshooting.trace.label": { + "message": "Trace naplók engedélyezése" + }, + "settings.troubleshooting.trace.help": { + "message": "TRACE szintre emeli a naplózást, majd utána visszaállítja." + }, + "settings.troubleshooting.capture.label": { + "message": "Rögzítési munkamenet" + }, + "settings.troubleshooting.capture.help": { + "message": "Újra csatlakozik és vár, hogy reprodukálhassa a problémát." + }, + "settings.troubleshooting.packets.label": { + "message": "Hálózati csomagok rögzítése" + }, + "settings.troubleshooting.packets.help": { + "message": "A rögzítés ideje alatt elmenti a hálózati forgalom .pcap fájlját." + }, + "settings.troubleshooting.duration.label": { + "message": "Rögzítés időtartama" + }, + "settings.troubleshooting.duration.help": { + "message": "Mennyi ideig fusson a rögzítési munkamenet." + }, + "settings.troubleshooting.duration.suffix": { + "message": "perc" + }, + "settings.troubleshooting.create": { + "message": "Hibakeresési csomag létrehozása" + }, + "settings.troubleshooting.progress.description": { + "message": "Naplók, rendszerinformációk és kapcsolati állapot gyűjtése folyamatban. Általában néhány pillanatot vesz igénybe — tartsa nyitva ezt az ablakot a befejezésig." + }, + "settings.troubleshooting.cancelling": { + "message": "Megszakítás…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "A hibakeresési csomag feltöltése sikeres!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Csomag elmentve" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Ossza meg az alábbi feltöltési kulcsot a NetBird támogatással. A helyi másolat is elmentve van az eszközén." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "A hibakeresési csomag helyileg elmentve." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Kulcs másolása" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Mappa megnyitása" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Fájl helyének megnyitása" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Feltöltés sikertelen: {reason} A csomag továbbra is el van mentve helyileg." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Feltöltés sikertelen. A csomag továbbra is el van mentve helyileg." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "NetBird újracsatlakoztatása…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Hibakeresési naplók rögzítése" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Hibakeresési csomag generálása…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Feltöltés a NetBirdhöz…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Megszakítás…" + }, + "settings.about.client": { + "message": "NetBird Kliens v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Kliens" + }, + "settings.about.development": { + "message": "[Fejlesztés]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Minden jog fenntartva." + }, + "settings.about.links.imprint": { + "message": "Impresszum" + }, + "settings.about.links.privacy": { + "message": "Adatvédelem" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Felhasználási feltételek" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Fórum" + }, + "settings.about.community.documentation": { + "message": "Dokumentáció" + }, + "settings.about.community.feedback": { + "message": "Visszajelzés" + }, + "update.banner.message": { + "message": "A NetBird {version} telepítésre kész." + }, + "update.banner.later": { + "message": "Később" + }, + "update.banner.installNow": { + "message": "Telepítés most" + }, + "update.card.versionAvailableDownload": { + "message": "A {version} verzió letöltésre elérhető." + }, + "update.card.versionAvailableInstall": { + "message": "A {version} verzió telepítésre elérhető." + }, + "update.card.whatsNew": { + "message": "Mi az újdonság?" + }, + "update.card.installNow": { + "message": "Telepítés most" + }, + "update.card.getInstaller": { + "message": "Letöltés" + }, + "update.card.autoCheckInterval": { + "message": "A NetBird a háttérben keres frissítéseket." + }, + "update.card.changelog": { + "message": "Változásnapló" + }, + "update.card.onLatestVersion": { + "message": "A legfrissebb verziót használja" + }, + "update.header.tooltip": { + "message": "Frissítés elérhető" + }, + "update.overlay.updatingVersion": { + "message": "NetBird frissítése a következőre: v{version}" + }, + "update.overlay.updating": { + "message": "NetBird frissítése" + }, + "update.overlay.description": { + "message": "Egy újabb verzió elérhető és települ. A NetBird automatikusan újraindul a frissítés befejeztével." + }, + "update.overlay.error.timeoutTitle": { + "message": "A frissítés túl sokáig tart" + }, + "update.overlay.error.timeoutDescription": { + "message": "A(z) {target} telepítése túl sokáig tartott, és nem fejeződött be." + }, + "update.overlay.error.canceledTitle": { + "message": "A frissítés megszakítva" + }, + "update.overlay.error.canceledDescription": { + "message": "A(z) {target} frissítését megszakították a befejezés előtt." + }, + "update.overlay.error.failTitle": { + "message": "A frissítés nem telepíthető" + }, + "update.overlay.error.failDescription": { + "message": "A(z) {target} nem volt telepíthető." + }, + "update.overlay.error.unknownMessage": { + "message": "ismeretlen hiba" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "az új verzió" + }, + "update.error.loadStateTitle": { + "message": "Frissítési állapot betöltése sikertelen" + }, + "update.error.triggerTitle": { + "message": "Frissítés indítása sikertelen" + }, + "update.page.versionLine": { + "message": "Kliens frissítése erre: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Kliens frissítése." + }, + "update.page.outdated": { + "message": "Az Ön kliensverziója régebbi, mint a Managementben beállított automatikus frissítési verzió." + }, + "update.page.status.running": { + "message": "Frissítés" + }, + "update.page.status.timeout": { + "message": "A frissítés időtúllépés miatt megszakadt. Kérjük, próbálja újra." + }, + "update.page.status.canceled": { + "message": "Frissítés megszakítva." + }, + "update.page.status.failed": { + "message": "Frissítés sikertelen: {message}" + }, + "update.page.status.unknownError": { + "message": "ismeretlen frissítési hiba" + }, + "update.page.failedTitle": { + "message": "Frissítés sikertelen" + }, + "update.page.timeoutMessage": { + "message": "Frissítés időtúllépés." + }, + "update.page.dontClose": { + "message": "Kérjük, ne zárja be ezt az ablakot." + }, + "update.page.updating": { + "message": "Frissítés…" + }, + "update.page.complete": { + "message": "Frissítés kész" + }, + "update.page.failed": { + "message": "Frissítés sikertelen" + }, + "window.title.settings": { + "message": "Beállítások" + }, + "window.title.signIn": { + "message": "Bejelentkezés" + }, + "window.title.sessionExpiration": { + "message": "Munkamenet lejár" + }, + "window.title.updating": { + "message": "Frissítés" + }, + "window.title.welcome": { + "message": "Üdvözli a NetBird" + }, + "window.title.error": { + "message": "Hiba" + }, + "welcome.title": { + "message": "Keresse a NetBirdöt a tálcán" + }, + "welcome.titleMac": { + "message": "Keresse a NetBirdöt a menüsorban" + }, + "welcome.description": { + "message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához." + }, + "welcome.descriptionMac": { + "message": "A NetBird a menüsorban fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához." + }, + "welcome.continue": { + "message": "Folytatás" + }, + "welcome.back": { + "message": "Vissza" + }, + "welcome.management.title": { + "message": "NetBird beállítása" + }, + "welcome.management.description": { + "message": "Kattintson a Folytatás gombra a kezdéshez, vagy válassza a „Saját üzemeltetésű” lehetőséget, ha saját NetBird-szervere van." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Használja az általunk üzemeltetett szolgáltatást. Nincs szükség beállításra." + }, + "welcome.management.selfHosted.title": { + "message": "Saját üzemeltetésű" + }, + "welcome.management.selfHosted.description": { + "message": "Csatlakozás a saját felügyeleti szerveréhez." + }, + "welcome.management.urlLabel": { + "message": "Felügyeleti szerver URL" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "A szerver nem érhető el. Ellenőrizze az URL-t vagy a hálózatot, majd folytassa, ha biztos benne, hogy helyes." + }, + "welcome.management.checking": { + "message": "Ellenőrzés…" + }, + "browserLogin.title": { + "message": "Folytassa a böngészőben a bejelentkezés befejezéséhez" + }, + "browserLogin.notSeeing": { + "message": "Nem látja a böngésző fülét?" + }, + "browserLogin.tryAgain": { + "message": "Próbálja újra" + }, + "browserLogin.openFailedTitle": { + "message": "A böngésző megnyitása sikertelen" + }, + "sessionExpiration.title": { + "message": "A munkamenet hamarosan lejár" + }, + "sessionExpiration.titleLater": { + "message": "A munkamenete lejár" + }, + "sessionExpiration.description": { + "message": "Az eszköz hamarosan lecsatlakozik. Megújításhoz böngészős bejelentkezés kell." + }, + "sessionExpiration.descriptionLater": { + "message": "Egy böngészős bejelentkezés a hálózaton tartja az eszközt." + }, + "sessionExpiration.stay": { + "message": "Munkamenet megújítása" + }, + "sessionExpiration.authenticate": { + "message": "Bejelentkezés" + }, + "sessionExpiration.logout": { + "message": "Kijelentkezés" + }, + "sessionExpiration.expired": { + "message": "Munkamenet lejárt" + }, + "sessionExpiration.expiredDescription": { + "message": "Eszköz lecsatlakozott. Hitelesítés böngészős bejelentkezéssel az újracsatlakozáshoz." + }, + "sessionExpiration.close": { + "message": "Bezárás" + }, + "sessionExpiration.extendFailedTitle": { + "message": "A munkamenet meghosszabbítása sikertelen" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Kijelentkezés sikertelen" + }, + "peers.search.placeholder": { + "message": "Keresés név vagy IP alapján" + }, + "peers.filter.all": { + "message": "Összes" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Nincs elérhető Peer" + }, + "peers.empty.description": { + "message": "Önnek vagy nincsenek elérhető Peerei, vagy nincs hozzáférése egyikhez sem." + }, + "peers.details.domain": { + "message": "Domain" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "Nyilvános kulcs" + }, + "peers.details.connection": { + "message": "Kapcsolat" + }, + "peers.details.latency": { + "message": "Késleltetés" + }, + "peers.details.lastHandshake": { + "message": "Utolsó Handshake" + }, + "peers.details.statusSince": { + "message": "Utolsó kapcsolati frissítés" + }, + "peers.details.bytes": { + "message": "Bájtok" + }, + "peers.details.bytesSent": { + "message": "Küldve" + }, + "peers.details.bytesReceived": { + "message": "Fogadva" + }, + "peers.details.localIce": { + "message": "Helyi ICE" + }, + "peers.details.remoteIce": { + "message": "Távoli ICE" + }, + "peers.details.never": { + "message": "Soha" + }, + "peers.details.justNow": { + "message": "Épp most" + }, + "peers.details.refresh": { + "message": "Frissítés" + }, + "peers.status.connected": { + "message": "Csatlakozva" + }, + "peers.status.connecting": { + "message": "Csatlakozás" + }, + "peers.status.disconnected": { + "message": "Lecsatlakozva" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Erőforrások" + }, + "peers.details.relayed": { + "message": "Relayed" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass engedélyezve" + }, + "networks.search.placeholder": { + "message": "Keresés hálózat vagy domain alapján" + }, + "networks.filter.all": { + "message": "Összes" + }, + "networks.filter.active": { + "message": "Aktív" + }, + "networks.filter.overlapping": { + "message": "Átfedő" + }, + "networks.empty.title": { + "message": "Nincs elérhető erőforrás" + }, + "networks.empty.description": { + "message": "Önnek vagy nincsenek elérhető hálózati erőforrásai vagy nincs hozzáférése egyikhez sem." + }, + "networks.selected": { + "message": "Kiválasztva" + }, + "networks.unselected": { + "message": "Nincs kiválasztva" + }, + "networks.ips.heading": { + "message": "Feloldott IP-címek" + }, + "networks.bulk.selectionCount": { + "message": "{selected} / {total} aktív" + }, + "networks.bulk.enableAll": { + "message": "Összes engedélyezése" + }, + "networks.bulk.disableAll": { + "message": "Összes letiltása" + }, + "exitNodes.search.placeholder": { + "message": "Keresés az Exit Node-ok között" + }, + "exitNodes.none": { + "message": "Egyik sem" + }, + "exitNodes.empty.title": { + "message": "Nincs elérhető Exit Node" + }, + "exitNodes.empty.description": { + "message": "Ehhez a Peerhez nem osztottak meg Exit Node-okat." + }, + "exitNodes.card.title": { + "message": "Exit Node" + }, + "exitNodes.card.statusActive": { + "message": "Aktív" + }, + "exitNodes.card.statusInactive": { + "message": "Inaktív" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Egyik sem" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Közvetlen kapcsolat Exit Node nélkül" + }, + "quickActions.connect": { + "message": "Csatlakozás" + }, + "quickActions.disconnect": { + "message": "Bontás" + }, + "daemon.unavailable.title": { + "message": "A NetBird szolgáltatás nem fut" + }, + "daemon.unavailable.description": { + "message": "Az alkalmazás automatikusan újracsatlakozik, amint a szolgáltatás újra elérhető." + }, + "daemon.unavailable.docsLink": { + "message": "Dokumentáció" + }, + "daemon.outdated.title": { + "message": "A NetBird Kliens elavult" + }, + "daemon.outdated.description": { + "message": "Az új GUI nem kompatibilis a régebbi klienseddel. Frissítsd a klienst az új alkalmazás használatához." + }, + "daemon.outdated.download": { + "message": "Legújabb letöltése" + }, + "error.jwt_clock_skew": { + "message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra." + }, + "error.jwt_expired": { + "message": "A bejelentkezési token lejárt. Kérjük, jelentkezzen be újra." + }, + "error.jwt_signature_invalid": { + "message": "A bejelentkezés sikertelen: a token aláírása érvénytelen. Kérjük, lépjen kapcsolatba a rendszergazdával." + }, + "error.session_expired": { + "message": "A munkamenet lejárt. Kérjük, jelentkezzen be újra." + }, + "error.invalid_setup_key": { + "message": "A telepítőkulcs hiányzik vagy érvénytelen." + }, + "error.permission_denied": { + "message": "A szerver elutasította a bejelentkezést." + }, + "error.daemon_unreachable": { + "message": "A NetBird szolgáltatás nem válaszol. Kérjük, ellenőrizze, hogy fut-e a szolgáltatás." + }, + "error.unknown": { + "message": "A művelet meghiúsult." + }, + "settings.ssh.privilege.hint": { + "message": "{actor} szükséges hozzá. Futtassa inkább ezt:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges:" + } +} diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json new file mode 100644 index 000000000..dab9e0cb4 --- /dev/null +++ b/client/ui/i18n/locales/it/common.json @@ -0,0 +1,1363 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Disconnesso" + }, + "tray.status.daemonUnavailable": { + "message": "Non in esecuzione" + }, + "tray.status.error": { + "message": "Errore" + }, + "tray.status.connected": { + "message": "Connesso" + }, + "tray.status.connecting": { + "message": "Connessione" + }, + "tray.status.needsLogin": { + "message": "Accesso richiesto" + }, + "tray.status.loginFailed": { + "message": "Accesso non riuscito" + }, + "tray.status.sessionExpired": { + "message": "Sessione scaduta" + }, + "tray.session.expiresIn": { + "message": "La sessione scade tra {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "meno di un minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minuti" + }, + "tray.session.unit.hour": { + "message": "1 ora" + }, + "tray.session.unit.hours": { + "message": "{count} ore" + }, + "tray.session.unit.day": { + "message": "1 giorno" + }, + "tray.session.unit.days": { + "message": "{count} giorni" + }, + "tray.menu.open": { + "message": "Apri NetBird" + }, + "tray.menu.connect": { + "message": "Connetti" + }, + "tray.menu.disconnect": { + "message": "Disconnetti" + }, + "tray.menu.exitNode": { + "message": "Nodo di uscita" + }, + "tray.menu.networks": { + "message": "Risorse" + }, + "tray.menu.profiles": { + "message": "Profili" + }, + "tray.menu.manageProfiles": { + "message": "Gestisci profili" + }, + "tray.menu.settings": { + "message": "Impostazioni..." + }, + "tray.menu.debugBundle": { + "message": "Crea pacchetto di debug" + }, + "tray.menu.about": { + "message": "Guida e supporto" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentazione" + }, + "tray.menu.troubleshoot": { + "message": "Risoluzione dei problemi" + }, + "tray.menu.downloadLatest": { + "message": "Scarica l'ultima versione" + }, + "tray.menu.installVersion": { + "message": "Installa la versione {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Esci da NetBird" + }, + "notify.daemonOutdated.title": { + "message": "Il servizio NetBird è obsoleto" + }, + "notify.daemonOutdated.body": { + "message": "Aggiorna il servizio NetBird per usare questa app." + }, + "notify.update.title": { + "message": "Aggiornamento NetBird disponibile" + }, + "notify.update.body": { + "message": "NetBird {version} è disponibile." + }, + "notify.update.enforcedSuffix": { + "message": " L'amministratore richiede questo aggiornamento." + }, + "notify.error.title": { + "message": "Errore" + }, + "notify.error.connect": { + "message": "Connessione non riuscita" + }, + "notify.error.disconnect": { + "message": "Disconnessione non riuscita" + }, + "notify.error.switchProfile": { + "message": "Impossibile passare a {profile}" + }, + "notify.error.exitNode": { + "message": "Impossibile aggiornare il nodo di uscita {name}" + }, + "notify.sessionExpired.title": { + "message": "Sessione NetBird scaduta" + }, + "notify.sessionExpired.body": { + "message": "La sessione NetBird è scaduta. Effettui di nuovo l'accesso." + }, + "notify.sessionWarning.title": { + "message": "La sessione sta per scadere" + }, + "notify.sessionWarning.body": { + "message": "La sessione NetBird scade tra {remaining}. Clicchi su Rinnova ora per rinnovarla." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "La sessione NetBird sta per scadere. Clicchi su Rinnova ora per rinnovarla." + }, + "notify.sessionWarning.extend": { + "message": "Rinnova ora" + }, + "notify.sessionWarning.dismiss": { + "message": "Ignora" + }, + "notify.sessionWarning.failed": { + "message": "Impossibile rinnovare la sessione NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sessione NetBird rinnovata" + }, + "notify.sessionWarning.successBody": { + "message": "La sessione è stata rinnovata." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Scadenza sessione rifiutata" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Il server ha inviato una scadenza di sessione non valida. Effettui di nuovo l'accesso." + }, + "notify.mdm.policyApplied.title": { + "message": "Impostazioni NetBird aggiornate" + }, + "notify.mdm.policyApplied.body": { + "message": "La configurazione di NetBird è stata aggiornata dalla policy IT." + }, + "common.cancel": { + "message": "Annulla" + }, + "common.save": { + "message": "Salva" + }, + "common.saveChanges": { + "message": "Salva modifiche" + }, + "common.saving": { + "message": "Salvataggio…" + }, + "common.close": { + "message": "Chiudi" + }, + "common.copy": { + "message": "Copia" + }, + "common.togglePasswordVisibility": { + "message": "Mostra/nascondi password" + }, + "common.increase": { + "message": "Aumenta" + }, + "common.decrease": { + "message": "Diminuisci" + }, + "common.delete": { + "message": "Elimina" + }, + "common.create": { + "message": "Crea" + }, + "common.add": { + "message": "Aggiungi" + }, + "common.remove": { + "message": "Rimuovi" + }, + "common.refresh": { + "message": "Aggiorna" + }, + "common.loading": { + "message": "Caricamento…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Nessun risultato trovato" + }, + "common.noResults.description": { + "message": "Non è stato trovato alcun risultato. Provi un altro termine di ricerca o modifichi i filtri." + }, + "notConnected.title": { + "message": "Disconnesso" + }, + "notConnected.description": { + "message": "Si connetta prima a NetBird per visualizzare informazioni dettagliate su peer, risorse di rete e nodi di uscita." + }, + "connect.status.disconnected": { + "message": "Disconnesso" + }, + "connect.status.connecting": { + "message": "Connessione..." + }, + "connect.status.connected": { + "message": "Connesso" + }, + "connect.status.disconnecting": { + "message": "Disconnessione..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon non disponibile" + }, + "connect.status.loginRequired": { + "message": "Accesso richiesto" + }, + "connect.error.loginTitle": { + "message": "Accesso non riuscito" + }, + "connect.error.connectTitle": { + "message": "Connessione non riuscita" + }, + "connect.error.disconnectTitle": { + "message": "Disconnessione non riuscita" + }, + "nav.peers.title": { + "message": "Peer" + }, + "nav.peers.description": { + "message": "{connected} di {total} connessi" + }, + "nav.resources.title": { + "message": "Risorse" + }, + "nav.resources.description": { + "message": "{active} di {total} attive" + }, + "nav.exitNode.title": { + "message": "Nodi di uscita" + }, + "nav.exitNode.none": { + "message": "Non attivo" + }, + "nav.exitNode.using": { + "message": "Tramite {name}" + }, + "header.openSettings": { + "message": "Apri impostazioni" + }, + "header.togglePanel": { + "message": "Mostra/nascondi pannello laterale" + }, + "profile.selector.loading": { + "message": "Caricamento..." + }, + "profile.selector.noProfile": { + "message": "Nessun profilo" + }, + "profile.selector.searchPlaceholder": { + "message": "Cerca profilo per nome..." + }, + "profile.selector.emptyTitle": { + "message": "Nessun profilo trovato" + }, + "profile.selector.emptyDescription": { + "message": "Provi un altro termine di ricerca o crei un nuovo profilo." + }, + "profile.selector.newProfile": { + "message": "Nuovo profilo" + }, + "profile.selector.moreOptions": { + "message": "Altre opzioni" + }, + "profile.selector.deregister": { + "message": "Annulla registrazione" + }, + "profile.selector.delete": { + "message": "Elimina" + }, + "profile.selector.switchTo": { + "message": "Passa a questo profilo" + }, + "profile.selector.edit": { + "message": "Modifica" + }, + "profile.edit.title": { + "message": "Modifica profilo" + }, + "profile.edit.submit": { + "message": "Salva modifiche" + }, + "profile.dialog.title": { + "message": "Inserisci il nome del profilo" + }, + "profile.dialog.nameLabel": { + "message": "Nome del profilo" + }, + "profile.dialog.description": { + "message": "Imposti un nome facilmente riconoscibile per il profilo." + }, + "profile.dialog.placeholder": { + "message": "es. lavoro" + }, + "profile.dialog.submit": { + "message": "Aggiungi profilo" + }, + "profile.dialog.required": { + "message": "Inserisca un nome per il profilo, es. lavoro, casa" + }, + "profile.dialog.managementHelp": { + "message": "Usi NetBird Cloud o il suo server." + }, + "profile.dialog.urlUnreachable": { + "message": "Impossibile raggiungere questo server. Controlli l'URL, oppure aggiunga comunque il profilo se è certo che sia corretto." + }, + "header.menu.settings": { + "message": "Impostazioni..." + }, + "header.menu.defaultView": { + "message": "Vista predefinita" + }, + "header.menu.advancedView": { + "message": "Vista avanzata" + }, + "header.menu.updateAvailable": { + "message": "Aggiornamento disponibile" + }, + "header.menu.open": { + "message": "Apri menu" + }, + "header.profile.switch": { + "message": "Cambia profilo" + }, + "connect.toggle.label": { + "message": "Attiva/disattiva connessione NetBird" + }, + "connect.localIp.label": { + "message": "Indirizzi IP locali" + }, + "common.search": { + "message": "Cerca" + }, + "common.filter": { + "message": "Filtra" + }, + "exitNodes.dropdown.trigger": { + "message": "Seleziona nodo di uscita" + }, + "peers.row.label": { + "message": "Apri dettagli di {name}, {status}" + }, + "peers.dialog.title": { + "message": "Dettagli peer" + }, + "networks.row.toggle": { + "message": "Attiva/disattiva {name}" + }, + "networks.bulk.label": { + "message": "Attiva/disattiva tutte le risorse visibili" + }, + "settings.nav.label": { + "message": "Sezioni delle impostazioni" + }, + "profile.switch.title": { + "message": "Passare al profilo «{name}»?" + }, + "profile.switch.message": { + "message": "Vuole davvero cambiare profilo?\nIl profilo attuale verrà disconnesso." + }, + "profile.switch.confirm": { + "message": "Conferma" + }, + "profile.deregister.title": { + "message": "Annullare la registrazione del profilo «{name}»?" + }, + "profile.deregister.message": { + "message": "Vuole davvero annullare la registrazione di questo profilo?\nDovrà accedere di nuovo per usarlo." + }, + "profile.deregister.confirm": { + "message": "Annulla registrazione" + }, + "profile.delete.title": { + "message": "Eliminare il profilo «{name}»?" + }, + "profile.delete.message": { + "message": "Vuole davvero eliminare questo profilo?\nQuesta azione non può essere annullata." + }, + "profile.delete.disabledActive": { + "message": "I profili attivi non possono essere eliminati. Passi a un altro profilo prima di eliminare questo." + }, + "profile.delete.disabledDefault": { + "message": "Il profilo predefinito non può essere eliminato." + }, + "profile.error.switchTitle": { + "message": "Cambio profilo non riuscito" + }, + "profile.error.deregisterTitle": { + "message": "Annullamento registrazione non riuscito" + }, + "profile.error.deleteTitle": { + "message": "Eliminazione profilo non riuscita" + }, + "profile.error.createTitle": { + "message": "Creazione profilo non riuscita" + }, + "profile.error.editTitle": { + "message": "Modifica del profilo non riuscita" + }, + "profile.error.loadTitle": { + "message": "Caricamento profili non riuscito" + }, + "profile.dropdown.activeProfile": { + "message": "Profilo attivo" + }, + "profile.dropdown.switchProfile": { + "message": "Cambia profilo" + }, + "profile.dropdown.noEmail": { + "message": "Altro" + }, + "profile.dropdown.addProfile": { + "message": "Aggiungi profilo" + }, + "profile.dropdown.manageProfiles": { + "message": "Gestisci profili" + }, + "profile.dropdown.settings": { + "message": "Impostazioni" + }, + "settings.profiles.section.profiles": { + "message": "Profili" + }, + "settings.profiles.intro": { + "message": "Mantenga affiancate identità NetBird separate, ad esempio account di lavoro e personali, oppure server di gestione diversi. Aggiunga, annulli la registrazione o elimini i profili qui sotto." + }, + "settings.profiles.addProfile": { + "message": "Aggiungi profilo" + }, + "settings.profiles.active": { + "message": "Attivo" + }, + "settings.profiles.emptyTitle": { + "message": "Nessun profilo" + }, + "settings.profiles.emptyDescription": { + "message": "Crei un profilo per connettersi a un server di gestione NetBird." + }, + "settings.error.loadTitle": { + "message": "Caricamento impostazioni non riuscito" + }, + "settings.error.saveTitle": { + "message": "Salvataggio impostazioni non riuscito" + }, + "settings.error.debugBundleTitle": { + "message": "Pacchetto di debug non riuscito" + }, + "settings.tabs.general": { + "message": "Generale" + }, + "settings.tabs.network": { + "message": "Rete" + }, + "settings.tabs.security": { + "message": "Sicurezza" + }, + "settings.tabs.profiles": { + "message": "Profili" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avanzate" + }, + "settings.tabs.troubleshooting": { + "message": "Risoluzione problemi" + }, + "settings.tabs.about": { + "message": "Informazioni" + }, + "settings.tabs.updateAvailable": { + "message": "Aggiornamento disponibile" + }, + "settings.general.section.general": { + "message": "Generale" + }, + "settings.general.section.connection": { + "message": "Connessione" + }, + "settings.general.connectOnStartup.label": { + "message": "Connetti all'avvio" + }, + "settings.general.connectOnStartup.help": { + "message": "Stabilisce automaticamente una connessione all'avvio del servizio." + }, + "settings.general.notifications.label": { + "message": "Notifiche desktop" + }, + "settings.general.notifications.help": { + "message": "Mostra notifiche desktop per nuovi aggiornamenti ed eventi di connessione." + }, + "settings.general.autostart.label": { + "message": "Avvia l'interfaccia NetBird all'accesso" + }, + "settings.general.autostart.help": { + "message": "Avvia automaticamente l'interfaccia NetBird quando effettua l'accesso. Riguarda solo l'interfaccia grafica, non il servizio in background." + }, + "settings.general.autostart.errorTitle": { + "message": "Modifica avvio automatico non riuscita" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Resta connesso dopo la chiusura", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Lingua dell'interfaccia" + }, + "settings.general.language.help": { + "message": "Scelga la lingua dell'interfaccia NetBird." + }, + "settings.general.language.search": { + "message": "Cerca lingua…" + }, + "settings.general.language.empty": { + "message": "Nessuna lingua corrisponde." + }, + "settings.general.management.label": { + "message": "Server di gestione" + }, + "settings.general.management.help": { + "message": "Si connetta a NetBird Cloud o al suo server di gestione self-hosted. Le modifiche riconnettono il client." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Self-hosted" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Inserisca un URL valido, es. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Impossibile raggiungere questo server. Controlli l'URL, oppure salvi comunque se è certo che sia corretto." + }, + "settings.general.management.switchCloudTitle": { + "message": "Passare a NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Questo disconnette il suo server self-hosted.\nPotrebbe dover accedere di nuovo." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Passa a Cloud" + }, + "settings.network.section.connectivity": { + "message": "Connettività" + }, + "settings.network.section.routingDns": { + "message": "Routing e DNS" + }, + "settings.network.monitor.label": { + "message": "Riconnetti al cambio di rete" + }, + "settings.network.monitor.help": { + "message": "Monitora la rete e si riconnette automaticamente ai cambiamenti, come il passaggio di Wi-Fi, le modifiche Ethernet o la ripresa dalla sospensione." + }, + "settings.network.dns.label": { + "message": "Abilita DNS" + }, + "settings.network.dns.help": { + "message": "Applica le impostazioni DNS gestite da NetBird al resolver dell'host." + }, + "settings.network.clientRoutes.label": { + "message": "Abilita route client" + }, + "settings.network.clientRoutes.help": { + "message": "Accetta le route da altri peer per raggiungere le loro reti." + }, + "settings.network.serverRoutes.label": { + "message": "Abilita route server" + }, + "settings.network.serverRoutes.help": { + "message": "Annuncia le route locali di questo host agli altri peer." + }, + "settings.network.ipv6.label": { + "message": "Abilita IPv6" + }, + "settings.network.ipv6.help": { + "message": "Usa l'indirizzamento IPv6 per la rete overlay NetBird." + }, + "settings.security.section.firewall": { + "message": "Firewall" + }, + "settings.security.section.encryption": { + "message": "Crittografia" + }, + "settings.security.blockInbound.label": { + "message": "Blocca traffico in entrata" + }, + "settings.security.blockInbound.help": { + "message": "Rifiuta le connessioni non richieste dai peer verso questo dispositivo e le reti che instrada. Il traffico in uscita non è interessato." + }, + "settings.security.blockLan.label": { + "message": "Blocca accesso alla LAN" + }, + "settings.security.blockLan.help": { + "message": "Impedisce ai peer di raggiungere la sua rete locale o i suoi dispositivi quando questo dispositivo instrada il loro traffico." + }, + "settings.security.rosenpass.label": { + "message": "Abilita resistenza quantistica" + }, + "settings.security.rosenpass.help": { + "message": "Aggiunge uno scambio di chiavi post-quantistico tramite Rosenpass sopra WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Abilita modalità permissiva" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Consente le connessioni con peer privi del supporto alla resistenza quantistica." + }, + "settings.ssh.section.server": { + "message": "Server" + }, + "settings.ssh.section.capabilities": { + "message": "Funzionalità" + }, + "settings.ssh.section.authentication": { + "message": "Autenticazione" + }, + "settings.ssh.server.label": { + "message": "Abilita server SSH" + }, + "settings.ssh.server.help": { + "message": "Esegue il server SSH di NetBird su questo host in modo che altri peer possano connettersi." + }, + "settings.ssh.root.label": { + "message": "Consenti accesso come root" + }, + "settings.ssh.root.help": { + "message": "Permette ai peer di accedere come utente root. Disabiliti per richiedere un account senza privilegi." + }, + "settings.ssh.sftp.label": { + "message": "Consenti SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Trasferisca file in modo sicuro usando client SFTP o SCP nativi." + }, + "settings.ssh.localForward.label": { + "message": "Inoltro porte locale" + }, + "settings.ssh.localForward.help": { + "message": "Permette ai peer in connessione di inoltrare porte locali verso servizi raggiungibili da questo host." + }, + "settings.ssh.remoteForward.label": { + "message": "Inoltro porte remoto" + }, + "settings.ssh.remoteForward.help": { + "message": "Permette ai peer in connessione di esporre porte di questo host verso la loro macchina." + }, + "settings.ssh.jwt.label": { + "message": "Abilita autenticazione JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verifica ogni sessione SSH con il suo IdP per l'identità utente e l'audit. Disabiliti per basarsi solo sulle policy ACL di rete, utile quando non è disponibile un IdP." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL cache JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Per quanto tempo questo client memorizza un JWT prima di richiederlo di nuovo sulle connessioni SSH in uscita. Imposti 0 per disabilitare la cache e autenticarsi a ogni connessione." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "sec." + }, + "settings.advanced.section.interface": { + "message": "Interfaccia" + }, + "settings.advanced.section.security": { + "message": "Sicurezza" + }, + "settings.advanced.interfaceName.label": { + "message": "Nome" + }, + "settings.advanced.interfaceName.error": { + "message": "Usi da 1 a 15 lettere, cifre, punti, trattini o trattini bassi." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Deve iniziare con \"utun\" seguito da un numero (es. utun100)." + }, + "settings.advanced.port.label": { + "message": "Porta" + }, + "settings.advanced.port.error": { + "message": "Inserisca una porta compresa tra {min} e {max}." + }, + "settings.advanced.port.help": { + "message": "Se impostata su 0, verrà usata una porta libera casuale." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Inserisca un valore MTU compreso tra {min} e {max}." + }, + "settings.advanced.psk.label": { + "message": "Chiave pre-condivisa" + }, + "settings.advanced.psk.help": { + "message": "PSK WireGuard opzionale per una crittografia simmetrica aggiuntiva. Non è la stessa cosa di una chiave di configurazione NetBird. Comunicherà solo con i peer che usano la stessa chiave pre-condivisa." + }, + "settings.troubleshooting.section.title": { + "message": "Pacchetto di debug" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizza informazioni sensibili" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Nasconde indirizzi IP, domini e altri valori sensibili." + }, + "settings.troubleshooting.anonymize.info": { + "message": "La modalità predefinita mantiene leggibili gli indirizzi IPv4 interni e i nomi dei peer per il supporto. La modalità rigorosa anonimizza inoltre gli indirizzi IP privati (RFC 1918), CGNAT e link-local, i nomi dei peer e le chiavi pubbliche WireGuard. I valori ricorrenti vengono associati allo stesso segnaposto, quindi i peer restano distinguibili. Usa la modalità rigorosa quando condividi il pacchetto di debug al di fuori della tua organizzazione." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Nessuna" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Predefinito" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Rigoroso" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Includi informazioni di sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Include OS, kernel, interfacce di rete e tabelle di routing." + }, + "settings.troubleshooting.upload.label": { + "message": "Carica il pacchetto sui server NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Restituisce una chiave di caricamento da condividere con il supporto NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Abilita log di traccia" + }, + "settings.troubleshooting.trace.help": { + "message": "Aumenta il livello di log a TRACE e lo ripristina al termine." + }, + "settings.troubleshooting.capture.label": { + "message": "Sessione di acquisizione" + }, + "settings.troubleshooting.capture.help": { + "message": "Si riconnette e attende per consentirle di riprodurre il problema." + }, + "settings.troubleshooting.packets.label": { + "message": "Acquisisci pacchetti di rete" + }, + "settings.troubleshooting.packets.help": { + "message": "Salva un .pcap del traffico di rete durante la sessione di acquisizione." + }, + "settings.troubleshooting.duration.label": { + "message": "Durata acquisizione" + }, + "settings.troubleshooting.duration.help": { + "message": "Per quanto tempo viene eseguita la sessione di acquisizione." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min." + }, + "settings.troubleshooting.create": { + "message": "Crea pacchetto" + }, + "settings.troubleshooting.progress.description": { + "message": "Raccolta di log, dettagli di sistema e stato della connessione. Di solito richiede un momento: tenga aperta questa finestra fino al completamento." + }, + "settings.troubleshooting.cancelling": { + "message": "Annullamento…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Pacchetto di debug caricato con successo!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Pacchetto salvato" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Condivida la chiave di caricamento qui sotto con il supporto NetBird. Una copia locale è stata salvata anche sul suo dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Il pacchetto di debug è stato salvato localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copia chiave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Apri cartella" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Apri posizione file" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Caricamento non riuscito: {reason} Il pacchetto è comunque salvato localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Caricamento non riuscito. Il pacchetto è comunque salvato localmente." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Riconnessione di NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Acquisizione log di debug" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Generazione del pacchetto di debug…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Caricamento su NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Annullamento…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Development]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Tutti i diritti riservati." + }, + "settings.about.links.imprint": { + "message": "Note legali" + }, + "settings.about.links.privacy": { + "message": "Privacy" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Termini di servizio" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Forum" + }, + "settings.about.community.documentation": { + "message": "Documentazione" + }, + "settings.about.community.feedback": { + "message": "Feedback" + }, + "update.banner.message": { + "message": "NetBird {version} è pronto per l'installazione." + }, + "update.banner.later": { + "message": "Più tardi" + }, + "update.banner.installNow": { + "message": "Installa ora" + }, + "update.card.versionAvailableDownload": { + "message": "La versione {version} è disponibile per il download." + }, + "update.card.versionAvailableInstall": { + "message": "La versione {version} è disponibile per l'installazione." + }, + "update.card.whatsNew": { + "message": "Novità?" + }, + "update.card.installNow": { + "message": "Installa ora" + }, + "update.card.getInstaller": { + "message": "Scarica" + }, + "update.card.autoCheckInterval": { + "message": "NetBird verifica gli aggiornamenti in background." + }, + "update.card.changelog": { + "message": "Changelog" + }, + "update.card.onLatestVersion": { + "message": "Sta usando l'ultima versione" + }, + "update.header.tooltip": { + "message": "Aggiornamento disponibile" + }, + "update.overlay.updatingVersion": { + "message": "Aggiornamento di NetBird alla v{version}" + }, + "update.overlay.updating": { + "message": "Aggiornamento di NetBird" + }, + "update.overlay.description": { + "message": "È disponibile una versione più recente ed è in corso l'installazione. NetBird si riavvierà automaticamente al termine dell'aggiornamento." + }, + "update.overlay.error.timeoutTitle": { + "message": "L'aggiornamento richiede troppo tempo" + }, + "update.overlay.error.timeoutDescription": { + "message": "L'installazione di {target} ha richiesto troppo tempo e non è stata completata." + }, + "update.overlay.error.canceledTitle": { + "message": "Aggiornamento interrotto" + }, + "update.overlay.error.canceledDescription": { + "message": "L'aggiornamento a {target} è stato annullato prima del completamento." + }, + "update.overlay.error.failTitle": { + "message": "Impossibile installare l'aggiornamento" + }, + "update.overlay.error.failDescription": { + "message": "Impossibile installare {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "errore sconosciuto" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "la nuova versione" + }, + "update.error.loadStateTitle": { + "message": "Caricamento stato aggiornamento non riuscito" + }, + "update.error.triggerTitle": { + "message": "Avvio aggiornamento non riuscito" + }, + "update.page.versionLine": { + "message": "Aggiornamento del client a: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Aggiornamento del client." + }, + "update.page.outdated": { + "message": "La versione del client è precedente alla versione di aggiornamento automatico impostata in Management." + }, + "update.page.status.running": { + "message": "Aggiornamento in corso" + }, + "update.page.status.timeout": { + "message": "Aggiornamento scaduto. Riprovi." + }, + "update.page.status.canceled": { + "message": "Aggiornamento annullato." + }, + "update.page.status.failed": { + "message": "Aggiornamento non riuscito: {message}" + }, + "update.page.status.unknownError": { + "message": "errore di aggiornamento sconosciuto" + }, + "update.page.failedTitle": { + "message": "Aggiornamento non riuscito" + }, + "update.page.timeoutMessage": { + "message": "Aggiornamento scaduto." + }, + "update.page.dontClose": { + "message": "Non chiuda questa finestra." + }, + "update.page.updating": { + "message": "Aggiornamento…" + }, + "update.page.complete": { + "message": "Aggiornamento completato" + }, + "update.page.failed": { + "message": "Aggiornamento non riuscito" + }, + "window.title.settings": { + "message": "Impostazioni" + }, + "window.title.signIn": { + "message": "Accesso" + }, + "window.title.sessionExpiration": { + "message": "Sessione in scadenza" + }, + "window.title.updating": { + "message": "Aggiornamento" + }, + "window.title.welcome": { + "message": "Benvenuto in NetBird" + }, + "window.title.error": { + "message": "Errore" + }, + "welcome.title": { + "message": "Cerchi NetBird nella tray" + }, + "welcome.titleMac": { + "message": "Cerchi NetBird nella barra dei menu" + }, + "welcome.description": { + "message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni." + }, + "welcome.descriptionMac": { + "message": "NetBird risiede nella barra dei menu. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni." + }, + "welcome.continue": { + "message": "Continua" + }, + "welcome.back": { + "message": "Indietro" + }, + "welcome.management.title": { + "message": "Configura NetBird" + }, + "welcome.management.description": { + "message": "Clicchi su Continua per iniziare, oppure scelga Self-hosted se dispone di un proprio server NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Usi il nostro servizio gestito. Nessuna configurazione necessaria." + }, + "welcome.management.selfHosted.title": { + "message": "Self-hosted" + }, + "welcome.management.selfHosted.description": { + "message": "Si connetta al suo server di gestione." + }, + "welcome.management.urlLabel": { + "message": "URL del server di gestione" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Inserisca un URL valido, es. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Impossibile raggiungere questo server. Controlli l'URL o la sua rete, poi prosegua se è certo che sia corretto." + }, + "welcome.management.checking": { + "message": "Verifica…" + }, + "browserLogin.title": { + "message": "Continui nel browser per completare l'accesso" + }, + "browserLogin.notSeeing": { + "message": "Non vede la scheda del browser?" + }, + "browserLogin.tryAgain": { + "message": "Riprova" + }, + "browserLogin.openFailedTitle": { + "message": "Apertura browser non riuscita" + }, + "sessionExpiration.title": { + "message": "La sessione sta per scadere" + }, + "sessionExpiration.titleLater": { + "message": "La sessione scadrà" + }, + "sessionExpiration.description": { + "message": "Questo dispositivo verrà disconnesso a breve. Rinnovi con un accesso dal browser." + }, + "sessionExpiration.descriptionLater": { + "message": "Un accesso dal browser mantiene questo dispositivo connesso alla sua rete." + }, + "sessionExpiration.stay": { + "message": "Rinnova sessione" + }, + "sessionExpiration.authenticate": { + "message": "Autenticati" + }, + "sessionExpiration.logout": { + "message": "Esci" + }, + "sessionExpiration.expired": { + "message": "Sessione scaduta" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo disconnesso. Si autentichi con un accesso dal browser per riconnettersi." + }, + "sessionExpiration.close": { + "message": "Chiudi" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Rinnovo sessione non riuscito" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Disconnessione non riuscita" + }, + "peers.search.placeholder": { + "message": "Cerca per nome o IP" + }, + "peers.filter.all": { + "message": "Tutti" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Nessun peer disponibile" + }, + "peers.empty.description": { + "message": "Non ha peer disponibili oppure non ha accesso a nessuno di essi." + }, + "peers.details.domain": { + "message": "Dominio" + }, + "peers.details.netbirdIp": { + "message": "IP NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 NetBird" + }, + "peers.details.publicKey": { + "message": "Chiave pubblica" + }, + "peers.details.connection": { + "message": "Connessione" + }, + "peers.details.latency": { + "message": "Latenza" + }, + "peers.details.lastHandshake": { + "message": "Ultimo handshake" + }, + "peers.details.statusSince": { + "message": "Ultimo aggiornamento connessione" + }, + "peers.details.bytes": { + "message": "Byte" + }, + "peers.details.bytesSent": { + "message": "Inviati" + }, + "peers.details.bytesReceived": { + "message": "Ricevuti" + }, + "peers.details.localIce": { + "message": "ICE locale" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Mai" + }, + "peers.details.justNow": { + "message": "Proprio ora" + }, + "peers.details.refresh": { + "message": "Aggiorna" + }, + "peers.status.connected": { + "message": "Connesso" + }, + "peers.status.connecting": { + "message": "Connessione" + }, + "peers.status.disconnected": { + "message": "Disconnesso" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Risorse" + }, + "peers.details.relayed": { + "message": "Tramite relay" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass abilitato" + }, + "networks.search.placeholder": { + "message": "Cerca per rete o dominio" + }, + "networks.filter.all": { + "message": "Tutte" + }, + "networks.filter.active": { + "message": "Attive" + }, + "networks.filter.overlapping": { + "message": "Sovrapposte" + }, + "networks.empty.title": { + "message": "Nessuna risorsa disponibile" + }, + "networks.empty.description": { + "message": "Non ha risorse di rete disponibili oppure non ha accesso a nessuna di esse." + }, + "networks.selected": { + "message": "Selezionata" + }, + "networks.unselected": { + "message": "Non selezionata" + }, + "networks.ips.heading": { + "message": "IP risolti" + }, + "networks.bulk.selectionCount": { + "message": "{selected} di {total} attive" + }, + "networks.bulk.enableAll": { + "message": "Abilita tutte" + }, + "networks.bulk.disableAll": { + "message": "Disabilita tutte" + }, + "exitNodes.search.placeholder": { + "message": "Cerca nodi di uscita" + }, + "exitNodes.none": { + "message": "Nessuno" + }, + "exitNodes.empty.title": { + "message": "Nessun nodo di uscita disponibile" + }, + "exitNodes.empty.description": { + "message": "Nessun nodo di uscita è stato condiviso con questo peer." + }, + "exitNodes.card.title": { + "message": "Nodo di uscita" + }, + "exitNodes.card.statusActive": { + "message": "Attivo" + }, + "exitNodes.card.statusInactive": { + "message": "Inattivo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Nessuno" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Connessione diretta senza nodo di uscita" + }, + "quickActions.connect": { + "message": "Connetti" + }, + "quickActions.disconnect": { + "message": "Disconnetti" + }, + "daemon.unavailable.title": { + "message": "Il servizio NetBird non è in esecuzione" + }, + "daemon.unavailable.description": { + "message": "L'app si riconnetterà automaticamente non appena il servizio sarà in esecuzione." + }, + "daemon.unavailable.docsLink": { + "message": "Documentazione" + }, + "daemon.outdated.title": { + "message": "NetBird Client è obsoleto" + }, + "daemon.outdated.description": { + "message": "La nuova GUI non è compatibile con il tuo client precedente. Aggiorna il client per usare la nuova applicazione." + }, + "daemon.outdated.download": { + "message": "Scarica l'ultima versione" + }, + "error.jwt_clock_skew": { + "message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi." + }, + "error.jwt_expired": { + "message": "Il token di accesso è scaduto. Effettui di nuovo l'accesso." + }, + "error.jwt_signature_invalid": { + "message": "Accesso non riuscito: la firma del token non è valida. Contatti il suo amministratore." + }, + "error.session_expired": { + "message": "La sessione è scaduta. Effettui di nuovo l'accesso." + }, + "error.invalid_setup_key": { + "message": "La chiave di configurazione è mancante o non valida." + }, + "error.permission_denied": { + "message": "L'accesso è stato rifiutato dal server." + }, + "error.daemon_unreachable": { + "message": "Il daemon NetBird non risponde. Verifichi che il servizio sia in esecuzione." + }, + "error.unknown": { + "message": "Operazione non riuscita." + }, + "settings.ssh.privilege.hint": { + "message": "Richiede {actor}. Esegua invece questo:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Può disabilitarlo, ma riabilitarlo richiede {actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}:" + } +} diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json new file mode 100644 index 000000000..246c232a8 --- /dev/null +++ b/client/ui/i18n/locales/ja/common.json @@ -0,0 +1,1363 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "未接続" + }, + "tray.status.daemonUnavailable": { + "message": "実行されていません" + }, + "tray.status.error": { + "message": "エラー" + }, + "tray.status.connected": { + "message": "接続済み" + }, + "tray.status.connecting": { + "message": "接続中" + }, + "tray.status.needsLogin": { + "message": "ログインが必要" + }, + "tray.status.loginFailed": { + "message": "ログインに失敗しました" + }, + "tray.status.sessionExpired": { + "message": "セッションが期限切れ" + }, + "tray.session.expiresIn": { + "message": "セッションはあと{remaining}で期限切れ" + }, + "tray.session.unit.lessThanMinute": { + "message": "1分未満" + }, + "tray.session.unit.minute": { + "message": "1分" + }, + "tray.session.unit.minutes": { + "message": "{count}分" + }, + "tray.session.unit.hour": { + "message": "1時間" + }, + "tray.session.unit.hours": { + "message": "{count}時間" + }, + "tray.session.unit.day": { + "message": "1日" + }, + "tray.session.unit.days": { + "message": "{count}日" + }, + "tray.menu.open": { + "message": "NetBird を開く" + }, + "tray.menu.connect": { + "message": "接続" + }, + "tray.menu.disconnect": { + "message": "切断" + }, + "tray.menu.exitNode": { + "message": "出口ノード" + }, + "tray.menu.networks": { + "message": "リソース" + }, + "tray.menu.profiles": { + "message": "プロファイル" + }, + "tray.menu.manageProfiles": { + "message": "プロファイルの管理" + }, + "tray.menu.settings": { + "message": "設定..." + }, + "tray.menu.debugBundle": { + "message": "デバッグバンドルを作成" + }, + "tray.menu.about": { + "message": "ヘルプとサポート" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "ドキュメント" + }, + "tray.menu.troubleshoot": { + "message": "トラブルシューティング" + }, + "tray.menu.downloadLatest": { + "message": "最新バージョンをダウンロード" + }, + "tray.menu.installVersion": { + "message": "バージョン {version} をインストール" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "デーモン: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "NetBird を終了" + }, + "notify.daemonOutdated.title": { + "message": "NetBird サービスが古くなっています" + }, + "notify.daemonOutdated.body": { + "message": "このアプリを使用するには NetBird サービスを更新してください。" + }, + "notify.update.title": { + "message": "NetBird の更新が利用可能" + }, + "notify.update.body": { + "message": "NetBird {version} が利用可能です。" + }, + "notify.update.enforcedSuffix": { + "message": "管理者がこの更新を必須にしています。" + }, + "notify.error.title": { + "message": "エラー" + }, + "notify.error.connect": { + "message": "接続に失敗しました" + }, + "notify.error.disconnect": { + "message": "切断に失敗しました" + }, + "notify.error.switchProfile": { + "message": "{profile} への切り替えに失敗しました" + }, + "notify.error.exitNode": { + "message": "出口ノード {name} の更新に失敗しました" + }, + "notify.sessionExpired.title": { + "message": "NetBird セッションが期限切れ" + }, + "notify.sessionExpired.body": { + "message": "NetBird セッションの有効期限が切れました。もう一度ログインしてください。" + }, + "notify.sessionWarning.title": { + "message": "まもなくセッションが期限切れ" + }, + "notify.sessionWarning.body": { + "message": "NetBird セッションはあと{remaining}で期限切れになります。更新するには「今すぐ延長」をクリックしてください。" + }, + "notify.sessionWarning.bodyGeneric": { + "message": "NetBird セッションはまもなく期限切れになります。更新するには「今すぐ延長」をクリックしてください。" + }, + "notify.sessionWarning.extend": { + "message": "今すぐ延長" + }, + "notify.sessionWarning.dismiss": { + "message": "閉じる" + }, + "notify.sessionWarning.failed": { + "message": "NetBird セッションの延長に失敗しました" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird セッションを延長しました" + }, + "notify.sessionWarning.successBody": { + "message": "セッションが更新されました。" + }, + "notify.sessionDeadlineRejected.title": { + "message": "セッション期限が拒否されました" + }, + "notify.sessionDeadlineRejected.body": { + "message": "サーバーが無効なセッション期限を送信しました。もう一度サインインしてください。" + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird 設定が更新されました" + }, + "notify.mdm.policyApplied.body": { + "message": "NetBird の構成が IT ポリシーによって更新されました。" + }, + "common.cancel": { + "message": "キャンセル" + }, + "common.save": { + "message": "保存" + }, + "common.saveChanges": { + "message": "変更を保存" + }, + "common.saving": { + "message": "保存中…" + }, + "common.close": { + "message": "閉じる" + }, + "common.copy": { + "message": "コピー" + }, + "common.togglePasswordVisibility": { + "message": "パスワードの表示を切り替え" + }, + "common.increase": { + "message": "増やす" + }, + "common.decrease": { + "message": "減らす" + }, + "common.delete": { + "message": "削除" + }, + "common.create": { + "message": "作成" + }, + "common.add": { + "message": "追加" + }, + "common.remove": { + "message": "削除" + }, + "common.refresh": { + "message": "更新" + }, + "common.loading": { + "message": "読み込み中…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "結果が見つかりませんでした" + }, + "common.noResults.description": { + "message": "結果が見つかりませんでした。別の検索語を試すか、フィルターを変更してください。" + }, + "notConnected.title": { + "message": "未接続" + }, + "notConnected.description": { + "message": "ピア、ネットワークリソース、出口ノードの詳細情報を表示するには、まず NetBird に接続してください。" + }, + "connect.status.disconnected": { + "message": "未接続" + }, + "connect.status.connecting": { + "message": "接続中..." + }, + "connect.status.connected": { + "message": "接続済み" + }, + "connect.status.disconnecting": { + "message": "切断中..." + }, + "connect.status.daemonUnavailable": { + "message": "デーモンが利用できません" + }, + "connect.status.loginRequired": { + "message": "ログインが必要" + }, + "connect.error.loginTitle": { + "message": "ログインに失敗しました" + }, + "connect.error.connectTitle": { + "message": "接続に失敗しました" + }, + "connect.error.disconnectTitle": { + "message": "切断に失敗しました" + }, + "nav.peers.title": { + "message": "ピア" + }, + "nav.peers.description": { + "message": "{total}台中{connected}台接続中" + }, + "nav.resources.title": { + "message": "リソース" + }, + "nav.resources.description": { + "message": "{total}件中{active}件有効" + }, + "nav.exitNode.title": { + "message": "出口ノード" + }, + "nav.exitNode.none": { + "message": "未使用" + }, + "nav.exitNode.using": { + "message": "{name} 経由" + }, + "header.openSettings": { + "message": "設定を開く" + }, + "header.togglePanel": { + "message": "サイドパネルを切り替え" + }, + "profile.selector.loading": { + "message": "読み込み中..." + }, + "profile.selector.noProfile": { + "message": "プロファイルなし" + }, + "profile.selector.searchPlaceholder": { + "message": "名前でプロファイルを検索..." + }, + "profile.selector.emptyTitle": { + "message": "プロファイルが見つかりません" + }, + "profile.selector.emptyDescription": { + "message": "別の検索語を試すか、新しいプロファイルを作成してください。" + }, + "profile.selector.newProfile": { + "message": "新しいプロファイル" + }, + "profile.selector.moreOptions": { + "message": "その他のオプション" + }, + "profile.selector.deregister": { + "message": "登録解除" + }, + "profile.selector.delete": { + "message": "削除" + }, + "profile.selector.switchTo": { + "message": "このプロファイルに切り替え" + }, + "profile.selector.edit": { + "message": "編集" + }, + "profile.edit.title": { + "message": "プロファイルを編集" + }, + "profile.edit.submit": { + "message": "変更を保存" + }, + "profile.dialog.title": { + "message": "プロファイル名を入力" + }, + "profile.dialog.nameLabel": { + "message": "プロファイル名" + }, + "profile.dialog.description": { + "message": "分かりやすいプロファイル名を設定してください。" + }, + "profile.dialog.placeholder": { + "message": "例: 仕事" + }, + "profile.dialog.submit": { + "message": "プロファイルを追加" + }, + "profile.dialog.required": { + "message": "プロファイル名を入力してください(例: 仕事、自宅)" + }, + "profile.dialog.managementHelp": { + "message": "NetBird Cloud または独自のサーバーを使用します。" + }, + "profile.dialog.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLを確認するか、正しいことが確実な場合はそのままプロファイルを追加してください。" + }, + "header.menu.settings": { + "message": "設定..." + }, + "header.menu.defaultView": { + "message": "デフォルト表示" + }, + "header.menu.advancedView": { + "message": "詳細表示" + }, + "header.menu.updateAvailable": { + "message": "更新が利用可能" + }, + "header.menu.open": { + "message": "メニューを開く" + }, + "header.profile.switch": { + "message": "プロファイルを切り替え" + }, + "connect.toggle.label": { + "message": "NetBird 接続を切り替え" + }, + "connect.localIp.label": { + "message": "ローカル IP アドレス" + }, + "common.search": { + "message": "検索" + }, + "common.filter": { + "message": "フィルター" + }, + "exitNodes.dropdown.trigger": { + "message": "出口ノードを選択" + }, + "peers.row.label": { + "message": "{name} の詳細を開く、{status}" + }, + "peers.dialog.title": { + "message": "ピアの詳細" + }, + "networks.row.toggle": { + "message": "{name} を切り替え" + }, + "networks.bulk.label": { + "message": "表示中のすべてのリソースを切り替え" + }, + "profile.switch.title": { + "message": "プロファイルを「{name}」に切り替えますか?" + }, + "profile.switch.message": { + "message": "プロファイルを切り替えてもよろしいですか?\n現在のプロファイルは切断されます。" + }, + "profile.switch.confirm": { + "message": "確認" + }, + "profile.deregister.title": { + "message": "プロファイル「{name}」の登録を解除しますか?" + }, + "profile.deregister.message": { + "message": "このプロファイルの登録を解除してもよろしいですか?\n再度使用するにはログインが必要になります。" + }, + "profile.deregister.confirm": { + "message": "登録解除" + }, + "profile.delete.title": { + "message": "プロファイル「{name}」を削除しますか?" + }, + "profile.delete.message": { + "message": "このプロファイルを削除してもよろしいですか?\nこの操作は取り消せません。" + }, + "profile.delete.disabledActive": { + "message": "使用中のプロファイルは削除できません。削除する前に別のプロファイルに切り替えてください。" + }, + "profile.delete.disabledDefault": { + "message": "デフォルトのプロファイルは削除できません。" + }, + "profile.error.switchTitle": { + "message": "プロファイルの切り替えに失敗しました" + }, + "profile.error.deregisterTitle": { + "message": "プロファイルの登録解除に失敗しました" + }, + "profile.error.deleteTitle": { + "message": "プロファイルの削除に失敗しました" + }, + "profile.error.createTitle": { + "message": "プロファイルの作成に失敗しました" + }, + "profile.error.editTitle": { + "message": "プロファイルの編集に失敗しました" + }, + "profile.error.loadTitle": { + "message": "プロファイルの読み込みに失敗しました" + }, + "profile.dropdown.activeProfile": { + "message": "使用中のプロファイル" + }, + "profile.dropdown.switchProfile": { + "message": "プロファイルを切り替え" + }, + "profile.dropdown.noEmail": { + "message": "その他" + }, + "profile.dropdown.addProfile": { + "message": "プロファイルを追加" + }, + "profile.dropdown.manageProfiles": { + "message": "プロファイルの管理" + }, + "profile.dropdown.settings": { + "message": "設定" + }, + "settings.profiles.section.profiles": { + "message": "プロファイル" + }, + "settings.profiles.intro": { + "message": "仕事用と個人用のアカウント、あるいは異なる管理サーバーなど、複数の NetBird ID を並行して管理できます。以下でプロファイルの追加、登録解除、削除ができます。" + }, + "settings.profiles.addProfile": { + "message": "プロファイルを追加" + }, + "settings.profiles.active": { + "message": "使用中" + }, + "settings.profiles.emptyTitle": { + "message": "プロファイルがありません" + }, + "settings.profiles.emptyDescription": { + "message": "NetBird 管理サーバーに接続するプロファイルを作成してください。" + }, + "settings.error.loadTitle": { + "message": "設定の読み込みに失敗しました" + }, + "settings.error.saveTitle": { + "message": "設定の保存に失敗しました" + }, + "settings.error.debugBundleTitle": { + "message": "デバッグバンドルの作成に失敗しました" + }, + "settings.nav.label": { + "message": "設定セクション" + }, + "settings.tabs.general": { + "message": "一般" + }, + "settings.tabs.network": { + "message": "ネットワーク" + }, + "settings.tabs.security": { + "message": "セキュリティ" + }, + "settings.tabs.profiles": { + "message": "プロファイル" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "詳細設定" + }, + "settings.tabs.troubleshooting": { + "message": "トラブルシューティング" + }, + "settings.tabs.about": { + "message": "情報" + }, + "settings.tabs.updateAvailable": { + "message": "更新が利用可能" + }, + "settings.general.section.general": { + "message": "一般" + }, + "settings.general.section.connection": { + "message": "接続" + }, + "settings.general.connectOnStartup.label": { + "message": "起動時に接続" + }, + "settings.general.connectOnStartup.help": { + "message": "サービスの起動時に自動的に接続を確立します。" + }, + "settings.general.notifications.label": { + "message": "デスクトップ通知" + }, + "settings.general.notifications.help": { + "message": "新しい更新や接続イベントに関するデスクトップ通知を表示します。" + }, + "settings.general.autostart.label": { + "message": "ログイン時に NetBird UI を起動" + }, + "settings.general.autostart.help": { + "message": "ログイン時に NetBird インターフェースを自動的に起動します。これはグラフィカルインターフェースにのみ影響し、バックグラウンドサービスには影響しません。" + }, + "settings.general.autostart.errorTitle": { + "message": "自動起動の変更に失敗しました" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "終了後も接続を維持", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "表示言語" + }, + "settings.general.language.help": { + "message": "NetBird インターフェースの言語を選択します。" + }, + "settings.general.language.search": { + "message": "言語を検索…" + }, + "settings.general.language.empty": { + "message": "一致する言語がありません。" + }, + "settings.general.management.label": { + "message": "管理サーバー" + }, + "settings.general.management.help": { + "message": "NetBird Cloud または自身のセルフホスト管理サーバーに接続します。変更するとクライアントが再接続します。" + }, + "settings.general.management.cloud": { + "message": "クラウド" + }, + "settings.general.management.selfHosted": { + "message": "セルフホスト" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "有効なURLを入力してください(例: https://netbird.selfhosted.com:443)" + }, + "settings.general.management.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLを確認するか、正しいことが確実な場合はそのまま保存してください。" + }, + "settings.general.management.switchCloudTitle": { + "message": "NetBird Cloud に切り替えますか?" + }, + "settings.general.management.switchCloudMessage": { + "message": "セルフホストサーバーが切断されます。\n再度ログインが必要になる場合があります。" + }, + "settings.general.management.switchCloudConfirm": { + "message": "クラウドに切り替え" + }, + "settings.network.section.connectivity": { + "message": "ネットワーク接続" + }, + "settings.network.section.routingDns": { + "message": "ルーティングとDNS" + }, + "settings.network.monitor.label": { + "message": "ネットワーク変更時に再接続" + }, + "settings.network.monitor.help": { + "message": "ネットワークを監視し、Wi-Fiの切り替え、イーサネットの変更、スリープからの復帰などの変化時に自動的に再接続します。" + }, + "settings.network.dns.label": { + "message": "DNSを有効にする" + }, + "settings.network.dns.help": { + "message": "NetBird が管理する DNS 設定をホストのリゾルバに適用します。" + }, + "settings.network.clientRoutes.label": { + "message": "クライアントルートを有効にする" + }, + "settings.network.clientRoutes.help": { + "message": "他のピアからルートを受け入れ、そのネットワークに到達できるようにします。" + }, + "settings.network.serverRoutes.label": { + "message": "サーバールートを有効にする" + }, + "settings.network.serverRoutes.help": { + "message": "このホストのローカルルートを他のピアにアドバタイズします。" + }, + "settings.network.ipv6.label": { + "message": "IPv6を有効にする" + }, + "settings.network.ipv6.help": { + "message": "NetBird オーバーレイネットワークで IPv6 アドレッシングを使用します。" + }, + "settings.security.section.firewall": { + "message": "ファイアウォール" + }, + "settings.security.section.encryption": { + "message": "暗号化" + }, + "settings.security.blockInbound.label": { + "message": "受信トラフィックをブロック" + }, + "settings.security.blockInbound.help": { + "message": "このデバイスおよびこのデバイスがルーティングするネットワークへの、ピアからの要求されていない接続を拒否します。送信トラフィックには影響しません。" + }, + "settings.security.blockLan.label": { + "message": "LANアクセスをブロック" + }, + "settings.security.blockLan.help": { + "message": "このデバイスがピアのトラフィックをルーティングする際に、ピアがローカルネットワークやそのデバイスに到達できないようにします。" + }, + "settings.security.rosenpass.label": { + "message": "量子耐性を有効にする" + }, + "settings.security.rosenpass.help": { + "message": "WireGuard® に加えて Rosenpass によるポスト量子鍵交換を追加します。" + }, + "settings.security.rosenpassPermissive.label": { + "message": "寛容モードを有効にする" + }, + "settings.security.rosenpassPermissive.help": { + "message": "量子耐性に対応していないピアへの接続を許可します。" + }, + "settings.ssh.section.server": { + "message": "サーバー" + }, + "settings.ssh.section.capabilities": { + "message": "機能" + }, + "settings.ssh.section.authentication": { + "message": "認証" + }, + "settings.ssh.server.label": { + "message": "SSHサーバーを有効にする" + }, + "settings.ssh.server.help": { + "message": "このホストで NetBird SSH サーバーを実行し、他のピアが接続できるようにします。" + }, + "settings.ssh.root.label": { + "message": "rootログインを許可" + }, + "settings.ssh.root.help": { + "message": "ピアが root ユーザーとしてサインインできるようにします。無効にすると非特権アカウントが必要になります。" + }, + "settings.ssh.sftp.label": { + "message": "SFTPを許可" + }, + "settings.ssh.sftp.help": { + "message": "ネイティブの SFTP または SCP クライアントを使用してファイルを安全に転送します。" + }, + "settings.ssh.localForward.label": { + "message": "ローカルポート転送" + }, + "settings.ssh.localForward.help": { + "message": "接続するピアが、このホストから到達可能なサービスへローカルポートをトンネリングできるようにします。" + }, + "settings.ssh.remoteForward.label": { + "message": "リモートポート転送" + }, + "settings.ssh.remoteForward.help": { + "message": "接続するピアが、このホスト上のポートを自身のマシンに公開できるようにします。" + }, + "settings.ssh.jwt.label": { + "message": "JWT認証を有効にする" + }, + "settings.ssh.jwt.help": { + "message": "各 SSH セッションを IdP に対して検証し、ユーザー ID と監査を行います。無効にするとネットワークの ACL ポリシーのみに依存します。IdP が利用できない場合に便利です。" + }, + "settings.ssh.jwtTtl.label": { + "message": "JWTキャッシュTTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "発信 SSH 接続で再度認証を求めるまでに、このクライアントが JWT をキャッシュする期間です。0 に設定するとキャッシュを無効にし、接続ごとに認証します。" + }, + "settings.ssh.jwtTtl.suffix": { + "message": "秒" + }, + "settings.advanced.section.interface": { + "message": "インターフェース" + }, + "settings.advanced.section.security": { + "message": "セキュリティ" + }, + "settings.advanced.interfaceName.label": { + "message": "名前" + }, + "settings.advanced.interfaceName.error": { + "message": "1〜15文字の英字、数字、ドット、ハイフン、アンダースコアを使用してください。" + }, + "settings.advanced.interfaceName.errorMac": { + "message": "「utun」に続けて数字で始まる必要があります(例: utun100)。" + }, + "settings.advanced.port.label": { + "message": "ポート" + }, + "settings.advanced.port.error": { + "message": "{min}〜{max}の範囲でポートを入力してください。" + }, + "settings.advanced.port.help": { + "message": "0 に設定すると、ランダムな空きポートが使用されます。" + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "{min}〜{max}の範囲で MTU 値を入力してください。" + }, + "settings.advanced.psk.label": { + "message": "事前共有鍵" + }, + "settings.advanced.psk.help": { + "message": "追加の対称暗号化のためのオプションの WireGuard PSK です。NetBird セットアップキーとは異なります。同じ事前共有鍵を使用するピアとのみ通信できます。" + }, + "settings.troubleshooting.section.title": { + "message": "デバッグバンドル" + }, + "settings.troubleshooting.anonymize.label": { + "message": "機密情報を匿名化" + }, + "settings.troubleshooting.anonymize.help": { + "message": "IP アドレス、ドメイン、その他の機密性の高い値を隠します。" + }, + "settings.troubleshooting.anonymize.info": { + "message": "「デフォルト」では、サポートのために内部 IPv4 アドレスとピア名は読める状態のまま残ります。「厳格」では、さらにプライベート (RFC 1918)、CGNAT、リンクローカルの IP アドレス、ピア名、WireGuard 公開鍵も匿名化されます。繰り返し現れる値は同じプレースホルダーに置き換えられるため、ピアは区別できます。デバッグバンドルを組織外に共有する場合は「厳格」を使用してください。" + }, + "settings.troubleshooting.anonymize.none": { + "message": "なし" + }, + "settings.troubleshooting.anonymize.default": { + "message": "デフォルト" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "厳格" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "システム情報を含める" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "OS、カーネル、ネットワークインターフェース、ルーティングテーブルを含めます。" + }, + "settings.troubleshooting.upload.label": { + "message": "バンドルを NetBird サーバーにアップロード" + }, + "settings.troubleshooting.upload.help": { + "message": "NetBird サポートと共有するためのアップロードキーを返します。" + }, + "settings.troubleshooting.trace.label": { + "message": "トレースログを有効にする" + }, + "settings.troubleshooting.trace.help": { + "message": "ログレベルを TRACE に引き上げ、その後元に戻します。" + }, + "settings.troubleshooting.capture.label": { + "message": "キャプチャセッション" + }, + "settings.troubleshooting.capture.help": { + "message": "再接続して待機し、問題を再現できるようにします。" + }, + "settings.troubleshooting.packets.label": { + "message": "ネットワークパケットをキャプチャ" + }, + "settings.troubleshooting.packets.help": { + "message": "キャプチャ期間中のネットワークトラフィックを .pcap として保存します。" + }, + "settings.troubleshooting.duration.label": { + "message": "キャプチャ時間" + }, + "settings.troubleshooting.duration.help": { + "message": "キャプチャセッションを実行する時間です。" + }, + "settings.troubleshooting.duration.suffix": { + "message": "分" + }, + "settings.troubleshooting.create": { + "message": "バンドルを作成" + }, + "settings.troubleshooting.progress.description": { + "message": "ログ、システムの詳細、接続状態を収集しています。通常はしばらくで完了します。完了するまで NetBird を使い続けても、設定を閉じても構いません。" + }, + "settings.troubleshooting.cancelling": { + "message": "キャンセル中…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "デバッグバンドルのアップロードに成功しました!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "バンドルを保存しました" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "下記のアップロードキーを NetBird サポート と共有してください。ローカルコピーもお使いのデバイスに保存されました。" + }, + "settings.troubleshooting.done.savedDescription": { + "message": "デバッグバンドルはローカルに保存されました。" + }, + "settings.troubleshooting.done.copyKey": { + "message": "キーをコピー" + }, + "settings.troubleshooting.done.openFolder": { + "message": "フォルダを開く" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "ファイルの場所を開く" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "アップロードに失敗しました: {reason} バンドルはローカルに保存されています。" + }, + "settings.troubleshooting.uploadFailed": { + "message": "アップロードに失敗しました。バンドルはローカルに保存されています。" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "NetBird を再接続しています…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "デバッグログをキャプチャしています" + }, + "settings.troubleshooting.stage.bundling": { + "message": "デバッグバンドルを生成しています…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "NetBird にアップロードしています…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "キャンセル中…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[開発版]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. All Rights Reserved." + }, + "settings.about.links.imprint": { + "message": "運営者情報" + }, + "settings.about.links.privacy": { + "message": "プライバシー" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "利用規約" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "フォーラム" + }, + "settings.about.community.documentation": { + "message": "ドキュメント" + }, + "settings.about.community.feedback": { + "message": "フィードバック" + }, + "update.banner.message": { + "message": "NetBird {version} をインストールする準備ができました。" + }, + "update.banner.later": { + "message": "後で" + }, + "update.banner.installNow": { + "message": "今すぐインストール" + }, + "update.card.versionAvailableDownload": { + "message": "バージョン {version} がダウンロード可能です。" + }, + "update.card.versionAvailableInstall": { + "message": "バージョン {version} がインストール可能です。" + }, + "update.card.whatsNew": { + "message": "新機能は?" + }, + "update.card.installNow": { + "message": "今すぐインストール" + }, + "update.card.getInstaller": { + "message": "ダウンロード" + }, + "update.card.autoCheckInterval": { + "message": "NetBird はバックグラウンドで更新を確認します。" + }, + "update.card.changelog": { + "message": "変更履歴" + }, + "update.card.onLatestVersion": { + "message": "最新バージョンを使用しています" + }, + "update.header.tooltip": { + "message": "更新が利用可能" + }, + "update.overlay.updatingVersion": { + "message": "NetBird を v{version} に更新しています" + }, + "update.overlay.updating": { + "message": "NetBird を更新しています" + }, + "update.overlay.description": { + "message": "新しいバージョンが利用可能で、インストール中です。更新が完了すると NetBird は自動的に再起動します。" + }, + "update.overlay.error.timeoutTitle": { + "message": "更新に時間がかかっています" + }, + "update.overlay.error.timeoutDescription": { + "message": "{target} のインストールに時間がかかりすぎ、完了しませんでした。" + }, + "update.overlay.error.canceledTitle": { + "message": "更新が停止されました" + }, + "update.overlay.error.canceledDescription": { + "message": "{target} への更新は完了前にキャンセルされました。" + }, + "update.overlay.error.failTitle": { + "message": "更新をインストールできませんでした" + }, + "update.overlay.error.failDescription": { + "message": "{target} をインストールできませんでした。" + }, + "update.overlay.error.unknownMessage": { + "message": "不明なエラー" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "新しいバージョン" + }, + "update.error.loadStateTitle": { + "message": "更新状態の読み込みに失敗しました" + }, + "update.error.triggerTitle": { + "message": "更新の開始に失敗しました" + }, + "update.page.versionLine": { + "message": "クライアントを次のバージョンに更新しています: {version}。" + }, + "update.page.versionLineGeneric": { + "message": "クライアントを更新しています。" + }, + "update.page.outdated": { + "message": "クライアントのバージョンが、管理サーバーで設定された自動更新バージョンより古くなっています。" + }, + "update.page.status.running": { + "message": "更新中" + }, + "update.page.status.timeout": { + "message": "更新がタイムアウトしました。もう一度お試しください。" + }, + "update.page.status.canceled": { + "message": "更新がキャンセルされました。" + }, + "update.page.status.failed": { + "message": "更新に失敗しました: {message}" + }, + "update.page.status.unknownError": { + "message": "不明な更新エラー" + }, + "update.page.failedTitle": { + "message": "更新に失敗しました" + }, + "update.page.timeoutMessage": { + "message": "更新がタイムアウトしました。" + }, + "update.page.dontClose": { + "message": "このウィンドウを閉じないでください。" + }, + "update.page.updating": { + "message": "更新中…" + }, + "update.page.complete": { + "message": "更新が完了しました" + }, + "update.page.failed": { + "message": "更新に失敗しました" + }, + "window.title.settings": { + "message": "設定" + }, + "window.title.signIn": { + "message": "サインイン" + }, + "window.title.sessionExpiration": { + "message": "セッションの期限切れ" + }, + "window.title.updating": { + "message": "更新中" + }, + "window.title.welcome": { + "message": "NetBird へようこそ" + }, + "window.title.error": { + "message": "エラー" + }, + "welcome.title": { + "message": "トレイの NetBird を確認してください" + }, + "welcome.titleMac": { + "message": "メニューバーの NetBird を確認してください" + }, + "welcome.description": { + "message": "NetBird はトレイに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。" + }, + "welcome.descriptionMac": { + "message": "NetBird はメニューバーに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。" + }, + "welcome.continue": { + "message": "続ける" + }, + "welcome.back": { + "message": "戻る" + }, + "welcome.management.title": { + "message": "NetBird をセットアップ" + }, + "welcome.management.description": { + "message": "「続ける」をクリックして開始するか、独自の NetBird サーバーをお持ちの場合は「セルフホスト」を選択してください。" + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "当社のホスト型サービスを使用します。セットアップは不要です。" + }, + "welcome.management.selfHosted.title": { + "message": "セルフホスト" + }, + "welcome.management.selfHosted.description": { + "message": "独自の管理サーバーに接続します。" + }, + "welcome.management.urlLabel": { + "message": "管理サーバーのURL" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "有効なURLを入力してください(例: https://netbird.selfhosted.com:443)" + }, + "welcome.management.urlUnreachable": { + "message": "このサーバーに到達できませんでした。URLまたはネットワークを確認し、正しいことが確実な場合は続行してください。" + }, + "welcome.management.checking": { + "message": "確認中…" + }, + "browserLogin.title": { + "message": "ブラウザでログインを完了してください" + }, + "browserLogin.notSeeing": { + "message": "サインインを完了できるようブラウザのタブを開きました。表示されませんか?" + }, + "browserLogin.tryAgain": { + "message": "再試行" + }, + "browserLogin.openFailedTitle": { + "message": "ブラウザの起動に失敗しました" + }, + "sessionExpiration.title": { + "message": "まもなくセッションが期限切れになります" + }, + "sessionExpiration.titleLater": { + "message": "セッションが期限切れになります" + }, + "sessionExpiration.description": { + "message": "このデバイスはまもなく切断されます。ブラウザでのサインインで更新してください。" + }, + "sessionExpiration.descriptionLater": { + "message": "ブラウザでサインインすると、このデバイスがネットワークに接続されたままになります。" + }, + "sessionExpiration.stay": { + "message": "セッションを更新" + }, + "sessionExpiration.authenticate": { + "message": "認証" + }, + "sessionExpiration.logout": { + "message": "ログアウト" + }, + "sessionExpiration.expired": { + "message": "セッションが期限切れになりました" + }, + "sessionExpiration.expiredDescription": { + "message": "デバイスが切断されました。再接続するにはブラウザでサインインして認証してください。" + }, + "sessionExpiration.close": { + "message": "閉じる" + }, + "sessionExpiration.extendFailedTitle": { + "message": "セッションの延長に失敗しました" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "ログアウトに失敗しました" + }, + "peers.search.placeholder": { + "message": "名前または IP で検索" + }, + "peers.filter.all": { + "message": "すべて" + }, + "peers.filter.online": { + "message": "オンライン" + }, + "peers.filter.offline": { + "message": "オフライン" + }, + "peers.empty.title": { + "message": "利用可能なピアがありません" + }, + "peers.empty.description": { + "message": "利用可能なピアがないか、いずれのピアにもアクセス権がありません。" + }, + "peers.details.domain": { + "message": "ドメイン" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "公開鍵" + }, + "peers.details.connection": { + "message": "接続" + }, + "peers.details.latency": { + "message": "レイテンシ" + }, + "peers.details.lastHandshake": { + "message": "最終ハンドシェイク" + }, + "peers.details.statusSince": { + "message": "最終接続更新" + }, + "peers.details.bytes": { + "message": "バイト" + }, + "peers.details.bytesSent": { + "message": "送信" + }, + "peers.details.bytesReceived": { + "message": "受信" + }, + "peers.details.localIce": { + "message": "ローカル ICE" + }, + "peers.details.remoteIce": { + "message": "リモート ICE" + }, + "peers.details.never": { + "message": "なし" + }, + "peers.details.justNow": { + "message": "たった今" + }, + "peers.details.refresh": { + "message": "更新" + }, + "peers.status.connected": { + "message": "接続済み" + }, + "peers.status.connecting": { + "message": "接続中" + }, + "peers.status.disconnected": { + "message": "未接続" + }, + "peers.details.relayAddress": { + "message": "リレー" + }, + "peers.details.networks": { + "message": "リソース" + }, + "peers.details.relayed": { + "message": "リレー経由" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass 有効" + }, + "networks.search.placeholder": { + "message": "ネットワークまたはドメインで検索" + }, + "networks.filter.all": { + "message": "すべて" + }, + "networks.filter.active": { + "message": "有効" + }, + "networks.filter.overlapping": { + "message": "重複" + }, + "networks.empty.title": { + "message": "利用可能なリソースがありません" + }, + "networks.empty.description": { + "message": "利用可能なネットワークリソースがないか、いずれのリソースにもアクセス権がありません。" + }, + "networks.selected": { + "message": "選択中" + }, + "networks.unselected": { + "message": "未選択" + }, + "networks.ips.heading": { + "message": "解決された IP" + }, + "networks.bulk.selectionCount": { + "message": "{total}件中{selected}件有効" + }, + "networks.bulk.enableAll": { + "message": "すべて有効化" + }, + "networks.bulk.disableAll": { + "message": "すべて無効化" + }, + "exitNodes.search.placeholder": { + "message": "出口ノードを検索" + }, + "exitNodes.none": { + "message": "なし" + }, + "exitNodes.empty.title": { + "message": "利用可能な出口ノードがありません" + }, + "exitNodes.empty.description": { + "message": "このピアと共有されている出口ノードはありません。" + }, + "exitNodes.card.title": { + "message": "出口ノード" + }, + "exitNodes.card.statusActive": { + "message": "有効" + }, + "exitNodes.card.statusInactive": { + "message": "無効" + }, + "exitNodes.dropdown.noneTitle": { + "message": "なし" + }, + "exitNodes.dropdown.noneDescription": { + "message": "出口ノードを使用しない直接接続" + }, + "quickActions.connect": { + "message": "接続" + }, + "quickActions.disconnect": { + "message": "切断" + }, + "daemon.unavailable.title": { + "message": "NetBird サービスが実行されていません" + }, + "daemon.unavailable.description": { + "message": "サービスが実行されると、アプリは自動的に再接続します。" + }, + "daemon.unavailable.docsLink": { + "message": "ドキュメント" + }, + "daemon.outdated.title": { + "message": "NetBird サービスが古くなっています" + }, + "daemon.outdated.description": { + "message": "このアプリを使用するには NetBird サービスを更新してください。" + }, + "daemon.outdated.download": { + "message": "最新版をダウンロード" + }, + "error.jwt_clock_skew": { + "message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。" + }, + "error.jwt_expired": { + "message": "サインイントークンの有効期限が切れました。もう一度サインインしてください。" + }, + "error.jwt_signature_invalid": { + "message": "サインインに失敗しました: トークンの署名が無効です。管理者にお問い合わせください。" + }, + "error.session_expired": { + "message": "セッションの有効期限が切れました。もう一度サインインしてください。" + }, + "error.invalid_setup_key": { + "message": "セットアップキーがないか、無効です。" + }, + "error.permission_denied": { + "message": "サインインがサーバーによって拒否されました。" + }, + "error.daemon_unreachable": { + "message": "NetBird デーモンが応答していません。サービスが実行されているか確認してください。" + }, + "error.unknown": { + "message": "操作に失敗しました。" + }, + "settings.ssh.privilege.hint": { + "message": "{actor}が必要です。代わりに次のコマンドを実行してください:" + }, + "settings.ssh.privilege.oneWay": { + "message": "無効にはできますが、再度有効にするには{actor}が必要です:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "有効にはできますが、再度無効にするには{actor}が必要です:" + } +} diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json new file mode 100644 index 000000000..418e93717 --- /dev/null +++ b/client/ui/i18n/locales/pt/common.json @@ -0,0 +1,1363 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Desconectado" + }, + "tray.status.daemonUnavailable": { + "message": "Não está em execução" + }, + "tray.status.error": { + "message": "Erro" + }, + "tray.status.connected": { + "message": "Conectado" + }, + "tray.status.connecting": { + "message": "Conectando" + }, + "tray.status.needsLogin": { + "message": "Login necessário" + }, + "tray.status.loginFailed": { + "message": "Falha no login" + }, + "tray.status.sessionExpired": { + "message": "Sessão expirada" + }, + "tray.session.expiresIn": { + "message": "A sessão expira em {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "menos de um minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minutos" + }, + "tray.session.unit.hour": { + "message": "1 hora" + }, + "tray.session.unit.hours": { + "message": "{count} horas" + }, + "tray.session.unit.day": { + "message": "1 dia" + }, + "tray.session.unit.days": { + "message": "{count} dias" + }, + "tray.menu.open": { + "message": "Abrir o NetBird" + }, + "tray.menu.connect": { + "message": "Conectar" + }, + "tray.menu.disconnect": { + "message": "Desconectar" + }, + "tray.menu.exitNode": { + "message": "Nó de saída" + }, + "tray.menu.networks": { + "message": "Recursos" + }, + "tray.menu.profiles": { + "message": "Perfis" + }, + "tray.menu.manageProfiles": { + "message": "Gerenciar perfis" + }, + "tray.menu.settings": { + "message": "Configurações..." + }, + "tray.menu.debugBundle": { + "message": "Criar pacote de depuração" + }, + "tray.menu.about": { + "message": "Ajuda e suporte" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentação" + }, + "tray.menu.troubleshoot": { + "message": "Solução de problemas" + }, + "tray.menu.downloadLatest": { + "message": "Baixar a versão mais recente" + }, + "tray.menu.installVersion": { + "message": "Instalar a versão {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Sair do NetBird" + }, + "notify.daemonOutdated.title": { + "message": "O serviço NetBird está desatualizado" + }, + "notify.daemonOutdated.body": { + "message": "Atualize o serviço NetBird para usar este aplicativo." + }, + "notify.update.title": { + "message": "Atualização do NetBird disponível" + }, + "notify.update.body": { + "message": "O NetBird {version} está disponível." + }, + "notify.update.enforcedSuffix": { + "message": " O seu administrador exige esta atualização." + }, + "notify.error.title": { + "message": "Erro" + }, + "notify.error.connect": { + "message": "Falha ao conectar" + }, + "notify.error.disconnect": { + "message": "Falha ao desconectar" + }, + "notify.error.switchProfile": { + "message": "Falha ao alternar para {profile}" + }, + "notify.error.exitNode": { + "message": "Falha ao atualizar o nó de saída {name}" + }, + "notify.sessionExpired.title": { + "message": "Sessão do NetBird expirada" + }, + "notify.sessionExpired.body": { + "message": "A sua sessão do NetBird expirou. Faça login novamente." + }, + "notify.sessionWarning.title": { + "message": "A sessão expira em breve" + }, + "notify.sessionWarning.body": { + "message": "A sua sessão do NetBird expira em {remaining}. Clique em Renovar agora para renová-la." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "A sua sessão do NetBird está prestes a expirar. Clique em Renovar agora para renová-la." + }, + "notify.sessionWarning.extend": { + "message": "Renovar agora" + }, + "notify.sessionWarning.dismiss": { + "message": "Dispensar" + }, + "notify.sessionWarning.failed": { + "message": "Falha ao renovar a sessão do NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sessão do NetBird renovada" + }, + "notify.sessionWarning.successBody": { + "message": "A sua sessão foi renovada." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Prazo da sessão rejeitado" + }, + "notify.sessionDeadlineRejected.body": { + "message": "O servidor enviou um prazo de sessão inválido. Faça login novamente." + }, + "notify.mdm.policyApplied.title": { + "message": "Definições do NetBird atualizadas" + }, + "notify.mdm.policyApplied.body": { + "message": "A sua configuração do NetBird foi atualizada pela política de TI." + }, + "common.cancel": { + "message": "Cancelar" + }, + "common.save": { + "message": "Salvar" + }, + "common.saveChanges": { + "message": "Salvar alterações" + }, + "common.saving": { + "message": "Salvando…" + }, + "common.close": { + "message": "Fechar" + }, + "common.copy": { + "message": "Copiar" + }, + "common.togglePasswordVisibility": { + "message": "Alternar visibilidade da senha" + }, + "common.increase": { + "message": "Aumentar" + }, + "common.decrease": { + "message": "Diminuir" + }, + "common.delete": { + "message": "Excluir" + }, + "common.create": { + "message": "Criar" + }, + "common.add": { + "message": "Adicionar" + }, + "common.remove": { + "message": "Remover" + }, + "common.refresh": { + "message": "Atualizar" + }, + "common.loading": { + "message": "Carregando…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Nenhum resultado encontrado" + }, + "common.noResults.description": { + "message": "Não encontramos nenhum resultado. Tente um termo de busca diferente ou altere os filtros." + }, + "notConnected.title": { + "message": "Desconectado" + }, + "notConnected.description": { + "message": "Conecte-se ao NetBird primeiro para ver informações detalhadas sobre seus peers, recursos de rede e nós de saída." + }, + "connect.status.disconnected": { + "message": "Desconectado" + }, + "connect.status.connecting": { + "message": "Conectando..." + }, + "connect.status.connected": { + "message": "Conectado" + }, + "connect.status.disconnecting": { + "message": "Desconectando..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon indisponível" + }, + "connect.status.loginRequired": { + "message": "Login necessário" + }, + "connect.error.loginTitle": { + "message": "Falha no login" + }, + "connect.error.connectTitle": { + "message": "Falha ao conectar" + }, + "connect.error.disconnectTitle": { + "message": "Falha ao desconectar" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} de {total} conectados" + }, + "nav.resources.title": { + "message": "Recursos" + }, + "nav.resources.description": { + "message": "{active} de {total} ativos" + }, + "nav.exitNode.title": { + "message": "Nós de saída" + }, + "nav.exitNode.none": { + "message": "Inativo" + }, + "nav.exitNode.using": { + "message": "Via {name}" + }, + "header.openSettings": { + "message": "Abrir configurações" + }, + "header.togglePanel": { + "message": "Alternar painel lateral" + }, + "profile.selector.loading": { + "message": "Carregando..." + }, + "profile.selector.noProfile": { + "message": "Nenhum perfil" + }, + "profile.selector.searchPlaceholder": { + "message": "Buscar perfil por nome..." + }, + "profile.selector.emptyTitle": { + "message": "Nenhum perfil encontrado" + }, + "profile.selector.emptyDescription": { + "message": "Tente um termo de busca diferente ou crie um novo perfil." + }, + "profile.selector.newProfile": { + "message": "Novo perfil" + }, + "profile.selector.moreOptions": { + "message": "Mais opções" + }, + "profile.selector.deregister": { + "message": "Cancelar registro" + }, + "profile.selector.delete": { + "message": "Excluir" + }, + "profile.selector.switchTo": { + "message": "Alternar para este perfil" + }, + "profile.selector.edit": { + "message": "Editar" + }, + "profile.edit.title": { + "message": "Editar perfil" + }, + "profile.edit.submit": { + "message": "Salvar alterações" + }, + "profile.dialog.title": { + "message": "Insira o nome do perfil" + }, + "profile.dialog.nameLabel": { + "message": "Nome do perfil" + }, + "profile.dialog.description": { + "message": "Defina um nome fácil de identificar para o seu perfil." + }, + "profile.dialog.placeholder": { + "message": "ex.: trabalho" + }, + "profile.dialog.submit": { + "message": "Adicionar perfil" + }, + "profile.dialog.required": { + "message": "Insira um nome de perfil, ex.: trabalho, casa" + }, + "profile.dialog.managementHelp": { + "message": "Use o NetBird Cloud ou seu próprio servidor." + }, + "profile.dialog.urlUnreachable": { + "message": "Não foi possível acessar este servidor. Verifique a URL ou adicione o perfil mesmo assim se tiver certeza de que está correta." + }, + "header.menu.settings": { + "message": "Configurações..." + }, + "header.menu.defaultView": { + "message": "Visualização padrão" + }, + "header.menu.advancedView": { + "message": "Visualização avançada" + }, + "header.menu.updateAvailable": { + "message": "Atualização disponível" + }, + "header.menu.open": { + "message": "Abrir menu" + }, + "header.profile.switch": { + "message": "Trocar perfil" + }, + "connect.toggle.label": { + "message": "Alternar conexão NetBird" + }, + "connect.localIp.label": { + "message": "Endereços IP locais" + }, + "common.search": { + "message": "Pesquisar" + }, + "common.filter": { + "message": "Filtrar" + }, + "exitNodes.dropdown.trigger": { + "message": "Selecionar nó de saída" + }, + "peers.row.label": { + "message": "Abrir detalhes de {name}, {status}" + }, + "peers.dialog.title": { + "message": "Detalhes do par" + }, + "networks.row.toggle": { + "message": "Alternar {name}" + }, + "networks.bulk.label": { + "message": "Alternar todos os recursos visíveis" + }, + "settings.nav.label": { + "message": "Seções das configurações" + }, + "profile.switch.title": { + "message": "Alternar perfil para \"{name}\"?" + }, + "profile.switch.message": { + "message": "Tem certeza de que deseja alternar de perfil?\nO seu perfil atual será desconectado." + }, + "profile.switch.confirm": { + "message": "Confirmar" + }, + "profile.deregister.title": { + "message": "Cancelar registro do perfil \"{name}\"?" + }, + "profile.deregister.message": { + "message": "Tem certeza de que deseja cancelar o registro deste perfil?\nVocê precisará fazer login novamente para usá-lo." + }, + "profile.deregister.confirm": { + "message": "Cancelar registro" + }, + "profile.delete.title": { + "message": "Excluir o perfil \"{name}\"?" + }, + "profile.delete.message": { + "message": "Tem certeza de que deseja excluir este perfil?\nEsta ação não pode ser desfeita." + }, + "profile.delete.disabledActive": { + "message": "Perfis ativos não podem ser excluídos. Alterne para outro antes de excluir este perfil." + }, + "profile.delete.disabledDefault": { + "message": "O perfil padrão não pode ser excluído." + }, + "profile.error.switchTitle": { + "message": "Falha ao alternar de perfil" + }, + "profile.error.deregisterTitle": { + "message": "Falha ao cancelar registro do perfil" + }, + "profile.error.deleteTitle": { + "message": "Falha ao excluir o perfil" + }, + "profile.error.createTitle": { + "message": "Falha ao criar o perfil" + }, + "profile.error.editTitle": { + "message": "Falha ao editar o perfil" + }, + "profile.error.loadTitle": { + "message": "Falha ao carregar os perfis" + }, + "profile.dropdown.activeProfile": { + "message": "Perfil ativo" + }, + "profile.dropdown.switchProfile": { + "message": "Alternar perfil" + }, + "profile.dropdown.noEmail": { + "message": "Outro" + }, + "profile.dropdown.addProfile": { + "message": "Adicionar perfil" + }, + "profile.dropdown.manageProfiles": { + "message": "Gerenciar perfis" + }, + "profile.dropdown.settings": { + "message": "Configurações" + }, + "settings.profiles.section.profiles": { + "message": "Perfis" + }, + "settings.profiles.intro": { + "message": "Mantenha identidades separadas do NetBird lado a lado, por exemplo contas de trabalho e pessoais, ou diferentes servidores de gerenciamento. Adicione, cancele o registro ou exclua perfis abaixo." + }, + "settings.profiles.addProfile": { + "message": "Adicionar perfil" + }, + "settings.profiles.active": { + "message": "Ativo" + }, + "settings.profiles.emptyTitle": { + "message": "Nenhum perfil" + }, + "settings.profiles.emptyDescription": { + "message": "Crie um perfil para conectar a um servidor de gerenciamento do NetBird." + }, + "settings.error.loadTitle": { + "message": "Falha ao carregar as configurações" + }, + "settings.error.saveTitle": { + "message": "Falha ao salvar as configurações" + }, + "settings.error.debugBundleTitle": { + "message": "Falha no pacote de depuração" + }, + "settings.tabs.general": { + "message": "Geral" + }, + "settings.tabs.network": { + "message": "Rede" + }, + "settings.tabs.security": { + "message": "Segurança" + }, + "settings.tabs.profiles": { + "message": "Perfis" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avançado" + }, + "settings.tabs.troubleshooting": { + "message": "Solução de problemas" + }, + "settings.tabs.about": { + "message": "Sobre" + }, + "settings.tabs.updateAvailable": { + "message": "Atualização disponível" + }, + "settings.general.section.general": { + "message": "Geral" + }, + "settings.general.section.connection": { + "message": "Conexão" + }, + "settings.general.connectOnStartup.label": { + "message": "Conectar ao iniciar" + }, + "settings.general.connectOnStartup.help": { + "message": "Estabelecer uma conexão automaticamente quando o serviço iniciar." + }, + "settings.general.notifications.label": { + "message": "Notificações na área de trabalho" + }, + "settings.general.notifications.help": { + "message": "Mostrar notificações na área de trabalho para novas atualizações e eventos de conexão." + }, + "settings.general.autostart.label": { + "message": "Iniciar a interface do NetBird ao fazer login" + }, + "settings.general.autostart.help": { + "message": "Iniciar a interface do NetBird automaticamente quando você fizer login. Isto afeta apenas a interface gráfica, não o serviço em segundo plano." + }, + "settings.general.autostart.errorTitle": { + "message": "Falha ao alterar o início automático" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Permanecer conectado ao sair", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Idioma de exibição" + }, + "settings.general.language.help": { + "message": "Escolha o idioma da interface do NetBird." + }, + "settings.general.language.search": { + "message": "Buscar idioma…" + }, + "settings.general.language.empty": { + "message": "Nenhum idioma corresponde." + }, + "settings.general.management.label": { + "message": "Servidor de gerenciamento" + }, + "settings.general.management.help": { + "message": "Conecte ao NetBird Cloud ou ao seu próprio servidor de gerenciamento auto-hospedado. As alterações reconectarão o cliente." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Auto-hospedado" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Insira uma URL válida, ex.: https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Não foi possível acessar este servidor. Verifique a URL ou salve mesmo assim se tiver certeza de que está correta." + }, + "settings.general.management.switchCloudTitle": { + "message": "Alternar para o NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Isto desconecta o seu servidor auto-hospedado.\nVocê pode precisar fazer login novamente." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Alternar para o Cloud" + }, + "settings.network.section.connectivity": { + "message": "Conectividade" + }, + "settings.network.section.routingDns": { + "message": "Roteamento e DNS" + }, + "settings.network.monitor.label": { + "message": "Reconectar ao mudar de rede" + }, + "settings.network.monitor.help": { + "message": "Monitora a rede e reconecta automaticamente diante de mudanças como troca de Wi-Fi, alterações na Ethernet ou retorno do modo de suspensão." + }, + "settings.network.dns.label": { + "message": "Ativar DNS" + }, + "settings.network.dns.help": { + "message": "Aplicar as configurações de DNS gerenciadas pelo NetBird ao resolvedor do host." + }, + "settings.network.clientRoutes.label": { + "message": "Ativar rotas de cliente" + }, + "settings.network.clientRoutes.help": { + "message": "Aceitar rotas de outros peers para alcançar as redes deles." + }, + "settings.network.serverRoutes.label": { + "message": "Ativar rotas de servidor" + }, + "settings.network.serverRoutes.help": { + "message": "Anunciar as rotas locais deste host para outros peers." + }, + "settings.network.ipv6.label": { + "message": "Ativar IPv6" + }, + "settings.network.ipv6.help": { + "message": "Usar endereçamento IPv6 para a rede de sobreposição do NetBird." + }, + "settings.security.section.firewall": { + "message": "Firewall" + }, + "settings.security.section.encryption": { + "message": "Criptografia" + }, + "settings.security.blockInbound.label": { + "message": "Bloquear tráfego de entrada" + }, + "settings.security.blockInbound.help": { + "message": "Rejeitar conexões não solicitadas de peers para este dispositivo e quaisquer redes que ele roteie. O tráfego de saída não é afetado." + }, + "settings.security.blockLan.label": { + "message": "Bloquear acesso à LAN" + }, + "settings.security.blockLan.help": { + "message": "Impedir que peers alcancem a sua rede local ou os dispositivos dela quando este dispositivo roteia o tráfego deles." + }, + "settings.security.rosenpass.label": { + "message": "Ativar resistência quântica" + }, + "settings.security.rosenpass.help": { + "message": "Adicionar uma troca de chaves pós-quântica via Rosenpass sobre o WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Ativar modo permissivo" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Permitir conexões com peers sem suporte a resistência quântica." + }, + "settings.ssh.section.server": { + "message": "Servidor" + }, + "settings.ssh.section.capabilities": { + "message": "Recursos" + }, + "settings.ssh.section.authentication": { + "message": "Autenticação" + }, + "settings.ssh.server.label": { + "message": "Ativar servidor SSH" + }, + "settings.ssh.server.help": { + "message": "Executar o servidor SSH do NetBird neste host para que outros peers possam conectar a ele." + }, + "settings.ssh.root.label": { + "message": "Permitir login como root" + }, + "settings.ssh.root.help": { + "message": "Permitir que peers façam login como usuário root. Desative para exigir uma conta sem privilégios." + }, + "settings.ssh.sftp.label": { + "message": "Permitir SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transferir arquivos com segurança usando clientes SFTP ou SCP nativos." + }, + "settings.ssh.localForward.label": { + "message": "Encaminhamento de porta local" + }, + "settings.ssh.localForward.help": { + "message": "Permitir que peers conectados encaminhem portas locais para serviços acessíveis a partir deste host." + }, + "settings.ssh.remoteForward.label": { + "message": "Encaminhamento de porta remota" + }, + "settings.ssh.remoteForward.help": { + "message": "Permitir que peers conectados exponham portas deste host de volta para a própria máquina deles." + }, + "settings.ssh.jwt.label": { + "message": "Ativar autenticação JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verificar cada sessão SSH no seu IdP para identidade do usuário e auditoria. Desative para depender apenas das políticas de ACL da rede, útil quando nenhum IdP está disponível." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL do cache de JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Por quanto tempo este cliente mantém um JWT em cache antes de solicitar novamente em conexões SSH de saída. Defina como 0 para desativar o cache e autenticar em cada conexão." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "s" + }, + "settings.advanced.section.interface": { + "message": "Interface" + }, + "settings.advanced.section.security": { + "message": "Segurança" + }, + "settings.advanced.interfaceName.label": { + "message": "Nome" + }, + "settings.advanced.interfaceName.error": { + "message": "Use de 1 a 15 letras, dígitos, pontos, hifens ou sublinhados." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Deve começar com \"utun\" seguido de um número (ex.: utun100)." + }, + "settings.advanced.port.label": { + "message": "Porta" + }, + "settings.advanced.port.error": { + "message": "Insira uma porta entre {min} e {max}." + }, + "settings.advanced.port.help": { + "message": "Se definida como 0, uma porta livre aleatória será usada." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Insira um valor de MTU entre {min} e {max}." + }, + "settings.advanced.psk.label": { + "message": "Chave pré-compartilhada" + }, + "settings.advanced.psk.help": { + "message": "PSK opcional do WireGuard para criptografia simétrica adicional. Não é o mesmo que uma chave de configuração do NetBird. Você só se comunicará com peers que usem a mesma chave pré-compartilhada." + }, + "settings.troubleshooting.section.title": { + "message": "Pacote de depuração" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizar informações sensíveis" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Oculta endereços IP, domínios e outros valores sensíveis." + }, + "settings.troubleshooting.anonymize.info": { + "message": "O modo padrão mantém os endereços IPv4 internos e os nomes dos peers legíveis para o suporte. O modo estrito anonimiza também os endereços IP privados (RFC 1918), CGNAT e link-local, os nomes dos peers e as chaves públicas do WireGuard. Valores recorrentes recebem o mesmo marcador, então os peers continuam distinguíveis. Use o modo estrito ao compartilhar o pacote de depuração fora da sua organização." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Nenhum" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Padrão" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Estrito" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Incluir informações do sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Incluir o OS, o kernel, as interfaces de rede e as tabelas de roteamento." + }, + "settings.troubleshooting.upload.label": { + "message": "Enviar pacote aos servidores do NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Retorna uma chave de upload para compartilhar com o suporte do NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Habilitar logs de trace" + }, + "settings.troubleshooting.trace.help": { + "message": "Eleva o nível de log para TRACE e o restaura em seguida." + }, + "settings.troubleshooting.capture.label": { + "message": "Sessão de captura" + }, + "settings.troubleshooting.capture.help": { + "message": "Reconecta e aguarda para que você possa reproduzir o problema." + }, + "settings.troubleshooting.packets.label": { + "message": "Capturar pacotes de rede" + }, + "settings.troubleshooting.packets.help": { + "message": "Salva um .pcap do tráfego de rede durante a sessão de captura." + }, + "settings.troubleshooting.duration.label": { + "message": "Duração da captura" + }, + "settings.troubleshooting.duration.help": { + "message": "Por quanto tempo a sessão de captura é executada." + }, + "settings.troubleshooting.duration.suffix": { + "message": "min" + }, + "settings.troubleshooting.create": { + "message": "Criar pacote" + }, + "settings.troubleshooting.progress.description": { + "message": "Coletando logs, detalhes do sistema e estado da conexão. Isto costuma levar um instante — mantenha esta janela aberta até concluir." + }, + "settings.troubleshooting.cancelling": { + "message": "Cancelando…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Pacote de depuração enviado com sucesso!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Pacote salvo" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Compartilhe a chave de upload abaixo com o suporte do NetBird. Uma cópia local também foi salva no seu dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "O seu pacote de depuração foi salvo localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copiar chave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Abrir pasta" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Abrir local do arquivo" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Falha no upload: {reason} O pacote continua salvo localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Falha no upload. O pacote continua salvo localmente." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconectando o NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturando logs de depuração" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Gerando o pacote de depuração…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Enviando para o NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Cancelando…" + }, + "settings.about.client": { + "message": "NetBird Client v{version}" + }, + "settings.about.clientName": { + "message": "NetBird Client" + }, + "settings.about.development": { + "message": "[Development]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Todos os direitos reservados." + }, + "settings.about.links.imprint": { + "message": "Identificação legal" + }, + "settings.about.links.privacy": { + "message": "Privacidade" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Termos de serviço" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Fórum" + }, + "settings.about.community.documentation": { + "message": "Documentação" + }, + "settings.about.community.feedback": { + "message": "Feedback" + }, + "update.banner.message": { + "message": "O NetBird {version} está pronto para instalar." + }, + "update.banner.later": { + "message": "Mais tarde" + }, + "update.banner.installNow": { + "message": "Instalar agora" + }, + "update.card.versionAvailableDownload": { + "message": "A versão {version} está disponível para download." + }, + "update.card.versionAvailableInstall": { + "message": "A versão {version} está disponível para instalação." + }, + "update.card.whatsNew": { + "message": "Novidades?" + }, + "update.card.installNow": { + "message": "Instalar agora" + }, + "update.card.getInstaller": { + "message": "Baixar" + }, + "update.card.autoCheckInterval": { + "message": "O NetBird verifica atualizações em segundo plano." + }, + "update.card.changelog": { + "message": "Registro de alterações" + }, + "update.card.onLatestVersion": { + "message": "Você está na versão mais recente" + }, + "update.header.tooltip": { + "message": "Atualização disponível" + }, + "update.overlay.updatingVersion": { + "message": "Atualizando o NetBird para a v{version}" + }, + "update.overlay.updating": { + "message": "Atualizando o NetBird" + }, + "update.overlay.description": { + "message": "Uma versão mais recente está disponível e sendo instalada. O NetBird reiniciará automaticamente quando a atualização terminar." + }, + "update.overlay.error.timeoutTitle": { + "message": "A atualização está demorando demais" + }, + "update.overlay.error.timeoutDescription": { + "message": "A instalação de {target} demorou demais e não foi concluída." + }, + "update.overlay.error.canceledTitle": { + "message": "A atualização foi interrompida" + }, + "update.overlay.error.canceledDescription": { + "message": "A atualização para {target} foi cancelada antes de terminar." + }, + "update.overlay.error.failTitle": { + "message": "Não foi possível instalar a atualização" + }, + "update.overlay.error.failDescription": { + "message": "Não foi possível instalar {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "erro desconhecido" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "a nova versão" + }, + "update.error.loadStateTitle": { + "message": "Falha ao carregar o estado da atualização" + }, + "update.error.triggerTitle": { + "message": "Falha ao iniciar a atualização" + }, + "update.page.versionLine": { + "message": "Atualizando o cliente para: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Atualizando o cliente." + }, + "update.page.outdated": { + "message": "A versão do seu cliente é anterior à versão de atualização automática definida no Management." + }, + "update.page.status.running": { + "message": "Atualizando" + }, + "update.page.status.timeout": { + "message": "A atualização expirou. Tente novamente." + }, + "update.page.status.canceled": { + "message": "Atualização cancelada." + }, + "update.page.status.failed": { + "message": "Falha na atualização: {message}" + }, + "update.page.status.unknownError": { + "message": "erro de atualização desconhecido" + }, + "update.page.failedTitle": { + "message": "Falha na atualização" + }, + "update.page.timeoutMessage": { + "message": "A atualização expirou." + }, + "update.page.dontClose": { + "message": "Não feche esta janela." + }, + "update.page.updating": { + "message": "Atualizando…" + }, + "update.page.complete": { + "message": "Atualização concluída" + }, + "update.page.failed": { + "message": "Falha na atualização" + }, + "window.title.settings": { + "message": "Configurações" + }, + "window.title.signIn": { + "message": "Login" + }, + "window.title.sessionExpiration": { + "message": "Sessão expirando" + }, + "window.title.updating": { + "message": "Atualizando" + }, + "window.title.welcome": { + "message": "Bem-vindo ao NetBird" + }, + "window.title.error": { + "message": "Erro" + }, + "welcome.title": { + "message": "Procure o NetBird na sua bandeja" + }, + "welcome.titleMac": { + "message": "Procure o NetBird na sua barra de menus" + }, + "welcome.description": { + "message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações." + }, + "welcome.descriptionMac": { + "message": "O NetBird fica na sua barra de menus. Clique no ícone para conectar, alternar perfis ou abrir as configurações." + }, + "welcome.continue": { + "message": "Continuar" + }, + "welcome.back": { + "message": "Voltar" + }, + "welcome.management.title": { + "message": "Configurar o NetBird" + }, + "welcome.management.description": { + "message": "Clique em Continuar para começar ou escolha Auto-hospedado se você tiver o seu próprio servidor NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Use o nosso serviço hospedado. Sem configuração necessária." + }, + "welcome.management.selfHosted.title": { + "message": "Auto-hospedado" + }, + "welcome.management.selfHosted.description": { + "message": "Conecte ao seu próprio servidor de gerenciamento." + }, + "welcome.management.urlLabel": { + "message": "URL do servidor de gerenciamento" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Insira uma URL válida, ex.: https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Não foi possível acessar este servidor. Verifique a URL ou a sua rede e continue se tiver certeza de que está correta." + }, + "welcome.management.checking": { + "message": "Verificando…" + }, + "browserLogin.title": { + "message": "Continue no seu navegador para concluir o login" + }, + "browserLogin.notSeeing": { + "message": "Não está vendo a aba do navegador?" + }, + "browserLogin.tryAgain": { + "message": "Tentar novamente" + }, + "browserLogin.openFailedTitle": { + "message": "Falha ao abrir o navegador" + }, + "sessionExpiration.title": { + "message": "A sessão expira em breve" + }, + "sessionExpiration.titleLater": { + "message": "A sua sessão vai expirar" + }, + "sessionExpiration.description": { + "message": "Este dispositivo será desconectado em breve. Renove com um login pelo navegador." + }, + "sessionExpiration.descriptionLater": { + "message": "Um login pelo navegador mantém este dispositivo conectado à sua rede." + }, + "sessionExpiration.stay": { + "message": "Renovar sessão" + }, + "sessionExpiration.authenticate": { + "message": "Autenticar" + }, + "sessionExpiration.logout": { + "message": "Sair" + }, + "sessionExpiration.expired": { + "message": "Sessão expirada" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo desconectado. Autentique com um login pelo navegador para reconectar." + }, + "sessionExpiration.close": { + "message": "Fechar" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Falha ao renovar a sessão" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Falha ao sair" + }, + "peers.search.placeholder": { + "message": "Buscar por nome ou IP" + }, + "peers.filter.all": { + "message": "Todos" + }, + "peers.filter.online": { + "message": "Online" + }, + "peers.filter.offline": { + "message": "Offline" + }, + "peers.empty.title": { + "message": "Nenhum peer disponível" + }, + "peers.empty.description": { + "message": "Você não tem nenhum peer disponível ou não tem acesso a nenhum deles." + }, + "peers.details.domain": { + "message": "Domínio" + }, + "peers.details.netbirdIp": { + "message": "IP do NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 do NetBird" + }, + "peers.details.publicKey": { + "message": "Chave pública" + }, + "peers.details.connection": { + "message": "Conexão" + }, + "peers.details.latency": { + "message": "Latência" + }, + "peers.details.lastHandshake": { + "message": "Último handshake" + }, + "peers.details.statusSince": { + "message": "Última atualização da conexão" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Enviados" + }, + "peers.details.bytesReceived": { + "message": "Recebidos" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Nunca" + }, + "peers.details.justNow": { + "message": "Agora mesmo" + }, + "peers.details.refresh": { + "message": "Atualizar" + }, + "peers.status.connected": { + "message": "Conectado" + }, + "peers.status.connecting": { + "message": "Conectando" + }, + "peers.status.disconnected": { + "message": "Desconectado" + }, + "peers.details.relayAddress": { + "message": "Relay" + }, + "peers.details.networks": { + "message": "Recursos" + }, + "peers.details.relayed": { + "message": "Via relay" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass ativado" + }, + "networks.search.placeholder": { + "message": "Buscar por rede ou domínio" + }, + "networks.filter.all": { + "message": "Todos" + }, + "networks.filter.active": { + "message": "Ativos" + }, + "networks.filter.overlapping": { + "message": "Sobrepostos" + }, + "networks.empty.title": { + "message": "Nenhum recurso disponível" + }, + "networks.empty.description": { + "message": "Você não tem nenhum recurso de rede disponível ou não tem acesso a nenhum deles." + }, + "networks.selected": { + "message": "Selecionado" + }, + "networks.unselected": { + "message": "Não selecionado" + }, + "networks.ips.heading": { + "message": "IPs resolvidos" + }, + "networks.bulk.selectionCount": { + "message": "{selected} de {total} ativos" + }, + "networks.bulk.enableAll": { + "message": "Ativar todos" + }, + "networks.bulk.disableAll": { + "message": "Desativar todos" + }, + "exitNodes.search.placeholder": { + "message": "Buscar nós de saída" + }, + "exitNodes.none": { + "message": "Nenhum" + }, + "exitNodes.empty.title": { + "message": "Nenhum nó de saída disponível" + }, + "exitNodes.empty.description": { + "message": "Nenhum nó de saída foi compartilhado com este peer." + }, + "exitNodes.card.title": { + "message": "Nó de saída" + }, + "exitNodes.card.statusActive": { + "message": "Ativo" + }, + "exitNodes.card.statusInactive": { + "message": "Inativo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Nenhum" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Conexão direta sem um nó de saída" + }, + "quickActions.connect": { + "message": "Conectar" + }, + "quickActions.disconnect": { + "message": "Desconectar" + }, + "daemon.unavailable.title": { + "message": "O serviço do NetBird não está em execução" + }, + "daemon.unavailable.description": { + "message": "O aplicativo reconectará automaticamente assim que o serviço estiver em execução." + }, + "daemon.unavailable.docsLink": { + "message": "Documentação" + }, + "daemon.outdated.title": { + "message": "O NetBird Client está desatualizado" + }, + "daemon.outdated.description": { + "message": "A nova GUI não é compatível com o seu cliente mais antigo. Atualize o seu cliente para usar o novo aplicativo." + }, + "daemon.outdated.download": { + "message": "Baixar a versão mais recente" + }, + "error.jwt_clock_skew": { + "message": "Falha no login: o relógio deste dispositivo está fora de sincronia com o servidor. Sincronize o relógio do sistema e tente novamente." + }, + "error.jwt_expired": { + "message": "O seu token de login expirou. Faça login novamente." + }, + "error.jwt_signature_invalid": { + "message": "Falha no login: a assinatura do token é inválida. Entre em contato com o seu administrador." + }, + "error.session_expired": { + "message": "A sua sessão expirou. Faça login novamente." + }, + "error.invalid_setup_key": { + "message": "A chave de configuração está ausente ou é inválida." + }, + "error.permission_denied": { + "message": "O login foi rejeitado pelo servidor." + }, + "error.daemon_unreachable": { + "message": "O daemon do NetBird não está respondendo. Verifique se o serviço está em execução." + }, + "error.unknown": { + "message": "A operação falhou." + }, + "settings.ssh.privilege.hint": { + "message": "Requer {actor}. Execute isto em vez disso:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Você pode desativar isto, mas ativar novamente requer {actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Você pode ativar isto, mas desativar novamente requer {actor}:" + } +} diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json new file mode 100644 index 000000000..958b5a21c --- /dev/null +++ b/client/ui/i18n/locales/ru/common.json @@ -0,0 +1,1363 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Отключено" + }, + "tray.status.daemonUnavailable": { + "message": "Не запущено" + }, + "tray.status.error": { + "message": "Ошибка" + }, + "tray.status.connected": { + "message": "Подключено" + }, + "tray.status.connecting": { + "message": "Подключение" + }, + "tray.status.needsLogin": { + "message": "Требуется вход" + }, + "tray.status.loginFailed": { + "message": "Ошибка входа" + }, + "tray.status.sessionExpired": { + "message": "Сеанс истёк" + }, + "tray.session.expiresIn": { + "message": "Сеанс истекает через {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "менее чем минуту" + }, + "tray.session.unit.minute": { + "message": "1 минуту" + }, + "tray.session.unit.minutes": { + "message": "{count} минут" + }, + "tray.session.unit.hour": { + "message": "1 час" + }, + "tray.session.unit.hours": { + "message": "{count} часов" + }, + "tray.session.unit.day": { + "message": "1 день" + }, + "tray.session.unit.days": { + "message": "{count} дней" + }, + "tray.menu.open": { + "message": "Открыть NetBird" + }, + "tray.menu.connect": { + "message": "Подключиться" + }, + "tray.menu.disconnect": { + "message": "Отключиться" + }, + "tray.menu.exitNode": { + "message": "Выходной узел" + }, + "tray.menu.networks": { + "message": "Ресурсы" + }, + "tray.menu.profiles": { + "message": "Профили" + }, + "tray.menu.manageProfiles": { + "message": "Управление профилями" + }, + "tray.menu.settings": { + "message": "Настройки…" + }, + "tray.menu.debugBundle": { + "message": "Создать отладочный пакет" + }, + "tray.menu.about": { + "message": "Помощь и поддержка" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Документация" + }, + "tray.menu.troubleshoot": { + "message": "Диагностика" + }, + "tray.menu.downloadLatest": { + "message": "Загрузить последнюю версию" + }, + "tray.menu.installVersion": { + "message": "Установить версию {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Демон: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Выйти из NetBird" + }, + "notify.daemonOutdated.title": { + "message": "Служба NetBird устарела" + }, + "notify.daemonOutdated.body": { + "message": "Обновите службу NetBird, чтобы использовать это приложение." + }, + "notify.update.title": { + "message": "Доступно обновление NetBird" + }, + "notify.update.body": { + "message": "Доступна версия NetBird {version}." + }, + "notify.update.enforcedSuffix": { + "message": " Ваш администратор требует установить это обновление." + }, + "notify.error.title": { + "message": "Ошибка" + }, + "notify.error.connect": { + "message": "Не удалось подключиться" + }, + "notify.error.disconnect": { + "message": "Не удалось отключиться" + }, + "notify.error.switchProfile": { + "message": "Не удалось переключиться на {profile}" + }, + "notify.error.exitNode": { + "message": "Не удалось обновить выходной узел {name}" + }, + "notify.sessionExpired.title": { + "message": "Сеанс NetBird истёк" + }, + "notify.sessionExpired.body": { + "message": "Сеанс NetBird истёк. Пожалуйста, войдите снова." + }, + "notify.sessionWarning.title": { + "message": "Сеанс скоро истечёт" + }, + "notify.sessionWarning.body": { + "message": "Сеанс NetBird истекает через {remaining}. Нажмите «Продлить сейчас», чтобы продлить сеанс." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "Сеанс NetBird скоро истечёт. Нажмите «Продлить сейчас», чтобы продлить сеанс." + }, + "notify.sessionWarning.extend": { + "message": "Продлить сейчас" + }, + "notify.sessionWarning.dismiss": { + "message": "Закрыть" + }, + "notify.sessionWarning.failed": { + "message": "Не удалось продлить сеанс NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Сеанс NetBird продлён" + }, + "notify.sessionWarning.successBody": { + "message": "Сеанс обновлён." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Неверный срок действия сеанса" + }, + "notify.sessionDeadlineRejected.body": { + "message": "Сервер передал неверный срок действия сеанса. Пожалуйста, войдите снова." + }, + "notify.mdm.policyApplied.title": { + "message": "Настройки NetBird обновлены" + }, + "notify.mdm.policyApplied.body": { + "message": "Конфигурация NetBird была обновлена в соответствии с вашей ИТ-политикой." + }, + "common.cancel": { + "message": "Отмена" + }, + "common.save": { + "message": "Сохранить" + }, + "common.saveChanges": { + "message": "Сохранить изменения" + }, + "common.saving": { + "message": "Сохранение…" + }, + "common.close": { + "message": "Закрыть" + }, + "common.copy": { + "message": "Копировать" + }, + "common.togglePasswordVisibility": { + "message": "Показать или скрыть пароль" + }, + "common.increase": { + "message": "Увеличить" + }, + "common.decrease": { + "message": "Уменьшить" + }, + "common.delete": { + "message": "Удалить" + }, + "common.create": { + "message": "Создать" + }, + "common.add": { + "message": "Добавить" + }, + "common.remove": { + "message": "Убрать" + }, + "common.refresh": { + "message": "Обновить" + }, + "common.loading": { + "message": "Загрузка…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Ничего не найдено" + }, + "common.noResults.description": { + "message": "Ничего не найдено. Попробуйте изменить поисковый запрос или фильтры." + }, + "notConnected.title": { + "message": "Отключено" + }, + "notConnected.description": { + "message": "Сначала подключитесь к NetBird, чтобы увидеть подробную информацию о пирах, сетевых ресурсах и выходных узлах." + }, + "connect.status.disconnected": { + "message": "Отключено" + }, + "connect.status.connecting": { + "message": "Подключение…" + }, + "connect.status.connected": { + "message": "Подключено" + }, + "connect.status.disconnecting": { + "message": "Отключение…" + }, + "connect.status.daemonUnavailable": { + "message": "Демон недоступен" + }, + "connect.status.loginRequired": { + "message": "Требуется вход" + }, + "connect.error.loginTitle": { + "message": "Не удалось войти" + }, + "connect.error.connectTitle": { + "message": "Не удалось подключиться" + }, + "connect.error.disconnectTitle": { + "message": "Не удалось отключиться" + }, + "nav.peers.title": { + "message": "Пиры" + }, + "nav.peers.description": { + "message": "{connected} из {total} подключено" + }, + "nav.resources.title": { + "message": "Ресурсы" + }, + "nav.resources.description": { + "message": "{active} из {total} активно" + }, + "nav.exitNode.title": { + "message": "Выходные узлы" + }, + "nav.exitNode.none": { + "message": "Не активен" + }, + "nav.exitNode.using": { + "message": "Через {name}" + }, + "header.openSettings": { + "message": "Открыть настройки" + }, + "header.togglePanel": { + "message": "Показать или скрыть боковую панель" + }, + "profile.selector.loading": { + "message": "Загрузка…" + }, + "profile.selector.noProfile": { + "message": "Нет профиля" + }, + "profile.selector.searchPlaceholder": { + "message": "Поиск профиля по имени…" + }, + "profile.selector.emptyTitle": { + "message": "Профили не найдены" + }, + "profile.selector.emptyDescription": { + "message": "Измените поисковый запрос или создайте новый профиль." + }, + "profile.selector.newProfile": { + "message": "Новый профиль" + }, + "profile.selector.moreOptions": { + "message": "Дополнительные параметры" + }, + "profile.selector.deregister": { + "message": "Отменить регистрацию" + }, + "profile.selector.delete": { + "message": "Удалить" + }, + "profile.selector.switchTo": { + "message": "Переключиться на этот профиль" + }, + "profile.selector.edit": { + "message": "Изменить" + }, + "profile.edit.title": { + "message": "Изменить профиль" + }, + "profile.edit.submit": { + "message": "Сохранить изменения" + }, + "profile.dialog.title": { + "message": "Введите имя профиля" + }, + "profile.dialog.nameLabel": { + "message": "Имя профиля" + }, + "profile.dialog.description": { + "message": "Задайте легко узнаваемое имя для профиля." + }, + "profile.dialog.placeholder": { + "message": "например, работа" + }, + "profile.dialog.submit": { + "message": "Добавить профиль" + }, + "profile.dialog.required": { + "message": "Введите имя профиля, например работа или дом" + }, + "profile.dialog.managementHelp": { + "message": "Используйте NetBird Cloud или собственный сервер." + }, + "profile.dialog.urlUnreachable": { + "message": "Не удалось связаться с сервером. Проверьте URL или всё равно добавьте профиль, если уверены, что он правильный." + }, + "header.menu.settings": { + "message": "Настройки…" + }, + "header.menu.defaultView": { + "message": "Обычный вид" + }, + "header.menu.advancedView": { + "message": "Расширенный вид" + }, + "header.menu.updateAvailable": { + "message": "Доступно обновление" + }, + "header.menu.open": { + "message": "Открыть меню" + }, + "header.profile.switch": { + "message": "Сменить профиль" + }, + "connect.toggle.label": { + "message": "Переключить подключение NetBird" + }, + "connect.localIp.label": { + "message": "Локальные IP-адреса" + }, + "common.search": { + "message": "Поиск" + }, + "common.filter": { + "message": "Фильтр" + }, + "exitNodes.dropdown.trigger": { + "message": "Выбрать выходной узел" + }, + "peers.row.label": { + "message": "Открыть подробности для {name}, {status}" + }, + "peers.dialog.title": { + "message": "Сведения об узле" + }, + "networks.row.toggle": { + "message": "Переключить {name}" + }, + "networks.bulk.label": { + "message": "Переключить все видимые ресурсы" + }, + "settings.nav.label": { + "message": "Разделы настроек" + }, + "profile.switch.title": { + "message": "Переключиться на профиль «{name}»?" + }, + "profile.switch.message": { + "message": "Вы действительно хотите переключить профиль?\nТекущий профиль будет отключён." + }, + "profile.switch.confirm": { + "message": "Подтвердить" + }, + "profile.deregister.title": { + "message": "Отменить регистрацию профиля «{name}»?" + }, + "profile.deregister.message": { + "message": "Вы действительно хотите отменить регистрацию этого профиля?\nДля его использования потребуется войти снова." + }, + "profile.deregister.confirm": { + "message": "Отменить регистрацию" + }, + "profile.delete.title": { + "message": "Удалить профиль «{name}»?" + }, + "profile.delete.message": { + "message": "Вы действительно хотите удалить этот профиль?\nЭто действие нельзя отменить." + }, + "profile.delete.disabledActive": { + "message": "Активные профили нельзя удалить. Переключитесь на другой профиль, прежде чем удалять этот." + }, + "profile.delete.disabledDefault": { + "message": "Профиль по умолчанию нельзя удалить." + }, + "profile.error.switchTitle": { + "message": "Не удалось переключить профиль" + }, + "profile.error.deregisterTitle": { + "message": "Не удалось отменить регистрацию профиля" + }, + "profile.error.deleteTitle": { + "message": "Не удалось удалить профиль" + }, + "profile.error.createTitle": { + "message": "Не удалось создать профиль" + }, + "profile.error.editTitle": { + "message": "Не удалось изменить профиль" + }, + "profile.error.loadTitle": { + "message": "Не удалось загрузить профили" + }, + "profile.dropdown.activeProfile": { + "message": "Активный профиль" + }, + "profile.dropdown.switchProfile": { + "message": "Переключить профиль" + }, + "profile.dropdown.noEmail": { + "message": "Другое" + }, + "profile.dropdown.addProfile": { + "message": "Добавить профиль" + }, + "profile.dropdown.manageProfiles": { + "message": "Управление профилями" + }, + "profile.dropdown.settings": { + "message": "Настройки" + }, + "settings.profiles.section.profiles": { + "message": "Профили" + }, + "settings.profiles.intro": { + "message": "Управляйте несколькими профилями NetBird параллельно — например, рабочими и личными учётными записями или разными серверами управления. Ниже можно добавлять профили, отменять их регистрацию и удалять их." + }, + "settings.profiles.addProfile": { + "message": "Добавить профиль" + }, + "settings.profiles.active": { + "message": "Активен" + }, + "settings.profiles.emptyTitle": { + "message": "Нет профилей" + }, + "settings.profiles.emptyDescription": { + "message": "Создайте профиль, чтобы подключиться к серверу управления NetBird." + }, + "settings.error.loadTitle": { + "message": "Не удалось загрузить настройки" + }, + "settings.error.saveTitle": { + "message": "Не удалось сохранить настройки" + }, + "settings.error.debugBundleTitle": { + "message": "Не удалось создать отладочный пакет" + }, + "settings.tabs.general": { + "message": "Общие" + }, + "settings.tabs.network": { + "message": "Сеть" + }, + "settings.tabs.security": { + "message": "Безопасность" + }, + "settings.tabs.profiles": { + "message": "Профили" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Дополнительно" + }, + "settings.tabs.troubleshooting": { + "message": "Диагностика" + }, + "settings.tabs.about": { + "message": "О программе" + }, + "settings.tabs.updateAvailable": { + "message": "Доступно обновление" + }, + "settings.general.section.general": { + "message": "Общие" + }, + "settings.general.section.connection": { + "message": "Подключение" + }, + "settings.general.connectOnStartup.label": { + "message": "Подключаться при запуске" + }, + "settings.general.connectOnStartup.help": { + "message": "Автоматически устанавливать подключение при запуске службы." + }, + "settings.general.notifications.label": { + "message": "Уведомления на рабочем столе" + }, + "settings.general.notifications.help": { + "message": "Показывать уведомления о новых обновлениях и событиях подключения." + }, + "settings.general.autostart.label": { + "message": "Запускать интерфейс NetBird при входе" + }, + "settings.general.autostart.help": { + "message": "Автоматически запускать интерфейс NetBird при входе в систему. Это влияет только на графический интерфейс, но не на фоновую службу." + }, + "settings.general.autostart.errorTitle": { + "message": "Не удалось изменить автозапуск" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Оставаться подключённым после выхода", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "Язык интерфейса" + }, + "settings.general.language.help": { + "message": "Выберите язык интерфейса NetBird." + }, + "settings.general.language.search": { + "message": "Поиск языка…" + }, + "settings.general.language.empty": { + "message": "Языки не найдены." + }, + "settings.general.management.label": { + "message": "Сервер управления" + }, + "settings.general.management.help": { + "message": "Подключайтесь к NetBird Cloud или к собственному серверу управления. Изменения вызовут переподключение клиента." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Собственный сервер" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Введите корректный URL, например https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Не удалось связаться с сервером. Проверьте URL или всё равно сохраните, если уверены, что он правильный." + }, + "settings.general.management.switchCloudTitle": { + "message": "Переключиться на NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Это отключит ваш собственный сервер.\nВозможно, потребуется войти снова." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Переключиться на Cloud" + }, + "settings.network.section.connectivity": { + "message": "Подключение" + }, + "settings.network.section.routingDns": { + "message": "Маршрутизация и DNS" + }, + "settings.network.monitor.label": { + "message": "Переподключаться при смене сети" + }, + "settings.network.monitor.help": { + "message": "Отслеживать сеть и автоматически переподключаться при изменениях, таких как смена Wi-Fi, изменения Ethernet или выход из спящего режима." + }, + "settings.network.dns.label": { + "message": "Включить DNS" + }, + "settings.network.dns.help": { + "message": "Применять управляемые NetBird настройки DNS к системному резолверу." + }, + "settings.network.clientRoutes.label": { + "message": "Включить клиентские маршруты" + }, + "settings.network.clientRoutes.help": { + "message": "Принимать маршруты от других пиров для доступа к их сетям." + }, + "settings.network.serverRoutes.label": { + "message": "Включить серверные маршруты" + }, + "settings.network.serverRoutes.help": { + "message": "Анонсировать локальные маршруты этого хоста другим пирам." + }, + "settings.network.ipv6.label": { + "message": "Включить IPv6" + }, + "settings.network.ipv6.help": { + "message": "Использовать IPv6-адресацию для оверлейной сети NetBird." + }, + "settings.security.section.firewall": { + "message": "Брандмауэр" + }, + "settings.security.section.encryption": { + "message": "Шифрование" + }, + "settings.security.blockInbound.label": { + "message": "Блокировать входящий трафик" + }, + "settings.security.blockInbound.help": { + "message": "Отклонять незапрошенные подключения от пиров к этому устройству и сетям, которые оно маршрутизирует. Исходящий трафик не затрагивается." + }, + "settings.security.blockLan.label": { + "message": "Блокировать доступ к LAN" + }, + "settings.security.blockLan.help": { + "message": "Запрещать пирам доступ к вашей локальной сети и её устройствам, когда это устройство маршрутизирует их трафик." + }, + "settings.security.rosenpass.label": { + "message": "Включить квантовую устойчивость" + }, + "settings.security.rosenpass.help": { + "message": "Добавить постквантовый обмен ключами через Rosenpass поверх WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Включить разрешающий режим" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Разрешать подключения к пирам без поддержки квантовой устойчивости." + }, + "settings.ssh.section.server": { + "message": "Сервер" + }, + "settings.ssh.section.capabilities": { + "message": "Возможности" + }, + "settings.ssh.section.authentication": { + "message": "Аутентификация" + }, + "settings.ssh.server.label": { + "message": "Включить SSH-сервер" + }, + "settings.ssh.server.help": { + "message": "Запускать SSH-сервер NetBird на этом хосте, чтобы другие пиры могли к нему подключаться." + }, + "settings.ssh.root.label": { + "message": "Разрешить вход под root" + }, + "settings.ssh.root.help": { + "message": "Разрешить пирам входить под пользователем root. Отключите, чтобы требовать непривилегированную учётную запись." + }, + "settings.ssh.sftp.label": { + "message": "Разрешить SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Безопасно передавать файлы через стандартные клиенты SFTP или SCP." + }, + "settings.ssh.localForward.label": { + "message": "Локальная переадресация портов" + }, + "settings.ssh.localForward.help": { + "message": "Разрешить подключающимся пирам туннелировать локальные порты к службам, доступным с этого хоста." + }, + "settings.ssh.remoteForward.label": { + "message": "Удалённая переадресация портов" + }, + "settings.ssh.remoteForward.help": { + "message": "Разрешить подключающимся пирам пробрасывать порты этого хоста на свою машину." + }, + "settings.ssh.jwt.label": { + "message": "Включить аутентификацию JWT" + }, + "settings.ssh.jwt.help": { + "message": "Проверять каждую сессию SSH через ваш IdP для идентификации пользователя и аудита. Отключите, чтобы полагаться только на сетевые политики ACL — полезно, когда IdP недоступен." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL кэша JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Как долго этот клиент кэширует JWT, прежде чем снова запрашивать его при исходящих SSH-подключениях. Установите 0, чтобы отключить кэширование и аутентифицироваться при каждом подключении." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "сек." + }, + "settings.advanced.section.interface": { + "message": "Интерфейс" + }, + "settings.advanced.section.security": { + "message": "Безопасность" + }, + "settings.advanced.interfaceName.label": { + "message": "Имя" + }, + "settings.advanced.interfaceName.error": { + "message": "Используйте от 1 до 15 букв, цифр, точек, дефисов или подчёркиваний." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Должно начинаться с «utun», за которым следует число (например, utun100)." + }, + "settings.advanced.port.label": { + "message": "Порт" + }, + "settings.advanced.port.error": { + "message": "Введите порт от {min} до {max}." + }, + "settings.advanced.port.help": { + "message": "Если задано 0, будет использован случайный свободный порт." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Введите значение MTU от {min} до {max}." + }, + "settings.advanced.psk.label": { + "message": "Общий ключ" + }, + "settings.advanced.psk.help": { + "message": "Необязательный PSK WireGuard для дополнительного симметричного шифрования. Это не то же самое, что ключ установки NetBird. Вы будете обмениваться данными только с пирами, использующими тот же общий ключ." + }, + "settings.troubleshooting.section.title": { + "message": "Отладочный пакет" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Анонимизировать конфиденциальную информацию" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Скрывает IP-адреса, домены и другие конфиденциальные значения." + }, + "settings.troubleshooting.anonymize.info": { + "message": "Режим «По умолчанию» оставляет внутренние IPv4-адреса и имена пиров читаемыми для поддержки. Режим «Строгий» дополнительно анонимизирует частные (RFC 1918), CGNAT и link-local IP-адреса, имена пиров и публичные ключи WireGuard. Повторяющиеся значения заменяются одним и тем же заполнителем, поэтому пиры остаются различимыми. Используйте режим «Строгий», когда передаёте отладочный пакет за пределы вашей организации." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Нет" + }, + "settings.troubleshooting.anonymize.default": { + "message": "По умолчанию" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Строгий" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Включить сведения о системе" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Включить ОС, ядро, сетевые интерфейсы и таблицы маршрутизации." + }, + "settings.troubleshooting.upload.label": { + "message": "Загрузить пакет на серверы NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Возвращает ключ загрузки, который можно передать поддержке NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Включить журналы TRACE" + }, + "settings.troubleshooting.trace.help": { + "message": "Повышает уровень журналирования до TRACE и затем восстанавливает прежний." + }, + "settings.troubleshooting.capture.label": { + "message": "Сеанс записи" + }, + "settings.troubleshooting.capture.help": { + "message": "Переподключается и ожидает, чтобы вы могли воспроизвести проблему." + }, + "settings.troubleshooting.packets.label": { + "message": "Записывать сетевые пакеты" + }, + "settings.troubleshooting.packets.help": { + "message": "Сохраняет .pcap сетевого трафика во время сеанса записи." + }, + "settings.troubleshooting.duration.label": { + "message": "Длительность записи" + }, + "settings.troubleshooting.duration.help": { + "message": "Как долго длится сеанс записи." + }, + "settings.troubleshooting.duration.suffix": { + "message": "мин." + }, + "settings.troubleshooting.create": { + "message": "Создать отладочный пакет" + }, + "settings.troubleshooting.progress.description": { + "message": "Сбор журналов, сведений о системе и состояния подключения. Обычно это занимает несколько секунд — не закрывайте это окно до завершения." + }, + "settings.troubleshooting.cancelling": { + "message": "Отмена…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Отладочный пакет успешно загружен!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Пакет сохранён" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Поделитесь ключом загрузки ниже с поддержкой NetBird. Локальная копия также сохранена на вашем устройстве." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "Отладочный пакет сохранён локально." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Копировать ключ" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Открыть папку" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Открыть расположение файла" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Не удалось загрузить: {reason} Пакет всё равно сохранён локально." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Не удалось загрузить. Пакет всё равно сохранён локально." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Переподключение NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Сбор отладочных журналов" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Создание отладочного пакета…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Загрузка в NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Отмена…" + }, + "settings.about.client": { + "message": "Клиент NetBird v{version}" + }, + "settings.about.clientName": { + "message": "Клиент NetBird" + }, + "settings.about.development": { + "message": "[Разработка]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Все права защищены." + }, + "settings.about.links.imprint": { + "message": "Правовая информация" + }, + "settings.about.links.privacy": { + "message": "Конфиденциальность" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Условия использования" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Форум" + }, + "settings.about.community.documentation": { + "message": "Документация" + }, + "settings.about.community.feedback": { + "message": "Обратная связь" + }, + "update.banner.message": { + "message": "NetBird {version} готов к установке." + }, + "update.banner.later": { + "message": "Позже" + }, + "update.banner.installNow": { + "message": "Установить сейчас" + }, + "update.card.versionAvailableDownload": { + "message": "Версия {version} доступна для загрузки." + }, + "update.card.versionAvailableInstall": { + "message": "Версия {version} доступна для установки." + }, + "update.card.whatsNew": { + "message": "Что нового?" + }, + "update.card.installNow": { + "message": "Установить сейчас" + }, + "update.card.getInstaller": { + "message": "Загрузить" + }, + "update.card.autoCheckInterval": { + "message": "NetBird проверяет обновления в фоновом режиме." + }, + "update.card.changelog": { + "message": "Список изменений" + }, + "update.card.onLatestVersion": { + "message": "У вас установлена последняя версия" + }, + "update.header.tooltip": { + "message": "Доступно обновление" + }, + "update.overlay.updatingVersion": { + "message": "Обновление NetBird до v{version}" + }, + "update.overlay.updating": { + "message": "Обновление NetBird" + }, + "update.overlay.description": { + "message": "Доступна более новая версия, идёт её установка. NetBird автоматически перезапустится после завершения обновления." + }, + "update.overlay.error.timeoutTitle": { + "message": "Обновление занимает слишком много времени" + }, + "update.overlay.error.timeoutDescription": { + "message": "Установка {target} заняла слишком много времени и не завершилась." + }, + "update.overlay.error.canceledTitle": { + "message": "Обновление остановлено" + }, + "update.overlay.error.canceledDescription": { + "message": "Обновление до {target} было отменено до завершения." + }, + "update.overlay.error.failTitle": { + "message": "Не удалось установить обновление" + }, + "update.overlay.error.failDescription": { + "message": "Не удалось установить обновление до {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "неизвестная ошибка" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "новой версии" + }, + "update.error.loadStateTitle": { + "message": "Не удалось загрузить состояние обновления" + }, + "update.error.triggerTitle": { + "message": "Не удалось запустить обновление" + }, + "update.page.versionLine": { + "message": "Обновление клиента до версии {version}." + }, + "update.page.versionLineGeneric": { + "message": "Обновление клиента." + }, + "update.page.outdated": { + "message": "Версия вашего клиента старше версии автообновления, заданной на сервере управления." + }, + "update.page.status.running": { + "message": "Обновление" + }, + "update.page.status.timeout": { + "message": "Время ожидания обновления истекло. Повторите попытку." + }, + "update.page.status.canceled": { + "message": "Обновление отменено." + }, + "update.page.status.failed": { + "message": "Не удалось обновить: {message}" + }, + "update.page.status.unknownError": { + "message": "неизвестная ошибка обновления" + }, + "update.page.failedTitle": { + "message": "Не удалось обновить" + }, + "update.page.timeoutMessage": { + "message": "Время ожидания обновления истекло." + }, + "update.page.dontClose": { + "message": "Пожалуйста, не закрывайте это окно." + }, + "update.page.updating": { + "message": "Обновление…" + }, + "update.page.complete": { + "message": "Обновление завершено" + }, + "update.page.failed": { + "message": "Обновление не удалось" + }, + "window.title.settings": { + "message": "Настройки" + }, + "window.title.signIn": { + "message": "Вход" + }, + "window.title.sessionExpiration": { + "message": "Истечение сеанса" + }, + "window.title.updating": { + "message": "Обновление" + }, + "window.title.welcome": { + "message": "Добро пожаловать в NetBird" + }, + "window.title.error": { + "message": "Ошибка" + }, + "welcome.title": { + "message": "Найдите NetBird в системном трее" + }, + "welcome.titleMac": { + "message": "Найдите NetBird в строке меню" + }, + "welcome.description": { + "message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки." + }, + "welcome.descriptionMac": { + "message": "NetBird находится в строке меню. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки." + }, + "welcome.continue": { + "message": "Продолжить" + }, + "welcome.back": { + "message": "Назад" + }, + "welcome.management.title": { + "message": "Настройка NetBird" + }, + "welcome.management.description": { + "message": "Нажмите «Продолжить», чтобы начать, или выберите «Собственный сервер», если у вас есть свой сервер NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Используйте наш облачный сервис. Настройка не требуется." + }, + "welcome.management.selfHosted.title": { + "message": "Собственный сервер" + }, + "welcome.management.selfHosted.description": { + "message": "Подключитесь к собственному серверу управления." + }, + "welcome.management.urlLabel": { + "message": "URL сервера управления" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Введите корректный URL, например https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Не удалось связаться с сервером. Проверьте URL или сеть, затем продолжите, если уверены, что он правильный." + }, + "welcome.management.checking": { + "message": "Проверка…" + }, + "browserLogin.title": { + "message": "Завершите вход в браузере" + }, + "browserLogin.notSeeing": { + "message": "Мы открыли вкладку браузера, чтобы вы могли войти в аккаунт. Не видите вкладку?" + }, + "browserLogin.tryAgain": { + "message": "Повторить" + }, + "browserLogin.openFailedTitle": { + "message": "Не удалось открыть браузер" + }, + "sessionExpiration.title": { + "message": "Сеанс скоро истечёт" + }, + "sessionExpiration.titleLater": { + "message": "Ваш сеанс истечёт" + }, + "sessionExpiration.description": { + "message": "Это устройство скоро будет отключено. Продлите сеанс через вход в браузере." + }, + "sessionExpiration.descriptionLater": { + "message": "Вход в браузере сохранит подключение этого устройства к вашей сети." + }, + "sessionExpiration.stay": { + "message": "Продлить сеанс" + }, + "sessionExpiration.authenticate": { + "message": "Войти" + }, + "sessionExpiration.logout": { + "message": "Выйти" + }, + "sessionExpiration.expired": { + "message": "Сеанс истёк" + }, + "sessionExpiration.expiredDescription": { + "message": "Устройство отключено. Войдите через браузер, чтобы переподключиться." + }, + "sessionExpiration.close": { + "message": "Закрыть" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Не удалось продлить сеанс" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Не удалось выйти" + }, + "peers.search.placeholder": { + "message": "Поиск по имени или IP" + }, + "peers.filter.all": { + "message": "Все" + }, + "peers.filter.online": { + "message": "В сети" + }, + "peers.filter.offline": { + "message": "Не в сети" + }, + "peers.empty.title": { + "message": "Нет доступных пиров" + }, + "peers.empty.description": { + "message": "У вас нет доступных пиров или нет доступа ни к одному из них." + }, + "peers.details.domain": { + "message": "Домен" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "Открытый ключ" + }, + "peers.details.connection": { + "message": "Подключение" + }, + "peers.details.latency": { + "message": "Задержка" + }, + "peers.details.lastHandshake": { + "message": "Последнее рукопожатие" + }, + "peers.details.statusSince": { + "message": "Последнее обновление подключения" + }, + "peers.details.bytes": { + "message": "Байты" + }, + "peers.details.bytesSent": { + "message": "Отправлено" + }, + "peers.details.bytesReceived": { + "message": "Получено" + }, + "peers.details.localIce": { + "message": "Локальный ICE" + }, + "peers.details.remoteIce": { + "message": "Удалённый ICE" + }, + "peers.details.never": { + "message": "Никогда" + }, + "peers.details.justNow": { + "message": "Только что" + }, + "peers.details.refresh": { + "message": "Обновить" + }, + "peers.status.connected": { + "message": "Подключено" + }, + "peers.status.connecting": { + "message": "Подключение" + }, + "peers.status.disconnected": { + "message": "Отключено" + }, + "peers.details.relayAddress": { + "message": "Ретранслятор" + }, + "peers.details.networks": { + "message": "Ресурсы" + }, + "peers.details.relayed": { + "message": "Через ретранслятор" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass включён" + }, + "networks.search.placeholder": { + "message": "Поиск по сети или домену" + }, + "networks.filter.all": { + "message": "Все" + }, + "networks.filter.active": { + "message": "Активные" + }, + "networks.filter.overlapping": { + "message": "Пересекающиеся" + }, + "networks.empty.title": { + "message": "Нет доступных ресурсов" + }, + "networks.empty.description": { + "message": "У вас нет доступных сетевых ресурсов или нет доступа ни к одному из них." + }, + "networks.selected": { + "message": "Выбрано" + }, + "networks.unselected": { + "message": "Не выбрано" + }, + "networks.ips.heading": { + "message": "Распознанные IP-адреса" + }, + "networks.bulk.selectionCount": { + "message": "{selected} из {total} активно" + }, + "networks.bulk.enableAll": { + "message": "Включить все" + }, + "networks.bulk.disableAll": { + "message": "Отключить все" + }, + "exitNodes.search.placeholder": { + "message": "Поиск выходных узлов" + }, + "exitNodes.none": { + "message": "Нет" + }, + "exitNodes.empty.title": { + "message": "Нет доступных выходных узлов" + }, + "exitNodes.empty.description": { + "message": "Этому пиру не предоставлены выходные узлы." + }, + "exitNodes.card.title": { + "message": "Выходной узел" + }, + "exitNodes.card.statusActive": { + "message": "Активен" + }, + "exitNodes.card.statusInactive": { + "message": "Неактивен" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Нет" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Прямое подключение без выходного узла" + }, + "quickActions.connect": { + "message": "Подключиться" + }, + "quickActions.disconnect": { + "message": "Отключиться" + }, + "daemon.unavailable.title": { + "message": "Служба NetBird не запущена" + }, + "daemon.unavailable.description": { + "message": "Приложение автоматически переподключится, как только служба будет запущена." + }, + "daemon.unavailable.docsLink": { + "message": "Документация" + }, + "daemon.outdated.title": { + "message": "Клиент NetBird устарел" + }, + "daemon.outdated.description": { + "message": "Новый GUI несовместим с вашим более старым клиентом. Обновите клиент, чтобы использовать новое приложение." + }, + "daemon.outdated.download": { + "message": "Скачать последнюю версию" + }, + "error.jwt_clock_skew": { + "message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку." + }, + "error.jwt_expired": { + "message": "Срок действия токена входа истёк. Войдите снова." + }, + "error.jwt_signature_invalid": { + "message": "Не удалось войти: недействительная подпись токена. Обратитесь к администратору." + }, + "error.session_expired": { + "message": "Ваш сеанс истёк. Войдите снова." + }, + "error.invalid_setup_key": { + "message": "Ключ установки отсутствует или недействителен." + }, + "error.permission_denied": { + "message": "Сервер отклонил вход." + }, + "error.daemon_unreachable": { + "message": "Демон NetBird не отвечает. Проверьте, запущена ли служба." + }, + "error.unknown": { + "message": "Не удалось выполнить операцию." + }, + "settings.ssh.privilege.hint": { + "message": "Требуются {actor}. Выполните вместо этого:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Отключить можно, но чтобы включить снова, нужны {actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Включить можно, но чтобы отключить снова, нужны {actor}:" + } +} diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json new file mode 100644 index 000000000..90ae5e003 --- /dev/null +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -0,0 +1,1363 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "已断开连接" + }, + "tray.status.daemonUnavailable": { + "message": "未运行" + }, + "tray.status.error": { + "message": "错误" + }, + "tray.status.connected": { + "message": "已连接" + }, + "tray.status.connecting": { + "message": "正在连接" + }, + "tray.status.needsLogin": { + "message": "需要登录" + }, + "tray.status.loginFailed": { + "message": "登录失败" + }, + "tray.status.sessionExpired": { + "message": "会话已过期" + }, + "tray.session.expiresIn": { + "message": "会话将在 {remaining} 后过期" + }, + "tray.session.unit.lessThanMinute": { + "message": "不到一分钟" + }, + "tray.session.unit.minute": { + "message": "1 分钟" + }, + "tray.session.unit.minutes": { + "message": "{count} 分钟" + }, + "tray.session.unit.hour": { + "message": "1 小时" + }, + "tray.session.unit.hours": { + "message": "{count} 小时" + }, + "tray.session.unit.day": { + "message": "1 天" + }, + "tray.session.unit.days": { + "message": "{count} 天" + }, + "tray.menu.open": { + "message": "打开 NetBird" + }, + "tray.menu.connect": { + "message": "连接" + }, + "tray.menu.disconnect": { + "message": "断开连接" + }, + "tray.menu.exitNode": { + "message": "出口节点" + }, + "tray.menu.networks": { + "message": "资源" + }, + "tray.menu.profiles": { + "message": "配置文件" + }, + "tray.menu.manageProfiles": { + "message": "管理配置文件" + }, + "tray.menu.settings": { + "message": "设置…" + }, + "tray.menu.debugBundle": { + "message": "创建调试包" + }, + "tray.menu.about": { + "message": "帮助与支持" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "文档" + }, + "tray.menu.troubleshoot": { + "message": "故障排除" + }, + "tray.menu.downloadLatest": { + "message": "下载最新版本" + }, + "tray.menu.installVersion": { + "message": "安装 {version} 版本" + }, + "tray.menu.guiVersion": { + "message": "GUI:{version}" + }, + "tray.menu.daemonVersion": { + "message": "守护进程:{version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "退出 NetBird" + }, + "notify.daemonOutdated.title": { + "message": "NetBird 服务版本过旧" + }, + "notify.daemonOutdated.body": { + "message": "请更新 NetBird 服务以使用此应用。" + }, + "notify.update.title": { + "message": "NetBird 有可用更新" + }, + "notify.update.body": { + "message": "NetBird {version} 已可用。" + }, + "notify.update.enforcedSuffix": { + "message": " 您的管理员要求进行此次更新。" + }, + "notify.error.title": { + "message": "错误" + }, + "notify.error.connect": { + "message": "连接失败" + }, + "notify.error.disconnect": { + "message": "断开连接失败" + }, + "notify.error.switchProfile": { + "message": "切换到 {profile} 失败" + }, + "notify.error.exitNode": { + "message": "更新出口节点 {name} 失败" + }, + "notify.sessionExpired.title": { + "message": "NetBird 会话已过期" + }, + "notify.sessionExpired.body": { + "message": "您的 NetBird 会话已过期。请重新登录。" + }, + "notify.sessionWarning.title": { + "message": "会话即将过期" + }, + "notify.sessionWarning.body": { + "message": "您的 NetBird 会话将在 {remaining} 后过期。点击“立即延长”以续期。" + }, + "notify.sessionWarning.bodyGeneric": { + "message": "您的 NetBird 会话即将过期。点击“立即延长”以续期。" + }, + "notify.sessionWarning.extend": { + "message": "立即延长" + }, + "notify.sessionWarning.dismiss": { + "message": "忽略" + }, + "notify.sessionWarning.failed": { + "message": "延长 NetBird 会话失败" + }, + "notify.sessionWarning.successTitle": { + "message": "NetBird 会话已延长" + }, + "notify.sessionWarning.successBody": { + "message": "您的会话已刷新。" + }, + "notify.sessionDeadlineRejected.title": { + "message": "会话截止时间被拒绝" + }, + "notify.sessionDeadlineRejected.body": { + "message": "服务器发送了无效的会话截止时间。请重新登录。" + }, + "notify.mdm.policyApplied.title": { + "message": "NetBird 设置已更新" + }, + "notify.mdm.policyApplied.body": { + "message": "您的 NetBird 配置已根据 IT 策略更新。" + }, + "common.cancel": { + "message": "取消" + }, + "common.save": { + "message": "保存" + }, + "common.saveChanges": { + "message": "保存更改" + }, + "common.saving": { + "message": "正在保存…" + }, + "common.close": { + "message": "关闭" + }, + "common.copy": { + "message": "复制" + }, + "common.togglePasswordVisibility": { + "message": "切换密码可见性" + }, + "common.increase": { + "message": "增加" + }, + "common.decrease": { + "message": "减少" + }, + "common.delete": { + "message": "删除" + }, + "common.create": { + "message": "创建" + }, + "common.add": { + "message": "添加" + }, + "common.remove": { + "message": "移除" + }, + "common.refresh": { + "message": "刷新" + }, + "common.loading": { + "message": "正在加载…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "未找到任何结果" + }, + "common.noResults.description": { + "message": "我们未能找到任何结果。请尝试其他搜索词或更改筛选条件。" + }, + "notConnected.title": { + "message": "已断开连接" + }, + "notConnected.description": { + "message": "请先连接到 NetBird,以查看有关对等节点、网络资源和出口节点的详细信息。" + }, + "connect.status.disconnected": { + "message": "已断开连接" + }, + "connect.status.connecting": { + "message": "正在连接…" + }, + "connect.status.connected": { + "message": "已连接" + }, + "connect.status.disconnecting": { + "message": "正在断开连接…" + }, + "connect.status.daemonUnavailable": { + "message": "守护进程不可用" + }, + "connect.status.loginRequired": { + "message": "需要登录" + }, + "connect.error.loginTitle": { + "message": "登录失败" + }, + "connect.error.connectTitle": { + "message": "连接失败" + }, + "connect.error.disconnectTitle": { + "message": "断开连接失败" + }, + "nav.peers.title": { + "message": "对等节点" + }, + "nav.peers.description": { + "message": "{total} 个中已连接 {connected} 个" + }, + "nav.resources.title": { + "message": "资源" + }, + "nav.resources.description": { + "message": "{total} 个中已激活 {active} 个" + }, + "nav.exitNode.title": { + "message": "出口节点" + }, + "nav.exitNode.none": { + "message": "未激活" + }, + "nav.exitNode.using": { + "message": "经由 {name}" + }, + "header.openSettings": { + "message": "打开设置" + }, + "header.togglePanel": { + "message": "切换侧边栏" + }, + "profile.selector.loading": { + "message": "正在加载…" + }, + "profile.selector.noProfile": { + "message": "无配置文件" + }, + "profile.selector.searchPlaceholder": { + "message": "按名称搜索配置文件…" + }, + "profile.selector.emptyTitle": { + "message": "未找到配置文件" + }, + "profile.selector.emptyDescription": { + "message": "请尝试其他搜索词或创建新的配置文件。" + }, + "profile.selector.newProfile": { + "message": "新建配置文件" + }, + "profile.selector.moreOptions": { + "message": "更多选项" + }, + "profile.selector.deregister": { + "message": "注销" + }, + "profile.selector.delete": { + "message": "删除" + }, + "profile.selector.switchTo": { + "message": "切换到此配置文件" + }, + "profile.selector.edit": { + "message": "编辑" + }, + "profile.edit.title": { + "message": "编辑配置文件" + }, + "profile.edit.submit": { + "message": "保存更改" + }, + "profile.dialog.title": { + "message": "输入配置文件名称" + }, + "profile.dialog.nameLabel": { + "message": "配置文件名称" + }, + "profile.dialog.description": { + "message": "为您的配置文件设置一个易于识别的名称。" + }, + "profile.dialog.placeholder": { + "message": "例如:工作" + }, + "profile.dialog.submit": { + "message": "添加配置文件" + }, + "profile.dialog.required": { + "message": "请输入配置文件名称,例如:工作、家庭" + }, + "profile.dialog.managementHelp": { + "message": "使用 NetBird Cloud 或您自己的服务器。" + }, + "profile.dialog.urlUnreachable": { + "message": "无法连接到此服务器。请检查 URL,如果确认无误,也可以照常添加该配置文件。" + }, + "header.menu.settings": { + "message": "设置…" + }, + "header.menu.defaultView": { + "message": "默认视图" + }, + "header.menu.advancedView": { + "message": "高级视图" + }, + "header.menu.updateAvailable": { + "message": "有可用更新" + }, + "header.menu.open": { + "message": "打开菜单" + }, + "header.profile.switch": { + "message": "切换配置文件" + }, + "connect.toggle.label": { + "message": "切换 NetBird 连接" + }, + "connect.localIp.label": { + "message": "本地 IP 地址" + }, + "common.search": { + "message": "搜索" + }, + "common.filter": { + "message": "筛选" + }, + "exitNodes.dropdown.trigger": { + "message": "选择出口节点" + }, + "peers.row.label": { + "message": "打开 {name} 的详情,{status}" + }, + "peers.dialog.title": { + "message": "对等节点详情" + }, + "networks.row.toggle": { + "message": "切换 {name}" + }, + "networks.bulk.label": { + "message": "切换所有可见资源" + }, + "settings.nav.label": { + "message": "设置部分" + }, + "profile.switch.title": { + "message": "切换到配置文件“{name}”?" + }, + "profile.switch.message": { + "message": "您确定要切换配置文件吗?\n您当前的配置文件将被断开连接。" + }, + "profile.switch.confirm": { + "message": "确认" + }, + "profile.deregister.title": { + "message": "注销配置文件“{name}”?" + }, + "profile.deregister.message": { + "message": "您确定要注销此配置文件吗?\n您将需要重新登录才能使用它。" + }, + "profile.deregister.confirm": { + "message": "注销" + }, + "profile.delete.title": { + "message": "删除配置文件“{name}”?" + }, + "profile.delete.message": { + "message": "您确定要删除此配置文件吗?\n此操作无法撤销。" + }, + "profile.delete.disabledActive": { + "message": "无法删除处于活动状态的配置文件。请先切换到其他配置文件,再删除此配置文件。" + }, + "profile.delete.disabledDefault": { + "message": "无法删除默认配置文件。" + }, + "profile.error.switchTitle": { + "message": "切换配置文件失败" + }, + "profile.error.deregisterTitle": { + "message": "注销配置文件失败" + }, + "profile.error.deleteTitle": { + "message": "删除配置文件失败" + }, + "profile.error.createTitle": { + "message": "创建配置文件失败" + }, + "profile.error.editTitle": { + "message": "编辑配置文件失败" + }, + "profile.error.loadTitle": { + "message": "加载配置文件失败" + }, + "profile.dropdown.activeProfile": { + "message": "当前配置文件" + }, + "profile.dropdown.switchProfile": { + "message": "切换配置文件" + }, + "profile.dropdown.noEmail": { + "message": "其他" + }, + "profile.dropdown.addProfile": { + "message": "添加配置文件" + }, + "profile.dropdown.manageProfiles": { + "message": "管理配置文件" + }, + "profile.dropdown.settings": { + "message": "设置" + }, + "settings.profiles.section.profiles": { + "message": "配置文件" + }, + "settings.profiles.intro": { + "message": "并行保留多个独立的 NetBird 身份,例如工作和个人账户,或不同的管理服务器。可在下方添加、注销或删除配置文件。" + }, + "settings.profiles.addProfile": { + "message": "添加配置文件" + }, + "settings.profiles.active": { + "message": "活动" + }, + "settings.profiles.emptyTitle": { + "message": "无配置文件" + }, + "settings.profiles.emptyDescription": { + "message": "创建一个配置文件以连接到 NetBird 管理服务器。" + }, + "settings.error.loadTitle": { + "message": "加载设置失败" + }, + "settings.error.saveTitle": { + "message": "保存设置失败" + }, + "settings.error.debugBundleTitle": { + "message": "创建调试包失败" + }, + "settings.tabs.general": { + "message": "常规" + }, + "settings.tabs.network": { + "message": "网络" + }, + "settings.tabs.security": { + "message": "安全" + }, + "settings.tabs.profiles": { + "message": "配置文件" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "高级" + }, + "settings.tabs.troubleshooting": { + "message": "故障排除" + }, + "settings.tabs.about": { + "message": "关于" + }, + "settings.tabs.updateAvailable": { + "message": "有可用更新" + }, + "settings.general.section.general": { + "message": "常规" + }, + "settings.general.section.connection": { + "message": "连接" + }, + "settings.general.connectOnStartup.label": { + "message": "启动时连接" + }, + "settings.general.connectOnStartup.help": { + "message": "在服务启动时自动建立连接。" + }, + "settings.general.notifications.label": { + "message": "桌面通知" + }, + "settings.general.notifications.help": { + "message": "显示有关新更新和连接事件的桌面通知。" + }, + "settings.general.autostart.label": { + "message": "登录时启动 NetBird 界面" + }, + "settings.general.autostart.help": { + "message": "在您登录时自动启动 NetBird 界面。此设置仅影响图形界面,不影响后台服务。" + }, + "settings.general.autostart.errorTitle": { + "message": "更改自启动设置失败" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "退出后保持连接", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, + "settings.general.language.label": { + "message": "显示语言" + }, + "settings.general.language.help": { + "message": "选择 NetBird 界面的语言。" + }, + "settings.general.language.search": { + "message": "搜索语言…" + }, + "settings.general.language.empty": { + "message": "没有匹配的语言。" + }, + "settings.general.management.label": { + "message": "管理服务器" + }, + "settings.general.management.help": { + "message": "连接到 NetBird Cloud 或您自己的自托管管理服务器。更改将使客户端重新连接。" + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "自托管" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "请输入有效的 URL,例如:https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "无法连接到此服务器。请检查 URL,如果确认无误,也可以照常保存。" + }, + "settings.general.management.switchCloudTitle": { + "message": "切换到 NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "这将断开您的自托管服务器。\n您可能需要重新登录。" + }, + "settings.general.management.switchCloudConfirm": { + "message": "切换到 Cloud" + }, + "settings.network.section.connectivity": { + "message": "连接性" + }, + "settings.network.section.routingDns": { + "message": "路由与 DNS" + }, + "settings.network.monitor.label": { + "message": "网络变化时重新连接" + }, + "settings.network.monitor.help": { + "message": "监测网络,并在发生变化时自动重新连接,例如切换 Wi-Fi、以太网变化或从睡眠中恢复。" + }, + "settings.network.dns.label": { + "message": "启用 DNS" + }, + "settings.network.dns.help": { + "message": "将 NetBird 管理的 DNS 设置应用到主机解析器。" + }, + "settings.network.clientRoutes.label": { + "message": "启用客户端路由" + }, + "settings.network.clientRoutes.help": { + "message": "接受来自其他对等节点的路由,以访问它们的网络。" + }, + "settings.network.serverRoutes.label": { + "message": "启用服务器路由" + }, + "settings.network.serverRoutes.help": { + "message": "向其他对等节点通告此主机的本地路由。" + }, + "settings.network.ipv6.label": { + "message": "启用 IPv6" + }, + "settings.network.ipv6.help": { + "message": "为 NetBird 叠加网络使用 IPv6 寻址。" + }, + "settings.security.section.firewall": { + "message": "防火墙" + }, + "settings.security.section.encryption": { + "message": "加密" + }, + "settings.security.blockInbound.label": { + "message": "阻止入站流量" + }, + "settings.security.blockInbound.help": { + "message": "拒绝对等节点向本设备及其路由的任何网络发起的未经请求的连接。出站流量不受影响。" + }, + "settings.security.blockLan.label": { + "message": "阻止 LAN 访问" + }, + "settings.security.blockLan.help": { + "message": "当本设备为对等节点路由流量时,阻止它们访问您的本地网络或其设备。" + }, + "settings.security.rosenpass.label": { + "message": "启用抗量子加密" + }, + "settings.security.rosenpass.help": { + "message": "在 WireGuard® 之上通过 Rosenpass 添加后量子密钥交换。" + }, + "settings.security.rosenpassPermissive.label": { + "message": "启用宽松模式" + }, + "settings.security.rosenpassPermissive.help": { + "message": "允许连接到不支持抗量子加密的对等节点。" + }, + "settings.ssh.section.server": { + "message": "服务器" + }, + "settings.ssh.section.capabilities": { + "message": "功能" + }, + "settings.ssh.section.authentication": { + "message": "身份验证" + }, + "settings.ssh.server.label": { + "message": "启用 SSH 服务器" + }, + "settings.ssh.server.help": { + "message": "在此主机上运行 NetBird SSH 服务器,以便其他对等节点可以连接到它。" + }, + "settings.ssh.root.label": { + "message": "允许 root 登录" + }, + "settings.ssh.root.help": { + "message": "允许对等节点以 root 用户身份登录。禁用后将要求使用非特权账户。" + }, + "settings.ssh.sftp.label": { + "message": "允许 SFTP" + }, + "settings.ssh.sftp.help": { + "message": "使用原生 SFTP 或 SCP 客户端安全地传输文件。" + }, + "settings.ssh.localForward.label": { + "message": "本地端口转发" + }, + "settings.ssh.localForward.help": { + "message": "允许连接的对等节点将本地端口隧道转发到此主机可访问的服务。" + }, + "settings.ssh.remoteForward.label": { + "message": "远程端口转发" + }, + "settings.ssh.remoteForward.help": { + "message": "允许连接的对等节点将此主机上的端口反向暴露到它们自己的机器。" + }, + "settings.ssh.jwt.label": { + "message": "启用 JWT 身份验证" + }, + "settings.ssh.jwt.help": { + "message": "针对您的 IdP 验证每个 SSH 会话,以确认用户身份并进行审计。禁用后将仅依赖网络 ACL 策略,在没有可用 IdP 时很有用。" + }, + "settings.ssh.jwtTtl.label": { + "message": "JWT 缓存 TTL" + }, + "settings.ssh.jwtTtl.help": { + "message": "在出站 SSH 连接再次提示前,此客户端缓存 JWT 的时长。设为 0 可禁用缓存,每次连接都进行身份验证。" + }, + "settings.ssh.jwtTtl.suffix": { + "message": "秒" + }, + "settings.advanced.section.interface": { + "message": "接口" + }, + "settings.advanced.section.security": { + "message": "安全" + }, + "settings.advanced.interfaceName.label": { + "message": "名称" + }, + "settings.advanced.interfaceName.error": { + "message": "请使用 1-15 个字母、数字、点、连字符或下划线。" + }, + "settings.advanced.interfaceName.errorMac": { + "message": "必须以“utun”开头,后跟一个数字(例如 utun100)。" + }, + "settings.advanced.port.label": { + "message": "端口" + }, + "settings.advanced.port.error": { + "message": "请输入介于 {min} 和 {max} 之间的端口。" + }, + "settings.advanced.port.help": { + "message": "如果设为 0,将使用一个随机的空闲端口。" + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "请输入介于 {min} 和 {max} 之间的 MTU 值。" + }, + "settings.advanced.psk.label": { + "message": "预共享密钥" + }, + "settings.advanced.psk.help": { + "message": "可选的 WireGuard PSK,用于额外的对称加密。它与 NetBird 设置密钥不同。您将只能与使用相同预共享密钥的对等节点通信。" + }, + "settings.troubleshooting.section.title": { + "message": "调试包" + }, + "settings.troubleshooting.anonymize.label": { + "message": "匿名化敏感信息" + }, + "settings.troubleshooting.anonymize.help": { + "message": "隐藏 IP 地址、域名和其他敏感值。" + }, + "settings.troubleshooting.anonymize.info": { + "message": "默认级别保留内部 IPv4 地址和对等节点名称,便于支持人员阅读。严格级别还会匿名化私有 (RFC 1918)、CGNAT 和链路本地 IP 地址、对等节点名称以及 WireGuard 公钥。相同的值会映射到相同的占位符,因此对等节点仍可区分。向组织外部分享调试包时请使用严格级别。" + }, + "settings.troubleshooting.anonymize.none": { + "message": "无" + }, + "settings.troubleshooting.anonymize.default": { + "message": "默认" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "严格" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "包含系统信息" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "包含操作系统、内核、网络接口和路由表。" + }, + "settings.troubleshooting.upload.label": { + "message": "将调试包上传到 NetBird 服务器" + }, + "settings.troubleshooting.upload.help": { + "message": "返回一个上传密钥,供您分享给 NetBird 支持团队。" + }, + "settings.troubleshooting.trace.label": { + "message": "启用跟踪日志" + }, + "settings.troubleshooting.trace.help": { + "message": "将日志级别提升到 TRACE,之后再恢复原级别。" + }, + "settings.troubleshooting.capture.label": { + "message": "捕获会话" + }, + "settings.troubleshooting.capture.help": { + "message": "重新连接并等待,以便您复现问题。" + }, + "settings.troubleshooting.packets.label": { + "message": "捕获网络数据包" + }, + "settings.troubleshooting.packets.help": { + "message": "在捕获期间将网络流量保存为 .pcap 文件。" + }, + "settings.troubleshooting.duration.label": { + "message": "捕获时长" + }, + "settings.troubleshooting.duration.help": { + "message": "捕获会话运行的时长。" + }, + "settings.troubleshooting.duration.suffix": { + "message": "分钟" + }, + "settings.troubleshooting.create": { + "message": "创建调试包" + }, + "settings.troubleshooting.progress.description": { + "message": "正在收集日志、系统详情和连接状态。这通常只需片刻——请保持此窗口打开,直到完成。" + }, + "settings.troubleshooting.cancelling": { + "message": "正在取消…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "调试包已成功上传!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "调试包已保存" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "请将下方的上传密钥分享给 NetBird 支持团队。本地也已保存了一份副本。" + }, + "settings.troubleshooting.done.savedDescription": { + "message": "您的调试包已保存在本地。" + }, + "settings.troubleshooting.done.copyKey": { + "message": "复制密钥" + }, + "settings.troubleshooting.done.openFolder": { + "message": "打开文件夹" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "打开文件位置" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "上传失败:{reason} 调试包仍已保存在本地。" + }, + "settings.troubleshooting.uploadFailed": { + "message": "上传失败。调试包仍已保存在本地。" + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "正在重新连接 NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "正在捕获调试日志" + }, + "settings.troubleshooting.stage.bundling": { + "message": "正在生成调试包…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "正在上传到 NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "正在取消…" + }, + "settings.about.client": { + "message": "NetBird 客户端 v{version}" + }, + "settings.about.clientName": { + "message": "NetBird 客户端" + }, + "settings.about.development": { + "message": "[开发版]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird。保留所有权利。" + }, + "settings.about.links.imprint": { + "message": "法律声明" + }, + "settings.about.links.privacy": { + "message": "隐私" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "服务条款" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "论坛" + }, + "settings.about.community.documentation": { + "message": "文档" + }, + "settings.about.community.feedback": { + "message": "反馈" + }, + "update.banner.message": { + "message": "NetBird {version} 已准备好安装。" + }, + "update.banner.later": { + "message": "稍后" + }, + "update.banner.installNow": { + "message": "立即安装" + }, + "update.card.versionAvailableDownload": { + "message": "{version} 版本可供下载。" + }, + "update.card.versionAvailableInstall": { + "message": "{version} 版本可供安装。" + }, + "update.card.whatsNew": { + "message": "更新内容?" + }, + "update.card.installNow": { + "message": "立即安装" + }, + "update.card.getInstaller": { + "message": "下载" + }, + "update.card.autoCheckInterval": { + "message": "NetBird 会在后台检查更新。" + }, + "update.card.changelog": { + "message": "更新日志" + }, + "update.card.onLatestVersion": { + "message": "您已是最新版本" + }, + "update.header.tooltip": { + "message": "有可用更新" + }, + "update.overlay.updatingVersion": { + "message": "正在将 NetBird 更新到 v{version}" + }, + "update.overlay.updating": { + "message": "正在更新 NetBird" + }, + "update.overlay.description": { + "message": "有更新的版本可用,正在安装。更新完成后,NetBird 将自动重启。" + }, + "update.overlay.error.timeoutTitle": { + "message": "更新耗时过长" + }, + "update.overlay.error.timeoutDescription": { + "message": "安装 {target} 耗时过长,未能完成。" + }, + "update.overlay.error.canceledTitle": { + "message": "更新已停止" + }, + "update.overlay.error.canceledDescription": { + "message": "对 {target} 的更新在完成前已被取消。" + }, + "update.overlay.error.failTitle": { + "message": "无法安装更新" + }, + "update.overlay.error.failDescription": { + "message": "无法安装 {target}。" + }, + "update.overlay.error.unknownMessage": { + "message": "未知错误" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "新版本" + }, + "update.error.loadStateTitle": { + "message": "加载更新状态失败" + }, + "update.error.triggerTitle": { + "message": "启动更新失败" + }, + "update.page.versionLine": { + "message": "正在将客户端更新到:{version}。" + }, + "update.page.versionLineGeneric": { + "message": "正在更新客户端。" + }, + "update.page.outdated": { + "message": "您的客户端版本早于管理服务器中设置的自动更新版本。" + }, + "update.page.status.running": { + "message": "正在更新" + }, + "update.page.status.timeout": { + "message": "更新超时。请重试。" + }, + "update.page.status.canceled": { + "message": "更新已取消。" + }, + "update.page.status.failed": { + "message": "更新失败:{message}" + }, + "update.page.status.unknownError": { + "message": "未知的更新错误" + }, + "update.page.failedTitle": { + "message": "更新失败" + }, + "update.page.timeoutMessage": { + "message": "更新超时。" + }, + "update.page.dontClose": { + "message": "请勿关闭此窗口。" + }, + "update.page.updating": { + "message": "正在更新…" + }, + "update.page.complete": { + "message": "更新完成" + }, + "update.page.failed": { + "message": "更新失败" + }, + "window.title.settings": { + "message": "设置" + }, + "window.title.signIn": { + "message": "登录" + }, + "window.title.sessionExpiration": { + "message": "会话即将过期" + }, + "window.title.updating": { + "message": "正在更新" + }, + "window.title.welcome": { + "message": "欢迎使用 NetBird" + }, + "window.title.error": { + "message": "错误" + }, + "welcome.title": { + "message": "在托盘中查找 NetBird" + }, + "welcome.titleMac": { + "message": "在菜单栏中查找 NetBird" + }, + "welcome.description": { + "message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。" + }, + "welcome.descriptionMac": { + "message": "NetBird 驻留在您的菜单栏中。点击图标即可连接、切换配置文件或打开设置。" + }, + "welcome.continue": { + "message": "继续" + }, + "welcome.back": { + "message": "返回" + }, + "welcome.management.title": { + "message": "设置 NetBird" + }, + "welcome.management.description": { + "message": "点击“继续”即可开始;如果您有自己的 NetBird 服务器,请选择“自托管”。" + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "使用我们的托管服务。无需任何设置。" + }, + "welcome.management.selfHosted.title": { + "message": "自托管" + }, + "welcome.management.selfHosted.description": { + "message": "连接到您自己的管理服务器。" + }, + "welcome.management.urlLabel": { + "message": "管理服务器 URL" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "请输入有效的 URL,例如:https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "无法连接到此服务器。请检查 URL 或您的网络,如果确认无误,再继续。" + }, + "welcome.management.checking": { + "message": "正在检查…" + }, + "browserLogin.title": { + "message": "请在浏览器中继续以完成登录" + }, + "browserLogin.notSeeing": { + "message": "没看到浏览器标签页?" + }, + "browserLogin.tryAgain": { + "message": "重试" + }, + "browserLogin.openFailedTitle": { + "message": "打开浏览器失败" + }, + "sessionExpiration.title": { + "message": "会话即将过期" + }, + "sessionExpiration.titleLater": { + "message": "您的会话即将过期" + }, + "sessionExpiration.description": { + "message": "此设备即将断开连接。通过浏览器登录进行续期。" + }, + "sessionExpiration.descriptionLater": { + "message": "通过浏览器登录可让此设备保持连接到您的网络。" + }, + "sessionExpiration.stay": { + "message": "续期会话" + }, + "sessionExpiration.authenticate": { + "message": "进行身份验证" + }, + "sessionExpiration.logout": { + "message": "退出登录" + }, + "sessionExpiration.expired": { + "message": "会话已过期" + }, + "sessionExpiration.expiredDescription": { + "message": "设备已断开连接。通过浏览器登录进行身份验证以重新连接。" + }, + "sessionExpiration.close": { + "message": "关闭" + }, + "sessionExpiration.extendFailedTitle": { + "message": "延长会话失败" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "退出登录失败" + }, + "peers.search.placeholder": { + "message": "按名称或 IP 搜索" + }, + "peers.filter.all": { + "message": "全部" + }, + "peers.filter.online": { + "message": "在线" + }, + "peers.filter.offline": { + "message": "离线" + }, + "peers.empty.title": { + "message": "无可用的对等节点" + }, + "peers.empty.description": { + "message": "您可能没有任何可用的对等节点,或者无权访问其中任何一个。" + }, + "peers.details.domain": { + "message": "域名" + }, + "peers.details.netbirdIp": { + "message": "NetBird IP" + }, + "peers.details.netbirdIpv6": { + "message": "NetBird IPv6" + }, + "peers.details.publicKey": { + "message": "公钥" + }, + "peers.details.connection": { + "message": "连接" + }, + "peers.details.latency": { + "message": "延迟" + }, + "peers.details.lastHandshake": { + "message": "上次握手" + }, + "peers.details.statusSince": { + "message": "上次连接更新" + }, + "peers.details.bytes": { + "message": "字节" + }, + "peers.details.bytesSent": { + "message": "已发送" + }, + "peers.details.bytesReceived": { + "message": "已接收" + }, + "peers.details.localIce": { + "message": "本地 ICE" + }, + "peers.details.remoteIce": { + "message": "远程 ICE" + }, + "peers.details.never": { + "message": "从不" + }, + "peers.details.justNow": { + "message": "刚刚" + }, + "peers.details.refresh": { + "message": "刷新" + }, + "peers.status.connected": { + "message": "已连接" + }, + "peers.status.connecting": { + "message": "正在连接" + }, + "peers.status.disconnected": { + "message": "已断开连接" + }, + "peers.details.relayAddress": { + "message": "中继" + }, + "peers.details.networks": { + "message": "资源" + }, + "peers.details.relayed": { + "message": "经中继" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "已启用 Rosenpass" + }, + "networks.search.placeholder": { + "message": "按网络或域名搜索" + }, + "networks.filter.all": { + "message": "全部" + }, + "networks.filter.active": { + "message": "活动" + }, + "networks.filter.overlapping": { + "message": "重叠" + }, + "networks.empty.title": { + "message": "无可用资源" + }, + "networks.empty.description": { + "message": "您可能没有任何可用的网络资源,或者无权访问其中任何一个。" + }, + "networks.selected": { + "message": "已选择" + }, + "networks.unselected": { + "message": "未选择" + }, + "networks.ips.heading": { + "message": "已解析的 IP" + }, + "networks.bulk.selectionCount": { + "message": "{total} 个中已激活 {selected} 个" + }, + "networks.bulk.enableAll": { + "message": "全部启用" + }, + "networks.bulk.disableAll": { + "message": "全部禁用" + }, + "exitNodes.search.placeholder": { + "message": "搜索出口节点" + }, + "exitNodes.none": { + "message": "无" + }, + "exitNodes.empty.title": { + "message": "无可用的出口节点" + }, + "exitNodes.empty.description": { + "message": "尚未向此对等节点共享任何出口节点。" + }, + "exitNodes.card.title": { + "message": "出口节点" + }, + "exitNodes.card.statusActive": { + "message": "活动" + }, + "exitNodes.card.statusInactive": { + "message": "非活动" + }, + "exitNodes.dropdown.noneTitle": { + "message": "无" + }, + "exitNodes.dropdown.noneDescription": { + "message": "不使用出口节点的直接连接" + }, + "quickActions.connect": { + "message": "连接" + }, + "quickActions.disconnect": { + "message": "断开连接" + }, + "daemon.unavailable.title": { + "message": "NetBird 服务未运行" + }, + "daemon.unavailable.description": { + "message": "服务运行后,应用将自动重新连接。" + }, + "daemon.unavailable.docsLink": { + "message": "文档" + }, + "daemon.outdated.title": { + "message": "NetBird 客户端版本过旧" + }, + "daemon.outdated.description": { + "message": "新版 GUI 与您较旧的客户端不兼容。请更新客户端以使用新应用。" + }, + "daemon.outdated.download": { + "message": "下载最新版本" + }, + "error.jwt_clock_skew": { + "message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。" + }, + "error.jwt_expired": { + "message": "您的登录令牌已过期。请重新登录。" + }, + "error.jwt_signature_invalid": { + "message": "登录失败:令牌签名无效。请联系您的管理员。" + }, + "error.session_expired": { + "message": "您的会话已过期。请重新登录。" + }, + "error.invalid_setup_key": { + "message": "设置密钥缺失或无效。" + }, + "error.permission_denied": { + "message": "登录被服务器拒绝。" + }, + "error.daemon_unreachable": { + "message": "NetBird 守护进程无响应。请检查服务是否正在运行。" + }, + "error.unknown": { + "message": "操作失败。" + }, + "settings.ssh.privilege.hint": { + "message": "需要{actor}。请改为运行:" + }, + "settings.ssh.privilege.oneWay": { + "message": "您可以关闭此项,但重新开启需要{actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "您可以开启此项,但再次关闭需要{actor}:" + } +} diff --git a/client/ui/icons.go b/client/ui/icons.go index 874f24fdd..5ab19ca05 100644 --- a/client/ui/icons.go +++ b/client/ui/icons.go @@ -1,16 +1,10 @@ -//go:build !(linux && 386) && !windows +//go:build !android && !ios && !freebsd && !js package main -import ( - _ "embed" -) +import _ "embed" -//go:embed assets/netbird.png -var iconAbout []byte - -//go:embed assets/netbird-disconnected.png -var iconAboutDisconnected []byte +// Windows reuses these PNGs: multi-frame .ico never redrew under Wails3's NIM_MODIFY, single-frame PNG does. //go:embed assets/netbird-systemtray-connected.png var iconConnected []byte @@ -21,18 +15,6 @@ var iconConnectedDark []byte //go:embed assets/netbird-systemtray-disconnected.png var iconDisconnected []byte -//go:embed assets/netbird-systemtray-update-disconnected.png -var iconUpdateDisconnected []byte - -//go:embed assets/netbird-systemtray-update-disconnected-dark.png -var iconUpdateDisconnectedDark []byte - -//go:embed assets/netbird-systemtray-update-connected.png -var iconUpdateConnected []byte - -//go:embed assets/netbird-systemtray-update-connected-dark.png -var iconUpdateConnectedDark []byte - //go:embed assets/netbird-systemtray-connecting.png var iconConnecting []byte @@ -44,3 +26,91 @@ var iconError []byte //go:embed assets/netbird-systemtray-error-dark.png var iconErrorDark []byte + +//go:embed assets/netbird-systemtray-needs-login.png +var iconNeedsLogin []byte + +//go:embed assets/netbird-systemtray-update-connected.png +var iconUpdateConnected []byte + +//go:embed assets/netbird-systemtray-update-connected-dark.png +var iconUpdateConnectedDark []byte + +//go:embed assets/netbird-systemtray-update-disconnected.png +var iconUpdateDisconnected []byte + +//go:embed assets/netbird-systemtray-update-disconnected-dark.png +var iconUpdateDisconnectedDark []byte + +//go:embed assets/netbird-systemtray-connected-macos.png +var iconConnectedMacOS []byte + +//go:embed assets/netbird-systemtray-disconnected-macos.png +var iconDisconnectedMacOS []byte + +//go:embed assets/netbird-systemtray-connecting-macos.png +var iconConnectingMacOS []byte + +//go:embed assets/netbird-systemtray-error-macos.png +var iconErrorMacOS []byte + +//go:embed assets/netbird-systemtray-needs-login-macos.png +var iconNeedsLoginMacOS []byte + +//go:embed assets/netbird-systemtray-update-connected-macos.png +var iconUpdateConnectedMacOS []byte + +//go:embed assets/netbird-systemtray-update-disconnected-macos.png +var iconUpdateDisconnectedMacOS []byte + +// SNI has no template recoloring, so ship an explicit pair: black (*-mono.png) +// for light panels, white (*-mono-dark.png) for dark panels. + +//go:embed assets/netbird-systemtray-connected-mono.png +var iconConnectedMono []byte + +//go:embed assets/netbird-systemtray-connected-mono-dark.png +var iconConnectedMonoDark []byte + +//go:embed assets/netbird-systemtray-connecting-mono.png +var iconConnectingMono []byte + +//go:embed assets/netbird-systemtray-connecting-mono-dark.png +var iconConnectingMonoDark []byte + +//go:embed assets/netbird-systemtray-disconnected-mono.png +var iconDisconnectedMono []byte + +//go:embed assets/netbird-systemtray-disconnected-mono-dark.png +var iconDisconnectedMonoDark []byte + +//go:embed assets/netbird-systemtray-error-mono.png +var iconErrorMono []byte + +//go:embed assets/netbird-systemtray-error-mono-dark.png +var iconErrorMonoDark []byte + +//go:embed assets/netbird-systemtray-needs-login-mono.png +var iconNeedsLoginMono []byte + +//go:embed assets/netbird-systemtray-needs-login-mono-dark.png +var iconNeedsLoginMonoDark []byte + +//go:embed assets/netbird-systemtray-update-connected-mono.png +var iconUpdateConnectedMono []byte + +//go:embed assets/netbird-systemtray-update-connected-mono-dark.png +var iconUpdateConnectedMonoDark []byte + +//go:embed assets/netbird-systemtray-update-disconnected-mono.png +var iconUpdateDisconnectedMono []byte + +//go:embed assets/netbird-systemtray-update-disconnected-mono-dark.png +var iconUpdateDisconnectedMonoDark []byte + +//go:embed assets/netbird.png +var iconWindow []byte + +// Per-platform menu-row icons live in icons_menu_{windows,other}.go. Windows +// uses 16x16: they go into the Win32 check-mark slot (SM_CXMENUCHECK, ~16x16 at +// 100% DPI) which crops anything bigger; macOS/Linux use 24x24. diff --git a/client/ui/icons_menu_darwin.go b/client/ui/icons_menu_darwin.go new file mode 100644 index 000000000..c077a6d2d --- /dev/null +++ b/client/ui/icons_menu_darwin.go @@ -0,0 +1,23 @@ +//go:build darwin + +package main + +import _ "embed" + +// 22px matches the NSMenuItem row text weight (HIG's 18-22 range); +// Windows uses 16px and Linux 24px — see the sibling icons_menu_*.go. + +//go:embed assets/netbird-menu-dot-connected-22.png +var iconMenuDotConnected []byte + +//go:embed assets/netbird-menu-dot-connecting-22.png +var iconMenuDotConnecting []byte + +//go:embed assets/netbird-menu-dot-error-22.png +var iconMenuDotError []byte + +//go:embed assets/netbird-menu-dot-idle-22.png +var iconMenuDotIdle []byte + +//go:embed assets/netbird-menu-dot-offline-22.png +var iconMenuDotOffline []byte diff --git a/client/ui/icons_menu_linux.go b/client/ui/icons_menu_linux.go new file mode 100644 index 000000000..54e0c743a --- /dev/null +++ b/client/ui/icons_menu_linux.go @@ -0,0 +1,22 @@ +//go:build linux + +package main + +import _ "embed" + +// 24x24: GTK4 menu rows render 22–48 px with no downscaling. + +//go:embed assets/netbird-menu-dot-connected.png +var iconMenuDotConnected []byte + +//go:embed assets/netbird-menu-dot-connecting.png +var iconMenuDotConnecting []byte + +//go:embed assets/netbird-menu-dot-error.png +var iconMenuDotError []byte + +//go:embed assets/netbird-menu-dot-idle.png +var iconMenuDotIdle []byte + +//go:embed assets/netbird-menu-dot-offline.png +var iconMenuDotOffline []byte diff --git a/client/ui/icons_menu_windows.go b/client/ui/icons_menu_windows.go new file mode 100644 index 000000000..c08359902 --- /dev/null +++ b/client/ui/icons_menu_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package main + +import _ "embed" + +// SetMenuItemBitmaps sizes the HBITMAP to SM_CXMENUCHECK/SM_CYMENUCHECK (16x16 +// at 100% DPI); larger bitmaps overflow the row, hence this Windows-only set +// downscaled from the 24x24 originals. + +//go:embed assets/netbird-menu-dot-connected-16.png +var iconMenuDotConnected []byte + +//go:embed assets/netbird-menu-dot-connecting-16.png +var iconMenuDotConnecting []byte + +//go:embed assets/netbird-menu-dot-error-16.png +var iconMenuDotError []byte + +//go:embed assets/netbird-menu-dot-idle-16.png +var iconMenuDotIdle []byte + +//go:embed assets/netbird-menu-dot-offline-16.png +var iconMenuDotOffline []byte diff --git a/client/ui/icons_windows.go b/client/ui/icons_windows.go deleted file mode 100644 index bd57b2690..000000000 --- a/client/ui/icons_windows.go +++ /dev/null @@ -1,44 +0,0 @@ -package main - -import ( - _ "embed" -) - -//go:embed assets/netbird.ico -var iconAbout []byte - -//go:embed assets/netbird-disconnected.ico -var iconAboutDisconnected []byte - -//go:embed assets/netbird-systemtray-connected.ico -var iconConnected []byte - -//go:embed assets/netbird-systemtray-connected-dark.ico -var iconConnectedDark []byte - -//go:embed assets/netbird-systemtray-disconnected.ico -var iconDisconnected []byte - -//go:embed assets/netbird-systemtray-update-disconnected.ico -var iconUpdateDisconnected []byte - -//go:embed assets/netbird-systemtray-update-disconnected-dark.ico -var iconUpdateDisconnectedDark []byte - -//go:embed assets/netbird-systemtray-update-connected.ico -var iconUpdateConnected []byte - -//go:embed assets/netbird-systemtray-update-connected-dark.ico -var iconUpdateConnectedDark []byte - -//go:embed assets/netbird-systemtray-connecting.ico -var iconConnecting []byte - -//go:embed assets/netbird-systemtray-connecting-dark.ico -var iconConnectingDark []byte - -//go:embed assets/netbird-systemtray-error.ico -var iconError []byte - -//go:embed assets/netbird-systemtray-error-dark.ico -var iconErrorDark []byte diff --git a/client/ui/localizer.go b/client/ui/localizer.go new file mode 100644 index 000000000..33c1cf205 --- /dev/null +++ b/client/ui/localizer.go @@ -0,0 +1,130 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "strings" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" + "github.com/netbirdio/netbird/client/ui/services" +) + +// Localizer caches the active language so key lookups skip the preferences store. +// +// Kept in the main package (not i18n/) because StatusLabel maps daemon +// status enum strings to translations; moving it would invert the +// dependency direction. +type Localizer struct { + bundle *i18n.Bundle + store *preferences.Store + + mu sync.RWMutex + lang i18n.LanguageCode + + unsubscribe func() +} + +// NewLocalizer seeds the active language from the on-disk preference. Either +// argument may be nil (tests): T then returns the raw key and Watch is a no-op. +func NewLocalizer(bundle *i18n.Bundle, store *preferences.Store) *Localizer { + l := &Localizer{ + bundle: bundle, + store: store, + lang: i18n.DefaultLanguage, + } + if store != nil { + if p := store.Get(); p.Language != "" { + l.lang = p.Language + } + } + return l +} + +// Language returns the active language code. +func (l *Localizer) Language() i18n.LanguageCode { + l.mu.RLock() + defer l.mu.RUnlock() + return l.lang +} + +// T resolves key in the current language; args are {placeholder}/value pairs. +// With no bundle wired it returns key unchanged. +func (l *Localizer) T(key string, args ...string) string { + if l == nil || l.bundle == nil { + return key + } + l.mu.RLock() + lang := l.lang + l.mu.RUnlock() + return l.bundle.Translate(lang, key, args...) +} + +// Watch invokes cb on each language change, after the cached language is +// updated so cb may call l.T with the new locale. Replaces any prior subscription. +func (l *Localizer) Watch(cb func(lang i18n.LanguageCode)) { + if l.store == nil { + return + } + ch, unsubscribe := l.store.Subscribe() + l.mu.Lock() + if l.unsubscribe != nil { + l.unsubscribe() + } + l.unsubscribe = unsubscribe + l.mu.Unlock() + + go func() { + for p := range ch { + if p.Language == "" { + continue + } + l.mu.Lock() + if l.lang == p.Language { + l.mu.Unlock() + continue + } + l.lang = p.Language + l.mu.Unlock() + log.Infof("localizer: language switched to %s", p.Language) + if cb != nil { + cb(p.Language) + } + } + }() +} + +// Close cancels the preference subscription. +func (l *Localizer) Close() { + l.mu.Lock() + defer l.mu.Unlock() + if l.unsubscribe != nil { + l.unsubscribe() + l.unsubscribe = nil + } +} + +// StatusLabel maps a daemon status string to its tray label; unrecognised +// statuses pass through verbatim. +func (l *Localizer) StatusLabel(status string) string { + switch { + case status == "", strings.EqualFold(status, services.StatusIdle): + return l.T("tray.status.disconnected") + case strings.EqualFold(status, services.StatusDaemonUnavailable): + return l.T("tray.status.daemonUnavailable") + case strings.EqualFold(status, services.StatusConnected): + return l.T("tray.status.connected") + case strings.EqualFold(status, services.StatusConnecting): + return l.T("tray.status.connecting") + case strings.EqualFold(status, services.StatusNeedsLogin): + return l.T("tray.status.needsLogin") + case strings.EqualFold(status, services.StatusLoginFailed): + return l.T("tray.status.loginFailed") + case strings.EqualFold(status, services.StatusSessionExpired): + return l.T("tray.status.sessionExpired") + } + return status +} diff --git a/client/ui/main.go b/client/ui/main.go new file mode 100644 index 000000000..5f740f5ec --- /dev/null +++ b/client/ui/main.go @@ -0,0 +1,396 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "embed" + "flag" + "io/fs" + "log" + "runtime" + "strings" + + "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" + "github.com/netbirdio/netbird/client/ui/services" + "github.com/netbirdio/netbird/client/ui/updater" + "github.com/netbirdio/netbird/util" +) + +//go:embed all:frontend/dist +var assets embed.FS + +// localesRoot embeds the i18n bundles shared by the tray (Go) and the React +// UI (Vite imports the same files). The `all:` prefix is required so +// _index.json is included — //go:embed drops files starting with "_" or "." +// otherwise. +// +//go:embed all:i18n/locales +var localesRoot embed.FS + +// stringList collects repeated string flags. The first user-supplied value +// drops the seeded default; subsequent passes append. +type stringList struct { + values []string + userSet bool +} + +func (s *stringList) String() string { + return strings.Join(s.values, ",") +} + +func (s *stringList) Set(v string) error { + if !s.userSet { + s.values = nil + s.userSet = true + } + s.values = append(s.values, v) + return nil +} + +type registeredServices struct { + connection *services.Connection + authSession *authsession.Session + settings *services.Settings + networks *services.Networks + profiles *services.Profiles + update *services.Update + daemonFeed *services.DaemonFeed + notifier *Notifier + compat *services.Compat + profileSwitcher *services.ProfileSwitcher + bundle *i18n.Bundle + prefStore *preferences.Store +} + +func init() { + application.RegisterEvent[services.Status](services.EventStatusSnapshot) + application.RegisterEvent[services.SystemEvent](services.EventDaemonNotification) + application.RegisterEvent[services.ProfileRef](services.EventProfileChanged) + application.RegisterEvent[authsession.Warning](services.EventSessionWarning) + application.RegisterEvent[updater.State](updater.EventStateChanged) + application.RegisterEvent[preferences.UIPreferences](preferences.EventPreferencesChanged) +} + +func main() { + daemonAddr, userSetLogFile := parseFlagsAndInitLog() + conn := NewConn(daemonAddr) + + // Without --log-file, the GUI manages a gui-client.log that follows the + // daemon's debug level and is collected in the debug bundle. It rides + // DaemonFeed's SubscribeEvents stream (see guilog.DebugLog). + debugLog := newDebugLog(userSetLogFile) + + // Declared before app.New so the SingleInstance callback closes over it. + var tray *Tray + app := newApplication(func() { + if tray != nil { + tray.ShowWindow() + } + }) + + profiles := services.NewProfiles(conn) + // updater.Holder owns the typed update State; DaemonFeed feeds it and the + // Update service is a thin Wails-bound facade over it plus the install RPCs. + updaterHolder := updater.NewHolder(app.Event) + update := services.NewUpdate(conn, updaterHolder) + daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog) + notifier := newNotifier() + compat := services.NewCompat(conn) + // macOS shows no toast until permission is requested. Run it after + // ApplicationStarted so the notifier's Startup has initialised the + // notification-center delegate. No-op on Linux/Windows (stubs report + // authorized). + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + go requestNotificationAuthorization(notifier) + initDockObserver() + }) + + bundle, prefStore, localizer := buildI18n(app) + + // After bundle + prefStore: both are used to localise daemon errors. + settings := services.NewSettings(conn, bundle, prefStore, daemonAddr) + connection := services.NewConnection(conn, bundle, prefStore) + profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed) + // authsession.Session owns the full extend + dismiss surface the tray + // drives directly; the Wails-bound services.Session wraps only the subset + // the React frontend calls, keeping the generated TS surface minimal. + authSession := authsession.NewSession(conn) + networks := services.NewNetworks(conn) + + registerServices(app, conn, registeredServices{ + connection: connection, + authSession: authSession, + settings: settings, + networks: networks, + profiles: profiles, + update: update, + daemonFeed: daemonFeed, + notifier: notifier, + compat: compat, + profileSwitcher: profileSwitcher, + bundle: bundle, + prefStore: prefStore, + }) + + window := newMainWindow(app, prefStore) + + // Settings is created eagerly (hidden) so the first gear click paints + // instantly and React keeps per-tab state across reopens. The other + // auxiliary windows stay lazy + destroy-on-close so Wails's macOS + // dock-reopen handler can't resurrect them. + windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow) + // Minimal WMs (XEmbed-tray path) neither center small windows nor restore + // position across hide -> show, dropping them top-left. Gate Go-side + // re-centering on that environment; nil leaves placement to the WM on full + // desktops, macOS, and Windows. + windowManager.SetRecenterOnShow(recenterOnShowPredicate()) + app.RegisterService(application.NewService(windowManager)) + + // Welcome window, first launch only — Continue flips OnboardingCompleted + // so later launches skip it. ApplicationStarted hook so the Wails window + // machinery is fully up before the window is created. + if !prefStore.Get().OnboardingCompleted { + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + windowManager.OpenWelcome() + }) + } + + // In-process StatusNotifierWatcher so the tray works on minimal WMs that + // don't ship one (Fluxbox, i3, GNOME without AppIndicator). No-op off + // Linux. Must run before NewTray so the systray's + // RegisterStatusNotifierItem hits a watcher we control. + startStatusNotifierWatcher() + + tray = NewTray(app, window, TrayServices{ + Connection: connection, + Settings: settings, + Profiles: profiles, + Networks: networks, + DaemonFeed: daemonFeed, + Notifier: notifier, + Update: update, + ProfileSwitcher: profileSwitcher, + WindowManager: windowManager, + Session: authSession, + Localizer: localizer, + Preferences: prefStore, + }) + listenForShowSignal(context.Background(), tray) + + // Start the feed only after every service's ServiceStartup has run. The + // first SubscribeEvents message replays cached state synchronously and can + // fire an OS notification; if Watch ran before app.Run it could beat the + // notifier's ServiceStartup, where the Linux notifier connects the session + // bus — its *dbus.Conn would still be nil and SendNotification would + // nil-deref (fatal panic on the dispatch goroutine, observed on Linux + // Mint). ApplicationStarted fires after the startup loop, so the bus is up. + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + daemonFeed.Watch(context.Background()) + // Probe daemon compatibility once the notifier bus is up; an outdated + // daemon may keep the main window from showing, so the OS toast is the + // only reliable signal the user gets. + go notifyIfDaemonOutdated(compat, notifier, localizer) + // One-time launch-on-login default for fresh installs; gated by the + // NetBird footprint check, MDM policy, and the persisted marker. + go applyAutostartDefault(context.Background(), services.NewAutostart(app.Autostart), prefStore, prefStore.ExistedAtLoad()) + }) + + if err := app.Run(); err != nil { + log.Fatal(err) + } +} + +// requestNotificationAuthorization prompts for macOS notification permission. +// The request blocks until the user responds (up to 3 minutes), so callers run +// it in a goroutine. No-op on Linux/Windows. +func requestNotificationAuthorization(notifier *Notifier) { + authorized, err := notifier.CheckNotificationAuthorization() + if err != nil { + logrus.Debugf("check notification authorization: %v", err) + return + } + if authorized { + return + } + if _, err := notifier.RequestNotificationAuthorization(); err != nil { + logrus.Debugf("request notification authorization: %v", err) + } +} + +// parseFlagsAndInitLog returns the daemon gRPC address and userSetLogFile +// (true when --log-file was passed). userSetLogFile is the manual-override +// signal: true leaves logging alone, false lets the GUI manage a +// daemon-driven gui-client.log. The flag default is empty (not "console") so +// "no flag" and an explicit "--log-file console" stay distinguishable; empty +// falls back to console for InitLog. +func parseFlagsAndInitLog() (string, bool) { + daemonAddr := flag.String("daemon-addr", DaemonAddr(), "Daemon gRPC address: unix:///path or tcp://host:port") + logFiles := &stringList{} + flag.Var(logFiles, "log-file", "Log destination. Repeat to log to multiple targets at once, e.g. `--log-file console --log-file Y:/netbird-ui.log`. Each value is one of: console, syslog, or a file path. File destinations are rotated by lumberjack (same as the daemon). Defaults to console. Passing any value disables the daemon-debug-driven gui-client.log.") + logLevel := flag.String("log-level", "info", "Log level: trace|debug|info|warn|error.") + flag.Parse() + + userSetLogFile := len(logFiles.values) > 0 + targets := logFiles.values + if !userSetLogFile { + targets = []string{"console"} + } + + if err := util.InitLog(*logLevel, targets...); err != nil { + log.Fatalf("init log: %v", err) + } + return *daemonAddr, userSetLogFile +} + +// newApplication constructs the Wails application. onSecondInstance fires when +// a second process launches under the same SingleInstance UniqueID. +func newApplication(onSecondInstance func()) *application.App { + // On macOS, Options.Icon feeds NSApplication's setApplicationIconImage, + // overriding the bundle icon (Assets.car / icons.icns) the OS already + // picked. Suppress it on darwin to keep the bundle's squircle. + appIcon := iconWindow + if runtime.GOOS == "darwin" { + appIcon = nil + } + + return application.New(application.Options{ + // On Windows, Name is the AppUserModelID for toast notifications and + // the HKCU\Software\Classes\AppUserModelId\ registry path. It must + // match the System.AppUserModel.ID the MSI sets on the Start Menu + // shortcut (client/netbird.wxs) and the AppUserModelId key the + // installer pre-populates with the toast activator CLSID; otherwise + // toasts show under a different identity and the MSI's CustomActivator + // value is orphaned. + Name: "NetBird", + Description: "NetBird desktop client", + Icon: appIcon, + Assets: application.AssetOptions{ + Handler: application.AssetFileServerFS(assets), + }, + Mac: application.MacOptions{ + ApplicationShouldTerminateAfterLastWindowClosed: false, + ActivationPolicy: application.ActivationPolicyAccessory, + }, + Linux: application.LinuxOptions{ + ProgramName: "netbird", + }, + Windows: application.WindowsOptions{ + WndProcInterceptor: endSessionInterceptor(), + }, + SingleInstance: &application.SingleInstanceOptions{ + UniqueID: "io.netbird.ui", + OnSecondInstanceLaunch: func(_ application.SecondInstanceData) { + onSecondInstance() + }, + }, + }) +} + +// buildI18n constructs the i18n bundle, preferences store, and tray localizer. +// The Bundle satisfies preferences.LanguageValidator so SetLanguage rejects +// codes that have no shipped translation. +func buildI18n(app *application.App) (*i18n.Bundle, *preferences.Store, *Localizer) { + // Reroot the embedded tree at the locales dir so the bundle sees + // _index.json and /common.json at top level (//go:embed roots at + // the package, not the leaf dir). + localesFS, err := fs.Sub(localesRoot, "i18n/locales") + if err != nil { + log.Fatalf("locate locales fs: %v", err) + } + bundle, err := i18n.NewBundle(localesFS) + if err != nil { + log.Fatalf("init i18n bundle: %v", err) + } + prefStore, err := preferences.NewStore(bundle, app.Event) + if err != nil { + log.Fatalf("init preferences store: %v", err) + } + return bundle, prefStore, NewLocalizer(bundle, prefStore) +} + +// registerServices binds every Wails-facing service onto the application. +// Services with no other caller are constructed inline; the rest arrive +// already built so the tray and feed loops share the same instances. +func registerServices(app *application.App, conn *Conn, s registeredServices) { + app.RegisterService(application.NewService(s.connection)) + app.RegisterService(application.NewService(services.NewSession(s.authSession, s.bundle, s.prefStore))) + app.RegisterService(application.NewService(s.settings)) + app.RegisterService(application.NewService(s.networks)) + app.RegisterService(application.NewService(services.NewForwarding(conn))) + app.RegisterService(application.NewService(s.profiles)) + app.RegisterService(application.NewService(services.NewDebug(conn))) + app.RegisterService(application.NewService(s.update)) + app.RegisterService(application.NewService(s.daemonFeed)) + app.RegisterService(application.NewService(s.notifier)) + app.RegisterService(application.NewService(s.profileSwitcher)) + app.RegisterService(application.NewService(services.NewI18n(s.bundle))) + app.RegisterService(application.NewService(services.NewPreferences(s.prefStore))) + app.RegisterService(application.NewService(services.NewAutostart(app.Autostart))) + app.RegisterService(application.NewService(services.NewVersion())) + app.RegisterService(application.NewService(services.NewUILog())) + app.RegisterService(application.NewService(s.compat)) +} + +// newMainWindow creates the hidden main window, sized to the user's last view +// mode, and installs the hide-on-close and macOS dock-reopen hooks. +func newMainWindow(app *application.App, prefStore *preferences.Store) *application.WebviewWindow { + // Width matches the last view mode so Advanced-mode users don't see the + // window pop from 380px to 900px on launch. Height is mode-agnostic. + initialWidth := 380 + if prefStore.Get().ViewMode == preferences.ViewModeAdvanced { + initialWidth = 900 + } + window := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "main", + Title: "NetBird", + Width: initialWidth, + Height: services.WindowHeight, + // Center on first show; minimal WMs (fluxbox, the XEmbed tray path) + // drop new windows top-left unless asked. + InitialPosition: application.WindowCentered, + Hidden: true, + BackgroundColour: services.WindowBackgroundColour, + URL: "/", + DisableResize: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + Mac: services.AppleMacOSAppearanceOptions(), + Windows: services.MicrosoftWindowsAppearanceOptions(), + Linux: application.LinuxWindow{ + Icon: iconWindow, + }, + }) + + // Hide instead of quit on close; "really quit" is reached via tray -> Quit. + window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + if services.ShuttingDown() { + return + } + e.Cancel() + window.Hide() + }) + + // On macOS, Wails' default applicationShouldHandleReopen handler Show()s + // every hidden window on dock-icon click, resurrecting hide-on-close + // surfaces like Settings. Cancel it in a hook (hooks run before listeners) + // and show only the main window. No-op elsewhere — the event never fires. + if runtime.GOOS == "darwin" { + app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) { + e.Cancel() + if e.Context().HasVisibleWindows() { + return + } + window.Show() + window.Focus() + }) + } + + return window +} diff --git a/client/ui/manifest.xml b/client/ui/manifest.xml deleted file mode 100644 index c71a407e5..000000000 --- a/client/ui/manifest.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - Netbird UI application - - - - - - - - \ No newline at end of file diff --git a/client/ui/netbird-ui.rb.tmpl b/client/ui/netbird-ui.rb.tmpl index 06971909d..1c77e6717 100644 --- a/client/ui/netbird-ui.rb.tmpl +++ b/client/ui/netbird-ui.rb.tmpl @@ -29,8 +29,13 @@ cask "{{ $projectName }}" do end uninstall_preflight do - system_command "#{appdir}/Netbird UI.app/uninstaller.sh", - sudo: false + system_command "/bin/sh", + args: ["-c", <<~CMD], + launchctl bootout system/netbird 2>/dev/null || \ + launchctl unload /Library/LaunchDaemons/netbird.plist 2>/dev/null || true + rm -f /Library/LaunchDaemons/netbird.plist + CMD + sudo: true end name "Netbird UI" diff --git a/client/ui/network.go b/client/ui/network.go deleted file mode 100644 index cd5d23558..000000000 --- a/client/ui/network.go +++ /dev/null @@ -1,707 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "fmt" - "runtime" - "sort" - "strings" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" - "fyne.io/systray" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/proto" -) - -const ( - allNetworksText = "All networks" - overlappingNetworksText = "Overlapping networks" - exitNodeNetworksText = "Exit-node networks" - allNetworks filter = "all" - overlappingNetworks filter = "overlapping" - exitNodeNetworks filter = "exit-node" - getClientFMT = "get client: %v" -) - -type filter string - -func (s *serviceClient) showNetworksUI() { - s.wNetworks = s.app.NewWindow("Networks") - s.wNetworks.SetOnClosed(s.cancel) - - allGrid := container.New(layout.NewGridLayout(3)) - go s.updateNetworks(allGrid, allNetworks) - overlappingGrid := container.New(layout.NewGridLayout(3)) - exitNodeGrid := container.New(layout.NewGridLayout(3)) - routeCheckContainer := container.NewVBox() - tabs := container.NewAppTabs( - container.NewTabItem(allNetworksText, allGrid), - container.NewTabItem(overlappingNetworksText, overlappingGrid), - container.NewTabItem(exitNodeNetworksText, exitNodeGrid), - ) - tabs.OnSelected = func(item *container.TabItem) { - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - } - tabs.OnUnselected = func(item *container.TabItem) { - grid, _ := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - grid.Objects = nil - } - - routeCheckContainer.Add(tabs) - scrollContainer := container.NewVScroll(routeCheckContainer) - scrollContainer.SetMinSize(fyne.NewSize(200, 300)) - - buttonBox := container.NewHBox( - layout.NewSpacer(), - widget.NewButton("Refresh", func() { - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - }), - widget.NewButton("Select all", func() { - _, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - s.selectAllFilteredNetworks(f) - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - }), - widget.NewButton("Deselect All", func() { - _, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - s.deselectAllFilteredNetworks(f) - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodeGrid) - }), - layout.NewSpacer(), - ) - - content := container.NewBorder(nil, buttonBox, nil, nil, scrollContainer) - - s.wNetworks.SetContent(content) - s.wNetworks.Show() - - s.startAutoRefresh(10*time.Second, tabs, allGrid, overlappingGrid, exitNodeGrid) -} - -func (s *serviceClient) updateNetworks(grid *fyne.Container, f filter) { - grid.Objects = nil - grid.Refresh() - idHeader := widget.NewLabelWithStyle(" ID", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - networkHeader := widget.NewLabelWithStyle("Range/Domains", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - resolvedIPsHeader := widget.NewLabelWithStyle("Resolved IPs", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - - grid.Add(idHeader) - grid.Add(networkHeader) - grid.Add(resolvedIPsHeader) - - filteredRoutes, err := s.getFilteredNetworks(f) - if err != nil { - return - } - - sortNetworksByIDs(filteredRoutes) - - for _, route := range filteredRoutes { - r := route - - checkBox := widget.NewCheck(r.GetID(), func(checked bool) { - s.selectNetwork(r.ID, checked) - }) - checkBox.Checked = route.Selected - checkBox.Resize(fyne.NewSize(20, 20)) - checkBox.Refresh() - - grid.Add(checkBox) - network := r.GetRange() - domains := r.GetDomains() - - if len(domains) == 0 { - grid.Add(widget.NewLabel(network)) - grid.Add(widget.NewLabel("")) - continue - } - - // our selectors are only for display - noopFunc := func(_ string) { - // do nothing - } - - domainsSelector := widget.NewSelect(domains, noopFunc) - domainsSelector.Selected = domains[0] - grid.Add(domainsSelector) - - var resolvedIPsList []string - for domain, ipList := range r.GetResolvedIPs() { - resolvedIPsList = append(resolvedIPsList, fmt.Sprintf("%s: %s", domain, strings.Join(ipList.GetIps(), ", "))) - } - - if len(resolvedIPsList) == 0 { - grid.Add(widget.NewLabel("")) - continue - } - - // TODO: limit width within the selector display - resolvedIPsSelector := widget.NewSelect(resolvedIPsList, noopFunc) - resolvedIPsSelector.Selected = resolvedIPsList[0] - resolvedIPsSelector.Resize(fyne.NewSize(100, 100)) - grid.Add(resolvedIPsSelector) - } - - s.wNetworks.Content().Refresh() - grid.Refresh() -} - -func (s *serviceClient) getFilteredNetworks(f filter) ([]*proto.Network, error) { - routes, err := s.fetchNetworks() - if err != nil { - log.Errorf(getClientFMT, err) - s.showError(fmt.Errorf(getClientFMT, err)) - return nil, err - } - switch f { - case overlappingNetworks: - return getOverlappingNetworks(routes), nil - case exitNodeNetworks: - return getExitNodeNetworks(routes), nil - default: - } - return routes, nil -} - -func getOverlappingNetworks(routes []*proto.Network) []*proto.Network { - var filteredRoutes []*proto.Network - existingRange := make(map[string][]*proto.Network) - for _, route := range routes { - if len(route.Domains) > 0 { - continue - } - if r, exists := existingRange[route.GetRange()]; exists { - r = append(r, route) - existingRange[route.GetRange()] = r - } else { - existingRange[route.GetRange()] = []*proto.Network{route} - } - } - for _, r := range existingRange { - if len(r) > 1 { - filteredRoutes = append(filteredRoutes, r...) - } - } - return filteredRoutes -} - -func isDefaultRoute(routeRange string) bool { - // routeRange is the merged display string from the daemon, e.g. "0.0.0.0/0", - // "::/0", or "0.0.0.0/0, ::/0" when a v4 exit node has a paired v6 entry. - for _, part := range strings.Split(routeRange, ",") { - switch strings.TrimSpace(part) { - case "0.0.0.0/0", "::/0": - return true - } - } - return false -} - -func getExitNodeNetworks(routes []*proto.Network) []*proto.Network { - var filteredRoutes []*proto.Network - for _, route := range routes { - if isDefaultRoute(route.Range) { - filteredRoutes = append(filteredRoutes, route) - } - } - return filteredRoutes -} - -func sortNetworksByIDs(routes []*proto.Network) { - sort.Slice(routes, func(i, j int) bool { - return strings.ToLower(routes[i].GetID()) < strings.ToLower(routes[j].GetID()) - }) -} - -func (s *serviceClient) fetchNetworks() ([]*proto.Network, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf(getClientFMT, err) - } - - resp, err := conn.ListNetworks(s.ctx, &proto.ListNetworksRequest{}) - if err != nil { - return nil, fmt.Errorf("failed to list routes: %v", err) - } - - return resp.Routes, nil -} - -func (s *serviceClient) selectNetwork(id string, checked bool) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf(getClientFMT, err) - s.showError(fmt.Errorf(getClientFMT, err)) - return - } - - req := &proto.SelectNetworksRequest{ - NetworkIDs: []string{id}, - Append: checked, - } - - if checked { - if _, err := conn.SelectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to select network: %v", err) - s.showError(fmt.Errorf("failed to select network: %v", err)) - return - } - log.Infof("Network '%s' selected", id) - } else { - if _, err := conn.DeselectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to deselect network: %v", err) - s.showError(fmt.Errorf("failed to deselect network: %v", err)) - return - } - log.Infof("Network '%s' deselected", id) - } -} - -func (s *serviceClient) selectAllFilteredNetworks(f filter) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf(getClientFMT, err) - return - } - - req := s.getNetworksRequest(f, true) - if _, err := conn.SelectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to select all networks: %v", err) - s.showError(fmt.Errorf("failed to select all networks: %v", err)) - return - } - - log.Debug("All networks selected") -} - -func (s *serviceClient) deselectAllFilteredNetworks(f filter) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf(getClientFMT, err) - return - } - - req := s.getNetworksRequest(f, false) - if _, err := conn.DeselectNetworks(s.ctx, req); err != nil { - log.Errorf("failed to deselect all networks: %v", err) - s.showError(fmt.Errorf("failed to deselect all networks: %v", err)) - return - } - - log.Debug("All networks deselected") -} - -func (s *serviceClient) getNetworksRequest(f filter, appendRoute bool) *proto.SelectNetworksRequest { - req := &proto.SelectNetworksRequest{} - if f == allNetworks { - req.All = true - } else { - routes, err := s.getFilteredNetworks(f) - if err != nil { - return nil - } - for _, route := range routes { - req.NetworkIDs = append(req.NetworkIDs, route.GetID()) - } - req.Append = appendRoute - } - return req -} - -func (s *serviceClient) showError(err error) { - wrappedMessage := wrapText(err.Error(), 50) - - dialog.ShowError(fmt.Errorf("%s", wrappedMessage), s.wNetworks) -} - -func (s *serviceClient) startAutoRefresh(interval time.Duration, tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) { - ticker := time.NewTicker(interval) - go func() { - for range ticker.C { - s.updateNetworksBasedOnDisplayTab(tabs, allGrid, overlappingGrid, exitNodesGrid) - } - }() - - s.wNetworks.SetOnClosed(func() { - ticker.Stop() - s.cancel() - }) -} - -func (s *serviceClient) updateNetworksBasedOnDisplayTab(tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) { - grid, f := getGridAndFilterFromTab(tabs, allGrid, overlappingGrid, exitNodesGrid) - s.wNetworks.Content().Refresh() - s.updateNetworks(grid, f) -} - -// startExitNodeRefresh initiates exit node menu refresh after connecting. -// On Windows, TrayOpenedCh is not supported by the systray library, so we use -// a background poller to keep exit nodes in sync while connected. -// On macOS/Linux, TrayOpenedCh handles refreshes on each tray open. -func (s *serviceClient) startExitNodeRefresh() { - s.cancelExitNodeRetry() - - if runtime.GOOS == "windows" { - ctx, cancel := context.WithCancel(s.ctx) - s.exitNodeMu.Lock() - s.exitNodeRetryCancel = cancel - s.exitNodeMu.Unlock() - - go s.pollExitNodes(ctx) - } else { - go s.updateExitNodes() - } -} - -func (s *serviceClient) cancelExitNodeRetry() { - s.exitNodeMu.Lock() - if s.exitNodeRetryCancel != nil { - s.exitNodeRetryCancel() - s.exitNodeRetryCancel = nil - } - s.exitNodeMu.Unlock() -} - -// pollExitNodes periodically refreshes exit nodes while connected. -// Uses a short initial interval to catch routes from the management sync, -// then switches to a longer interval for ongoing updates. -func (s *serviceClient) pollExitNodes(ctx context.Context) { - // Initial fast polling to catch routes as they appear after connect. - for i := 0; i < 5; i++ { - if s.updateExitNodes() { - break - } - select { - case <-ctx.Done(): - return - case <-time.After(2 * time.Second): - } - } - - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - s.updateExitNodes() - } - } -} - -// updateExitNodes fetches exit nodes from the daemon and recreates the menu. -// Returns true if exit nodes were found. -func (s *serviceClient) updateExitNodes() bool { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return false - } - exitNodes, err := s.getExitNodes(conn) - if err != nil { - log.Errorf("get exit nodes: %v", err) - return false - } - - s.exitNodeMu.Lock() - defer s.exitNodeMu.Unlock() - - s.recreateExitNodeMenu(exitNodes) - - if len(s.mExitNodeItems) > 0 { - s.mExitNode.Enable() - return true - } - - s.mExitNode.Disable() - return false -} - -func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) { - for _, node := range s.mExitNodeItems { - node.cancel() - node.Hide() - node.Remove() - } - s.mExitNodeItems = nil - if s.mExitNodeSeparator != nil { - s.mExitNodeSeparator.Remove() - s.mExitNodeSeparator = nil - } - if s.mExitNodeDeselectAll != nil { - s.mExitNodeDeselectAll.Remove() - s.mExitNodeDeselectAll = nil - } - - if runtime.GOOS == "linux" || runtime.GOOS == "freebsd" { - s.mExitNode.Remove() - s.mExitNode = systray.AddMenuItem("Exit Node", disabledMenuDescr) - } - - var showDeselectAll bool - - for _, node := range exitNodes { - if node.Selected { - showDeselectAll = true - } - - menuItem := s.mExitNode.AddSubMenuItemCheckbox( - node.ID, - fmt.Sprintf("Use exit node %s", node.ID), - node.Selected, - ) - - ctx, cancel := context.WithCancel(s.ctx) - s.mExitNodeItems = append(s.mExitNodeItems, menuHandler{ - MenuItem: menuItem, - cancel: cancel, - }) - go s.handleChecked(ctx, node.ID, menuItem) - } - - if showDeselectAll { - s.addExitNodeDeselectAll() - } - -} - -func (s *serviceClient) addExitNodeDeselectAll() { - sep := s.mExitNode.AddSubMenuItem("───────────────", "") - sep.Disable() - s.mExitNodeSeparator = sep - - deselectAllItem := s.mExitNode.AddSubMenuItem("Deselect All", "Deselect All") - s.mExitNodeDeselectAll = deselectAllItem - - go func() { - for { - _, ok := <-deselectAllItem.ClickedCh - if !ok { - return - } - exitNodes, err := s.handleExitNodeMenuDeselectAll() - if err != nil { - log.Warnf("failed to handle deselect all exit nodes: %v", err) - } else { - s.exitNodeMu.Lock() - s.recreateExitNodeMenu(exitNodes) - s.exitNodeMu.Unlock() - } - } - }() -} - -func (s *serviceClient) getExitNodes(conn proto.DaemonServiceClient) ([]*proto.Network, error) { - ctx, cancel := context.WithTimeout(s.ctx, defaultFailTimeout) - defer cancel() - - resp, err := conn.ListNetworks(ctx, &proto.ListNetworksRequest{}) - if err != nil { - return nil, fmt.Errorf("list networks: %v", err) - } - - var exitNodes []*proto.Network - for _, network := range resp.Routes { - if isDefaultRoute(network.Range) { - exitNodes = append(exitNodes, network) - } - } - return exitNodes, nil -} - -func (s *serviceClient) handleChecked(ctx context.Context, id string, item *systray.MenuItem) { - for { - select { - case <-ctx.Done(): - return - case _, ok := <-item.ClickedCh: - if !ok { - return - } - if err := s.toggleExitNode(id, item); err != nil { - log.Errorf("failed to toggle exit node: %v", err) - continue - } - } - } -} - -func (s *serviceClient) handleExitNodeMenuDeselectAll() ([]*proto.Network, error) { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf("get client: %v", err) - } - - exitNodes, err := s.getExitNodes(conn) - if err != nil { - return nil, fmt.Errorf("get exit nodes: %v", err) - } - - var ids []string - for _, e := range exitNodes { - if e.Selected { - ids = append(ids, e.ID) - } - } - - // deselect selected exit nodes - if err := s.deselectOtherExitNodes(conn, ids); err != nil { - return nil, err - } - - updatedExitNodes, err := s.getExitNodes(conn) - if err != nil { - return nil, fmt.Errorf("re-fetch exit nodes: %v", err) - } - - return updatedExitNodes, nil -} - -// Add function to toggle exit node selection -func (s *serviceClient) toggleExitNode(nodeID string, item *systray.MenuItem) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf("get client: %v", err) - } - - log.Infof("Toggling exit node '%s'", nodeID) - - s.exitNodeMu.Lock() - defer s.exitNodeMu.Unlock() - - exitNodes, err := s.getExitNodes(conn) - if err != nil { - return fmt.Errorf("get exit nodes: %v", err) - } - - var exitNode *proto.Network - // find other selected nodes and ours - ids := make([]string, 0, len(exitNodes)) - for _, node := range exitNodes { - if node.ID == nodeID { - // preserve original state - cp := *node //nolint:govet - exitNode = &cp - - // set desired state for recreation - node.Selected = true - continue - } - if node.Selected { - ids = append(ids, node.ID) - - // set desired state for recreation - node.Selected = false - } - } - - // exit node is the only selected node, deselect it - deselectAll := item.Checked() && len(ids) == 0 - if deselectAll { - ids = append(ids, nodeID) - for _, node := range exitNodes { - if node.ID == nodeID { - // set desired state for recreation - node.Selected = false - } - } - } - - // deselect all other selected exit nodes - if err := s.deselectOtherExitNodes(conn, ids); err != nil { - return err - } - - if !deselectAll { - if err := s.selectNewExitNode(conn, exitNode, nodeID, item); err != nil { - return err - } - } - - // linux/bsd doesn't handle Check/Uncheck well, so we recreate the menu - if runtime.GOOS == "linux" || runtime.GOOS == "freebsd" { - s.recreateExitNodeMenu(exitNodes) - } - - return nil -} - -func (s *serviceClient) deselectOtherExitNodes(conn proto.DaemonServiceClient, ids []string) error { - // deselect all other selected exit nodes - if len(ids) > 0 { - deselectReq := &proto.SelectNetworksRequest{ - NetworkIDs: ids, - } - if _, err := conn.DeselectNetworks(s.ctx, deselectReq); err != nil { - return fmt.Errorf("deselect networks: %v", err) - } - - log.Infof("Deselected exit nodes: %v", ids) - } - - // uncheck all other exit node menu items - for _, i := range s.mExitNodeItems { - i.Uncheck() - log.Infof("Unchecked exit node %v", i) - } - - return nil -} - -func (s *serviceClient) selectNewExitNode(conn proto.DaemonServiceClient, exitNode *proto.Network, nodeID string, item *systray.MenuItem) error { - if exitNode != nil && !exitNode.Selected { - selectReq := &proto.SelectNetworksRequest{ - NetworkIDs: []string{exitNode.ID}, - Append: true, - } - if _, err := conn.SelectNetworks(s.ctx, selectReq); err != nil { - return fmt.Errorf("select network: %v", err) - } - - log.Infof("Selected exit node '%s'", nodeID) - } - - item.Check() - log.Infof("Checked exit node '%s'", nodeID) - - return nil -} - -func getGridAndFilterFromTab(tabs *container.AppTabs, allGrid, overlappingGrid, exitNodesGrid *fyne.Container) (*fyne.Container, filter) { - switch tabs.Selected().Text { - case overlappingNetworksText: - return overlappingGrid, overlappingNetworks - case exitNodeNetworksText: - return exitNodesGrid, exitNodeNetworks - default: - return allGrid, allNetworks - } -} - -// wrapText inserts newlines into the text to ensure that each line is -// no longer than 'lineLength' runes. -func wrapText(text string, lineLength int) string { - var sb strings.Builder - var currentLineLength int - - for _, runeValue := range text { - sb.WriteRune(runeValue) - currentLineLength++ - - if currentLineLength >= lineLength || runeValue == '\n' { - sb.WriteRune('\n') - currentLineLength = 0 - } - } - - return sb.String() -} diff --git a/client/ui/notifier.go b/client/ui/notifier.go new file mode 100644 index 000000000..71ae3b0df --- /dev/null +++ b/client/ui/notifier.go @@ -0,0 +1,101 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "errors" + "sync/atomic" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/services/notifications" +) + +var errNotificationsUnavailable = errors.New("notifications unavailable") + +// Notifier wraps the Wails notification service so an unavailable backend +// disables notifications instead of aborting the app. Startup fails for +// environment reasons (a bare unbundled binary on macOS has no bundle +// identifier, a headless Linux session has no D-Bus session bus), and Wails +// treats a service startup error as fatal. After a failed startup every call +// is a no-op: on macOS, touching UNUserNotificationCenter without a bundle +// identifier raises an Objective-C exception that recover() cannot catch. +type Notifier struct { + inner *notifications.NotificationService + available atomic.Bool +} + +func newNotifier() *Notifier { + return &Notifier{inner: notifications.New()} +} + +// ServiceName implements the Wails service-name hook for startup logs. +func (n *Notifier) ServiceName() string { + return n.inner.ServiceName() +} + +// ServiceStartup starts the platform notifier, downgrading failure to a +// warning so the app keeps running without notifications. +func (n *Notifier) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + if err := n.inner.ServiceStartup(ctx, options); err != nil { + log.Warnf("notifications disabled: %v", err) + return nil + } + n.available.Store(true) + return nil +} + +func (n *Notifier) ServiceShutdown() error { + if !n.available.Load() { + return nil + } + return n.inner.ServiceShutdown() +} + +func (n *Notifier) CheckNotificationAuthorization() (bool, error) { + if !n.available.Load() { + return false, errNotificationsUnavailable + } + return n.inner.CheckNotificationAuthorization() +} + +func (n *Notifier) RequestNotificationAuthorization() (bool, error) { + if !n.available.Load() { + return false, errNotificationsUnavailable + } + return n.inner.RequestNotificationAuthorization() +} + +// SendNotification delivers a notification, silently dropping it when the +// backend never started (notifications are best-effort everywhere). +func (n *Notifier) SendNotification(options notifications.NotificationOptions) error { + if !n.available.Load() { + log.Debugf("notifications disabled, dropping %q", options.ID) + return nil + } + return n.inner.SendNotification(options) +} + +func (n *Notifier) SendNotificationWithActions(options notifications.NotificationOptions) error { + if !n.available.Load() { + log.Debugf("notifications disabled, dropping %q", options.ID) + return nil + } + return n.inner.SendNotificationWithActions(options) +} + +func (n *Notifier) RegisterNotificationCategory(category notifications.NotificationCategory) error { + if !n.available.Load() { + return nil + } + return n.inner.RegisterNotificationCategory(category) +} + +// OnNotificationResponse registers the response callback. Pure Go state, so +// it is safe (and simply inert) when the backend never started. +// +//wails:ignore +func (n *Notifier) OnNotificationResponse(callback func(result notifications.NotificationResult)) { + n.inner.OnNotificationResponse(callback) +} diff --git a/client/ui/notifier/notifier.go b/client/ui/notifier/notifier.go deleted file mode 100644 index 8d1cbe4c4..000000000 --- a/client/ui/notifier/notifier.go +++ /dev/null @@ -1,27 +0,0 @@ -// Package notifier sends desktop notifications. On Windows it uses the WinRT -// COM API directly via go-toast/v2 to avoid the PowerShell window flash that -// fyne's default implementation produces. On other platforms it delegates to -// fyne. -package notifier - -import "fyne.io/fyne/v2" - -// Notifier sends desktop notifications. -type Notifier interface { - Send(title, body string) -} - -// New returns a platform-specific Notifier. The fyne app is used as the -// fallback notifier on platforms where no native implementation is wired up, -// and on Windows when the COM path fails to initialize. -func New(app fyne.App) Notifier { - return newNotifier(app) -} - -type fyneNotifier struct { - app fyne.App -} - -func (f *fyneNotifier) Send(title, body string) { - f.app.SendNotification(fyne.NewNotification(title, body)) -} diff --git a/client/ui/notifier/notifier_other.go b/client/ui/notifier/notifier_other.go deleted file mode 100644 index 686d2885f..000000000 --- a/client/ui/notifier/notifier_other.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !windows - -package notifier - -import "fyne.io/fyne/v2" - -func newNotifier(app fyne.App) Notifier { - return &fyneNotifier{app: app} -} diff --git a/client/ui/notifier/notifier_windows.go b/client/ui/notifier/notifier_windows.go deleted file mode 100644 index c7afb43ae..000000000 --- a/client/ui/notifier/notifier_windows.go +++ /dev/null @@ -1,88 +0,0 @@ -package notifier - -import ( - "os" - "path/filepath" - "sync" - - "fyne.io/fyne/v2" - toast "git.sr.ht/~jackmordaunt/go-toast/v2" - "git.sr.ht/~jackmordaunt/go-toast/v2/wintoast" - log "github.com/sirupsen/logrus" -) - -const ( - // appID is the AppUserModelID shown in the Windows Action Center. It - // must match the System.AppUserModel.ID property set on the Start Menu - // shortcut by the MSI (see client/netbird.wxs); otherwise Windows - // groups toasts under a separate, unbranded entry. - appID = "NetBird" - - // appGUID identifies the COM activation callback class. Generated once - // for NetBird; do not change without coordinating an installer bump, - // since old registry entries pointing at the previous GUID would orphan. - appGUID = "{0E1B4DE7-E148-432B-9814-544F941826EC}" -) - -type comNotifier struct { - fallback *fyneNotifier - ready bool - iconPath string -} - -var ( - initOnce sync.Once - initErr error -) - -func newNotifier(app fyne.App) Notifier { - n := &comNotifier{ - fallback: &fyneNotifier{app: app}, - iconPath: resolveIcon(), - } - initOnce.Do(func() { - initErr = wintoast.SetAppData(wintoast.AppData{ - AppID: appID, - GUID: appGUID, - IconPath: n.iconPath, - }) - }) - if initErr != nil { - log.Warnf("toast: register app data failed, falling back to fyne notifications: %v", initErr) - return n.fallback - } - n.ready = true - return n -} - -func (n *comNotifier) Send(title, body string) { - if !n.ready { - n.fallback.Send(title, body) - return - } - notification := toast.Notification{ - AppID: appID, - Title: title, - Body: body, - Icon: n.iconPath, - } - if err := notification.Push(); err != nil { - log.Warnf("toast: push failed, using fyne fallback: %v", err) - n.fallback.Send(title, body) - } -} - -// resolveIcon returns an absolute path to the toast icon, or an empty string -// when no icon can be located. Windows requires a PNG/JPG for the -// AppUserModelId IconUri registry value; .ico is silently ignored. -func resolveIcon() string { - exe, err := os.Executable() - if err != nil { - return "" - } - candidate := filepath.Join(filepath.Dir(exe), "netbird.png") - if _, err := os.Stat(candidate); err == nil { - return candidate - } - return "" -} diff --git a/client/ui/preferences/store.go b/client/ui/preferences/store.go new file mode 100644 index 000000000..3b677016f --- /dev/null +++ b/client/ui/preferences/store.go @@ -0,0 +1,334 @@ +//go:build !android && !ios && !freebsd && !js + +// Package preferences holds user-scope UI state, independent of the daemon +// profile and shared across all profiles. The Store persists to JSON under +// os.UserConfigDir() and broadcasts changes to in-process subscribers plus an +// optional emitter. +package preferences + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/util" +) + +// Lives under os.UserConfigDir()/netbird (OS-user writable, not the daemon's +// root-owned state). +const preferencesFileName = "ui-preferences.json" + +// EventPreferencesChanged fires on every persisted update, payload UIPreferences. +const EventPreferencesChanged = "netbird:preferences:changed" + +// ViewMode is the preferred Main-window layout: "default" (compact, 380-wide) +// or "advanced" (900-wide). +type ViewMode string + +const ( + ViewModeDefault ViewMode = "default" + ViewModeAdvanced ViewMode = "advanced" +) + +// DefaultViewMode applies when no file exists or its view-mode is empty. +const DefaultViewMode = ViewModeDefault + +var ErrUnsupportedViewMode = errors.New("unsupported view mode") + +func (v ViewMode) IsValid() bool { + switch v { + case ViewModeDefault, ViewModeAdvanced: + return true + } + return false +} + +// UIPreferences is rewritten in full on every change; there are no partial updates. +type UIPreferences struct { + Language i18n.LanguageCode `json:"language"` + ViewMode ViewMode `json:"viewMode"` + OnboardingCompleted bool `json:"onboardingCompleted"` + // AutostartInitialized records that the one-time autostart default + // decision has run for this OS user. It only ever transitions to true + // and is never reset, so the default-on flow runs at most once, ever. + AutostartInitialized bool `json:"autostartInitialized"` + // KeepConnectedOnQuit leaves the daemon connected when the GUI quits. + // Its false zero value preserves the historical disconnect-on-quit + // behaviour for preference files written before the field existed. + KeepConnectedOnQuit bool `json:"keepConnectedOnQuit"` +} + +// LanguageValidator rejects SetLanguage inputs with no shipped bundle. +// *i18n.Bundle satisfies it. +type LanguageValidator interface { + HasLanguage(code i18n.LanguageCode) bool +} + +// Emitter broadcasts changes to the frontend. Wails' +// *application.EventProcessor satisfies it; tests pass nil or a fake. +type Emitter interface { + Emit(name string, data ...any) bool +} + +// Store is the user-scope UI preferences store. +type Store struct { + path string + + mu sync.RWMutex + current UIPreferences + existedAtLoad bool + + subsMu sync.Mutex + subs []chan UIPreferences + + validator LanguageValidator + emitter Emitter +} + +// NewStore loads preferences from disk, falling back to defaults. A nil +// validator skips SetLanguage validation; a nil emitter skips broadcasting. +func NewStore(validator LanguageValidator, emitter Emitter) (*Store, error) { + path, err := preferencesPath() + if err != nil { + return nil, fmt.Errorf("resolve preferences path: %w", err) + } + + // Language starts empty: the frontend treats absence as the signal to + // detect the browser locale on first launch and call SetLanguage. + s := &Store{ + path: path, + validator: validator, + emitter: emitter, + current: UIPreferences{ViewMode: DefaultViewMode}, + } + + if err := s.load(); err != nil { + log.Warnf("load ui preferences from %s: %v (using defaults)", path, err) + } + + return s, nil +} + +// Get returns a copy of the current preferences. +func (s *Store) Get() UIPreferences { + s.mu.RLock() + defer s.mu.RUnlock() + return s.current +} + +// SetViewMode validates, persists, and broadcasts. No-op if unchanged. +func (s *Store) SetViewMode(mode ViewMode) error { + if !mode.IsValid() { + return fmt.Errorf("%w: %q", ErrUnsupportedViewMode, mode) + } + + s.mu.Lock() + if s.current.ViewMode == mode { + s.mu.Unlock() + return nil + } + next := s.current + next.ViewMode = mode + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + +// SetOnboardingCompleted persists the welcome-window dismissal. No-op if unchanged. +func (s *Store) SetOnboardingCompleted(done bool) error { + s.mu.Lock() + if s.current.OnboardingCompleted == done { + s.mu.Unlock() + return nil + } + next := s.current + next.OnboardingCompleted = done + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + +// SetAutostartInitialized persists the one-time autostart decision marker. +// No-op if unchanged. +func (s *Store) SetAutostartInitialized(done bool) error { + s.mu.Lock() + if s.current.AutostartInitialized == done { + s.mu.Unlock() + return nil + } + next := s.current + next.AutostartInitialized = done + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + +// SetKeepConnectedOnQuit persists the disconnect-on-quit opt-out. No-op if unchanged. +func (s *Store) SetKeepConnectedOnQuit(keep bool) error { + s.mu.Lock() + if s.current.KeepConnectedOnQuit == keep { + s.mu.Unlock() + return nil + } + next := s.current + next.KeepConnectedOnQuit = keep + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + +// SetLanguage validates, persists, and broadcasts. No-op if unchanged. +func (s *Store) SetLanguage(lang i18n.LanguageCode) error { + if lang == "" { + return fmt.Errorf("%w: empty code", i18n.ErrUnsupportedLanguage) + } + if s.validator != nil && !s.validator.HasLanguage(lang) { + return fmt.Errorf("%w: %q", i18n.ErrUnsupportedLanguage, lang) + } + + s.mu.Lock() + if s.current.Language == lang { + s.mu.Unlock() + return nil + } + next := s.current + next.Language = lang + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + +// Subscribe returns a channel of persisted changes and an unsubscribe func. +// The unsubscribe func closes the channel; callers must not close it themselves. +func (s *Store) Subscribe() (<-chan UIPreferences, func()) { + ch := make(chan UIPreferences, 4) + s.subsMu.Lock() + s.subs = append(s.subs, ch) + s.subsMu.Unlock() + + unsubscribe := func() { + s.subsMu.Lock() + defer s.subsMu.Unlock() + for i, c := range s.subs { + if c == ch { + s.subs = append(s.subs[:i], s.subs[i+1:]...) + close(ch) + return + } + } + } + return ch, unsubscribe +} + +// ExistedAtLoad reports whether the backing preferences file was present on +// disk when the store loaded. It distinguishes a user who ran a prior GUI +// version from a brand-new OS user with no preferences yet. +func (s *Store) ExistedAtLoad() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.existedAtLoad +} + +// load reads the file into current. A missing file is not an error (the +// in-memory default stands); malformed contents return an error. +func (s *Store) load() error { + if _, err := os.Stat(s.path); err != nil { + if errors.Is(err, os.ErrNotExist) { + log.Infof("no ui preferences file at %s; using defaults", s.path) + return nil + } + return fmt.Errorf("stat preferences: %w", err) + } + + s.mu.Lock() + s.existedAtLoad = true + s.mu.Unlock() + + var loaded UIPreferences + if _, err := util.ReadJson(s.path, &loaded); err != nil { + return err + } + + if !loaded.ViewMode.IsValid() { + loaded.ViewMode = DefaultViewMode + } + + s.mu.Lock() + s.current = loaded + s.mu.Unlock() + return nil +} + +// persistLocked writes v to disk. Caller must hold s.mu and update in-memory +// state only after this returns nil. +func (s *Store) persistLocked(v UIPreferences) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(s.path), err) + } + return util.WriteJson(context.Background(), s.path, v) +} + +// broadcast fans v out to subscribers and the emitter. Full-buffer subscribers +// are skipped: consumers only need the latest value, so dropping is safe. +func (s *Store) broadcast(v UIPreferences) { + s.subsMu.Lock() + subs := make([]chan UIPreferences, len(s.subs)) + copy(subs, s.subs) + s.subsMu.Unlock() + + for _, ch := range subs { + select { + case ch <- v: + default: + log.Debugf("preferences subscriber channel full; dropping update") + } + } + + if s.emitter != nil { + s.emitter.Emit(EventPreferencesChanged, v) + } +} + +func preferencesPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "netbird", preferencesFileName), nil +} diff --git a/client/ui/preferences/store_test.go b/client/ui/preferences/store_test.go new file mode 100644 index 000000000..3e1cb3107 --- /dev/null +++ b/client/ui/preferences/store_test.go @@ -0,0 +1,301 @@ +//go:build !android && !ios && !freebsd && !js + +package preferences + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/ui/i18n" +) + +// fakeValidator implements LanguageValidator for tests so we don't need a +// fully-loaded i18n.Bundle. +type fakeValidator struct{ ok map[i18n.LanguageCode]bool } + +func (f fakeValidator) HasLanguage(code i18n.LanguageCode) bool { return f.ok[code] } + +// recordingEmitter captures Emit calls so tests can assert the broadcast +// fired. +type recordingEmitter struct { + mu sync.Mutex + calls []emitCall +} + +type emitCall struct { + name string + data []any +} + +func (r *recordingEmitter) Emit(name string, data ...any) bool { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, emitCall{name: name, data: data}) + return true +} + +func (r *recordingEmitter) calledWith(name string) []emitCall { + r.mu.Lock() + defer r.mu.Unlock() + var out []emitCall + for _, c := range r.calls { + if c.name == name { + out = append(out, c) + } + } + return out +} + +// withTempConfigDir reroots os.UserConfigDir() at a temporary directory by +// pointing the OS-specific env vars there. Restored automatically by +// t.Setenv. +func withTempConfigDir(t *testing.T) string { + t.Helper() + tmp := t.TempDir() + switch runtime.GOOS { + case "darwin": + t.Setenv("HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "Library", "Application Support"), 0o755)) + case "windows": + t.Setenv("AppData", tmp) + default: + t.Setenv("XDG_CONFIG_HOME", tmp) + } + return tmp +} + +func TestStore_DefaultsWhenFileMissing(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil) + require.NoError(t, err) + + got := s.Get() + assert.Equal(t, i18n.LanguageCode(""), got.Language, "language must be empty when no file is on disk so the frontend can detect the browser locale") + assert.Equal(t, DefaultViewMode, got.ViewMode, "view-mode default should still apply") +} + +func TestStore_SetLanguagePersistsAndBroadcasts(t *testing.T) { + withTempConfigDir(t) + emitter := &recordingEmitter{} + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true, "hu": true}}, emitter) + require.NoError(t, err) + + ch, unsubscribe := s.Subscribe() + defer unsubscribe() + + require.NoError(t, s.SetLanguage("hu")) + + got := s.Get() + assert.Equal(t, i18n.LanguageCode("hu"), got.Language, "Get should reflect the SetLanguage value") + + select { + case v := <-ch: + assert.Equal(t, i18n.LanguageCode("hu"), v.Language, "subscriber should receive the new value") + case <-time.After(time.Second): + t.Fatal("subscriber timed out waiting for update") + } + + emits := emitter.calledWith(EventPreferencesChanged) + require.Len(t, emits, 1, "Emit should fire exactly once per SetLanguage") + payload, ok := emits[0].data[0].(UIPreferences) + require.True(t, ok, "emitter payload should be UIPreferences") + assert.Equal(t, i18n.LanguageCode("hu"), payload.Language) +} + +func TestStore_LoadFromDisk(t *testing.T) { + withTempConfigDir(t) + path, err := preferencesPath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(`{"language":"hu"}`), 0o644)) + + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"hu": true}}, nil) + require.NoError(t, err) + + got := s.Get() + assert.Equal(t, i18n.LanguageCode("hu"), got.Language, "Get should load language from existing file") +} + +func TestStore_UnsupportedLanguageRejected(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil) + require.NoError(t, err) + + err = s.SetLanguage("xx") + require.Error(t, err, "unknown language must be rejected") + assert.ErrorIs(t, err, i18n.ErrUnsupportedLanguage) + + err = s.SetLanguage("") + assert.ErrorIs(t, err, i18n.ErrUnsupportedLanguage, "empty language code must be rejected") +} + +func TestStore_NoValidatorAcceptsAnything(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(nil, nil) + require.NoError(t, err) + + require.NoError(t, s.SetLanguage("fr")) + got := s.Get() + assert.Equal(t, i18n.LanguageCode("fr"), got.Language) +} + +func TestStore_SetLanguageIdempotent(t *testing.T) { + withTempConfigDir(t) + emitter := &recordingEmitter{} + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, emitter) + require.NoError(t, err) + + // First call goes from "" (unset) to "en" — real change, one broadcast. + require.NoError(t, s.SetLanguage("en")) + require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, + "first SetLanguage from unset should broadcast") + + // Second call is a no-op — no disk write, no broadcast. Without this + // guard the tray would re-render the menu on every cosmetic re-save of + // the preferences file. + require.NoError(t, s.SetLanguage("en")) + assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, + "re-setting the current language should not broadcast again") +} + +func TestStore_CorruptFileFallsBackToDefault(t *testing.T) { + withTempConfigDir(t) + path, err := preferencesPath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("{not json"), 0o644)) + + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true}}, nil) + require.NoError(t, err, "corrupt file should not fail construction") + + got := s.Get() + assert.Equal(t, i18n.LanguageCode(""), got.Language, "corrupt JSON should leave the empty (unset) default in place so the frontend can re-detect") +} + +func TestStore_UnsubscribeStopsUpdates(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"en": true, "hu": true}}, nil) + require.NoError(t, err) + + ch, unsubscribe := s.Subscribe() + unsubscribe() + + require.NoError(t, s.SetLanguage("hu")) + + select { + case _, ok := <-ch: + assert.False(t, ok, "channel should be closed after unsubscribe") + case <-time.After(time.Second): + t.Fatal("expected closed channel, got nothing") + } +} + +func TestStore_FileShapeIsJSON(t *testing.T) { + withTempConfigDir(t) + s, err := NewStore(fakeValidator{ok: map[i18n.LanguageCode]bool{"hu": true}}, nil) + require.NoError(t, err) + require.NoError(t, s.SetLanguage("hu")) + + path, err := preferencesPath() + require.NoError(t, err) + data, err := os.ReadFile(path) + require.NoError(t, err) + + var parsed UIPreferences + require.NoError(t, json.Unmarshal(data, &parsed), "on-disk file must be valid JSON") + assert.Equal(t, i18n.LanguageCode("hu"), parsed.Language) +} + +func TestStore_SetAutostartInitializedPersistsAcrossReload(t *testing.T) { + withTempConfigDir(t) + emitter := &recordingEmitter{} + s, err := NewStore(nil, emitter) + require.NoError(t, err) + + assert.False(t, s.Get().AutostartInitialized, "marker must default to false when no file is on disk") + + require.NoError(t, s.SetAutostartInitialized(true)) + assert.True(t, s.Get().AutostartInitialized, "Get should reflect the persisted marker") + require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first marker write should broadcast") + + // Re-setting the same value must be a no-op: no disk write, no broadcast. + require.NoError(t, s.SetAutostartInitialized(true)) + assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent marker write should not broadcast again") + + // A fresh Store (new GUI launch) must see the marker so the autostart + // default decision never runs twice. + reloaded, err := NewStore(nil, nil) + require.NoError(t, err) + assert.True(t, reloaded.Get().AutostartInitialized, "marker must survive a reload from disk") +} + +func TestStore_SetKeepConnectedOnQuitPersistsAcrossReload(t *testing.T) { + withTempConfigDir(t) + emitter := &recordingEmitter{} + s, err := NewStore(nil, emitter) + require.NoError(t, err) + + assert.False(t, s.Get().KeepConnectedOnQuit, "quitting must disconnect by default") + + require.NoError(t, s.SetKeepConnectedOnQuit(true)) + assert.True(t, s.Get().KeepConnectedOnQuit, "Get should reflect the persisted opt-out") + require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first write should broadcast") + + require.NoError(t, s.SetKeepConnectedOnQuit(true)) + assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent write should not broadcast again") + + reloaded, err := NewStore(nil, nil) + require.NoError(t, err) + assert.True(t, reloaded.Get().KeepConnectedOnQuit, "opt-out must survive a reload from disk") +} + +func TestStore_KeepConnectedOnQuitDefaultsFalseForPreExistingFile(t *testing.T) { + withTempConfigDir(t) + + // A preferences file written before the field existed must keep the + // historical disconnect-on-quit behaviour rather than silently opting out. + path, err := preferencesPath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(`{"language":"en","viewMode":"default"}`), 0o600)) + + s, err := NewStore(nil, nil) + require.NoError(t, err) + assert.False(t, s.Get().KeepConnectedOnQuit, "a file predating the field must not opt out of disconnect-on-quit") + assert.True(t, s.ExistedAtLoad(), "the pre-existing file must be seen on disk") +} + +func TestStore_ExistedAtLoad(t *testing.T) { + withTempConfigDir(t) + + // Brand-new OS user: no preferences file on disk yet. + fresh, err := NewStore(nil, nil) + require.NoError(t, err) + assert.False(t, fresh.ExistedAtLoad(), "ExistedAtLoad must be false when no file is on disk") + + // Persisting a value writes the file to disk. + require.NoError(t, fresh.SetLanguage("en")) + + // A subsequent GUI launch reopens the now-present file. + reopened, err := NewStore(nil, nil) + require.NoError(t, err) + assert.True(t, reopened.ExistedAtLoad(), "ExistedAtLoad must be true after the store has persisted and is reopened") +} + +func TestStore_ErrUnsupportedSentinel(t *testing.T) { + // Verifies callers can match on the sentinel error rather than parsing + // strings — protects against accidental %v -> %w changes that would + // silently break errors.Is. + err := errors.New("inner") + wrapped := errors.Join(i18n.ErrUnsupportedLanguage, err) + assert.ErrorIs(t, wrapped, i18n.ErrUnsupportedLanguage) +} diff --git a/client/ui/process/process.go b/client/ui/process/process.go deleted file mode 100644 index 28276f416..000000000 --- a/client/ui/process/process.go +++ /dev/null @@ -1,38 +0,0 @@ -package process - -import ( - "os" - "path/filepath" - "strings" - - "github.com/shirou/gopsutil/v3/process" -) - -func IsAnotherProcessRunning() (int32, bool, error) { - processes, err := process.Processes() - if err != nil { - return 0, false, err - } - - pid := os.Getpid() - processName := strings.ToLower(filepath.Base(os.Args[0])) - - for _, p := range processes { - if int(p.Pid) == pid { - continue - } - - runningProcessPath, err := p.Exe() - // most errors are related to short-lived processes - if err != nil { - continue - } - - runningProcessName := strings.ToLower(filepath.Base(runningProcessPath)) - if runningProcessName == processName && isProcessOwnedByCurrentUser(p) { - return p.Pid, true, nil - } - } - - return 0, false, nil -} diff --git a/client/ui/process/process_nonwindows.go b/client/ui/process/process_nonwindows.go deleted file mode 100644 index cf9f6443d..000000000 --- a/client/ui/process/process_nonwindows.go +++ /dev/null @@ -1,26 +0,0 @@ -//go:build !windows - -package process - -import ( - "os" - - "github.com/shirou/gopsutil/v3/process" - log "github.com/sirupsen/logrus" -) - -func isProcessOwnedByCurrentUser(p *process.Process) bool { - currentUserID := os.Getuid() - uids, err := p.Uids() - if err != nil { - log.Errorf("get process uids: %v", err) - return false - } - for _, id := range uids { - log.Debugf("checking process uid: %d", id) - if int(id) == currentUserID { - return true - } - } - return false -} diff --git a/client/ui/process/process_windows.go b/client/ui/process/process_windows.go deleted file mode 100644 index 2d211d1a4..000000000 --- a/client/ui/process/process_windows.go +++ /dev/null @@ -1,24 +0,0 @@ -package process - -import ( - "os/user" - - "github.com/shirou/gopsutil/v3/process" - log "github.com/sirupsen/logrus" -) - -func isProcessOwnedByCurrentUser(p *process.Process) bool { - processUsername, err := p.Username() - if err != nil { - log.Errorf("get process username error: %v", err) - return false - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user error: %v", err) - return false - } - - return processUsername == currUser.Username -} diff --git a/client/ui/profile.go b/client/ui/profile.go deleted file mode 100644 index 83b0ec18b..000000000 --- a/client/ui/profile.go +++ /dev/null @@ -1,775 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "errors" - "fmt" - "os/user" - "slices" - "sort" - "sync" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" - "fyne.io/systray" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/proto" -) - -// showProfilesUI creates and displays the Profiles window with a list of existing profiles, -// a button to add new profiles, allows removal, and lets the user switch the active profile. -func (s *serviceClient) showProfilesUI() { - - profiles, err := s.getProfiles() - if err != nil { - log.Errorf("get profiles: %v", err) - return - } - - var refresh func() - // List widget for profiles - list := widget.NewList( - func() int { return len(profiles) }, - func() fyne.CanvasObject { - // Each item: Selected indicator, Name, spacer, Select, Logout & Remove buttons - return container.NewHBox( - widget.NewLabel(""), // indicator - widget.NewLabel(""), // profile name - layout.NewSpacer(), - widget.NewButton("Select", nil), - widget.NewButton("Deregister", nil), - widget.NewButton("Remove", nil), - ) - }, - func(i widget.ListItemID, item fyne.CanvasObject) { - // Populate each row - row := item.(*fyne.Container) - indicator := row.Objects[0].(*widget.Label) - nameLabel := row.Objects[1].(*widget.Label) - selectBtn := row.Objects[3].(*widget.Button) - logoutBtn := row.Objects[4].(*widget.Button) - removeBtn := row.Objects[5].(*widget.Button) - - profile := profiles[i] - // Show a checkmark if selected - if profile.IsActive { - indicator.SetText("✓") - } else { - indicator.SetText("") - } - nameLabel.SetText(formatProfileLabel(profile, profiles)) - - // Configure Select/Active button - selectBtn.SetText(func() string { - if profile.IsActive { - return "Active" - } - return "Select" - }()) - selectBtn.OnTapped = func() { - if profile.IsActive { - return // already active - } - // confirm switch - dialog.ShowConfirm( - "Switch Profile", - fmt.Sprintf("Are you sure you want to switch to '%s'?", profile.Name), - func(confirm bool) { - if !confirm { - return - } - // switch - 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) - return - } - - dialog.ShowInformation( - "Profile Switched", - fmt.Sprintf("Profile '%s' switched successfully", profile.Name), - s.wProfiles, - ) - - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get daemon client: %v", err) - return - } - - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("failed to get status after switching profile: %v", err) - return - } - - if status.Status == string(internal.StatusConnected) { - if err := s.menuDownClick(); err != nil { - log.Errorf("failed to handle down click after switching profile: %v", err) - dialog.ShowError(fmt.Errorf("failed to handle down click"), s.wProfiles) - return - } - } - // update slice flags - refresh() - }, - s.wProfiles, - ) - } - - logoutBtn.Show() - logoutBtn.SetText("Deregister") - logoutBtn.OnTapped = func() { - s.handleProfileLogout(profile, refresh) - } - - // Remove profile - removeBtn.SetText("Remove") - removeBtn.OnTapped = func() { - dialog.ShowConfirm( - "Delete Profile", - fmt.Sprintf("Are you sure you want to delete '%s'?", profile.Name), - func(confirm bool) { - if !confirm { - return - } - - 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) - return - } - dialog.ShowInformation( - "Profile Removed", - fmt.Sprintf("Profile '%s' removed successfully", profile.Name), - s.wProfiles, - ) - // update slice - refresh() - }, - s.wProfiles, - ) - } - }, - ) - - refresh = func() { - newProfiles, err := s.getProfiles() - if err != nil { - dialog.ShowError(err, s.wProfiles) - return - } - profiles = newProfiles // update the slice - list.Refresh() // tell Fyne to re-call length/update on every visible row - } - - // Button to add a new profile - newBtn := widget.NewButton("New Profile", func() { - nameEntry := widget.NewEntry() - nameEntry.SetPlaceHolder("Enter Profile Name") - - formItems := []*widget.FormItem{{Text: "Name:", Widget: nameEntry}} - dlg := dialog.NewForm( - "New Profile", - "Create", - "Cancel", - formItems, - func(confirm bool) { - if !confirm { - return - } - name := nameEntry.Text - if name == "" { - dialog.ShowError(errors.New("profile name cannot be empty"), s.wProfiles) - return - } - - // add profile - err = s.addProfile(name) - if err != nil { - log.Errorf("failed to create profile: %v", err) - dialog.ShowError(fmt.Errorf("failed to create profile"), s.wProfiles) - return - } - dialog.ShowInformation( - "Profile Created", - fmt.Sprintf("Profile '%s' created successfully", name), - s.wProfiles, - ) - // update slice - refresh() - }, - s.wProfiles, - ) - // make dialog wider - dlg.Resize(fyne.NewSize(350, 150)) - dlg.Show() - }) - - // Assemble window content - content := container.NewBorder(nil, newBtn, nil, nil, list) - s.wProfiles = s.app.NewWindow("NetBird Profiles") - s.wProfiles.SetContent(content) - s.wProfiles.Resize(fyne.NewSize(400, 300)) - s.wProfiles.SetOnClosed(s.cancel) - - s.wProfiles.Show() -} - -func (s *serviceClient) addProfile(profileName string) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return fmt.Errorf("get current user: %w", err) - } - - _, err = conn.AddProfile(s.ctx, &proto.AddProfileRequest{ - ProfileName: profileName, - Username: currUser.Username, - }) - - if err != nil { - return fmt.Errorf("add profile: %w", err) - } - - return nil -} - -func (s *serviceClient) switchProfile(handle string) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return fmt.Errorf("get current user: %w", err) - } - - resp, err := conn.SwitchProfile(s.ctx, &proto.SwitchProfileRequest{ - ProfileName: &handle, - Username: &currUser.Username, - }) - if err != nil { - return fmt.Errorf("switch profile failed: %w", err) - } - - if err := s.profileManager.SwitchProfile(profilemanager.ID(resp.Id)); err != nil { - return fmt.Errorf("switch profile: %w", err) - } - - return nil -} - -func (s *serviceClient) removeProfile(profileName string) error { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - return fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return fmt.Errorf("get current user: %w", err) - } - - _, err = conn.RemoveProfile(s.ctx, &proto.RemoveProfileRequest{ - ProfileName: profileName, - Username: currUser.Username, - }) - if err != nil { - return fmt.Errorf("remove profile: %w", err) - } - - return nil -} - -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 { - return nil, fmt.Errorf(getClientFMT, err) - } - - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - profilesResp, err := conn.ListProfiles(s.ctx, &proto.ListProfilesRequest{ - Username: currUser.Username, - }) - if err != nil { - return nil, fmt.Errorf("list profiles: %w", err) - } - - var profiles []Profile - - for _, profile := range profilesResp.Profiles { - profiles = append(profiles, Profile{ - ID: profile.Id, - Name: profile.Name, - IsActive: profile.IsActive, - }) - } - - return profiles, nil -} - -func (s *serviceClient) handleProfileLogout(profile Profile, refreshCallback func()) { - dialog.ShowConfirm( - "Deregister", - fmt.Sprintf("Are you sure you want to deregister from '%s'?", profile.Name), - func(confirm bool) { - if !confirm { - return - } - - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get service client: %v", err) - dialog.ShowError(fmt.Errorf("failed to connect to service"), s.wProfiles) - return - } - - currUser, err := user.Current() - if err != nil { - log.Errorf("failed to get current user: %v", err) - dialog.ShowError(fmt.Errorf("failed to get current user"), s.wProfiles) - return - } - - 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: &profile.ID, - Username: &username, - }) - if err != nil { - log.Errorf("logout failed: %v", err) - dialog.ShowError(fmt.Errorf("deregister failed"), s.wProfiles) - return - } - - dialog.ShowInformation( - "Deregistered", - fmt.Sprintf("Successfully deregistered from '%s'", profile.Name), - s.wProfiles, - ) - - refreshCallback() - }, - s.wProfiles, - ) -} - -type subItem struct { - *systray.MenuItem - ctx context.Context - cancel context.CancelFunc -} - -type profileMenu struct { - mu sync.Mutex - ctx context.Context - serviceClient *serviceClient - profileManager *profilemanager.ProfileManager - eventHandler *eventHandler - profileMenuItem *systray.MenuItem - emailMenuItem *systray.MenuItem - profileSubItems []*subItem - manageProfilesSubItem *subItem - logoutSubItem *subItem - profilesState []Profile - downClickCallback func() error - upClickCallback func(context.Context) error - getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error) - loadSettingsCallback func() - app fyne.App -} - -type newProfileMenuArgs struct { - ctx context.Context - serviceClient *serviceClient - profileManager *profilemanager.ProfileManager - eventHandler *eventHandler - profileMenuItem *systray.MenuItem - emailMenuItem *systray.MenuItem - downClickCallback func() error - upClickCallback func(context.Context) error - getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error) - loadSettingsCallback func() - app fyne.App -} - -func newProfileMenu(args newProfileMenuArgs) *profileMenu { - p := profileMenu{ - ctx: args.ctx, - serviceClient: args.serviceClient, - profileManager: args.profileManager, - eventHandler: args.eventHandler, - profileMenuItem: args.profileMenuItem, - emailMenuItem: args.emailMenuItem, - downClickCallback: args.downClickCallback, - upClickCallback: args.upClickCallback, - getSrvClientCallback: args.getSrvClientCallback, - loadSettingsCallback: args.loadSettingsCallback, - app: args.app, - } - - p.emailMenuItem.Disable() - p.emailMenuItem.Hide() - p.refresh() - go p.updateMenu() - - return &p -} - -func (p *profileMenu) getProfiles() ([]Profile, error) { - conn, err := p.getSrvClientCallback(defaultFailTimeout) - if err != nil { - return nil, fmt.Errorf(getClientFMT, err) - } - currUser, err := user.Current() - if err != nil { - return nil, fmt.Errorf("get current user: %w", err) - } - - profilesResp, err := conn.ListProfiles(p.ctx, &proto.ListProfilesRequest{ - Username: currUser.Username, - }) - if err != nil { - return nil, fmt.Errorf("list profiles: %w", err) - } - - var profiles []Profile - - for _, profile := range profilesResp.Profiles { - profiles = append(profiles, Profile{ - ID: profile.Id, - Name: profile.Name, - IsActive: profile.IsActive, - }) - } - - return profiles, nil -} - -func (p *profileMenu) refresh() { - p.mu.Lock() - defer p.mu.Unlock() - - profiles, err := p.getProfiles() - if err != nil { - log.Errorf("failed to list profiles: %v", err) - return - } - - // Clear existing profile items - p.clear(profiles) - - currUser, err := user.Current() - if err != nil { - log.Errorf("failed to get current user: %v", err) - return - } - - conn, err := p.getSrvClientCallback(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get daemon client: %v", err) - return - } - - activeProf, err := conn.GetActiveProfile(p.ctx, &proto.GetActiveProfileRequest{}) - if err != nil { - log.Errorf("failed to get active profile: %v", err) - return - } - - if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username { - 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() - } else if activeProfState.Email != "" { - p.emailMenuItem.SetTitle(fmt.Sprintf("(%s)", activeProfState.Email)) - p.emailMenuItem.Show() - } - } - - for _, profile := range profiles { - item := p.profileMenuItem.AddSubMenuItem(formatProfileLabel(profile, profiles), "") - if profile.IsActive { - item.Check() - } - - ctx, cancel := context.WithCancel(context.Background()) - p.profileSubItems = append(p.profileSubItems, &subItem{item, ctx, cancel}) - - go func() { - for { - select { - case <-ctx.Done(): - return // context cancelled - case _, ok := <-item.ClickedCh: - if !ok { - return // channel closed - } - - // Handle profile selection - if profile.IsActive { - log.Infof("Profile '%s' is already active", profile.Name) - return - } - conn, err := p.getSrvClientCallback(defaultFailTimeout) - if err != nil { - log.Errorf("failed to get daemon client: %v", err) - return - } - - switchResp, err := conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{ - ProfileName: &profile.ID, - Username: &currUser.Username, - }) - if err != nil { - log.Errorf("failed to switch profile: %v", err) - // show notification dialog - p.serviceClient.notifier.Send("Error", "Failed to switch profile") - return - } - - err = p.profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)) - if err != nil { - log.Errorf("failed to switch profile '%s': %v", profile.Name, err) - return - } - - log.Infof("Switched to profile '%s'", profile.Name) - - status, err := conn.Status(ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("failed to get status after switching profile: %v", err) - return - } - - if status.Status == string(internal.StatusConnected) { - if err := p.downClickCallback(); err != nil { - log.Errorf("failed to handle down click after switching profile: %v", err) - } - } - - if p.serviceClient.connectCancel != nil { - p.serviceClient.connectCancel() - } - - connectCtx, connectCancel := context.WithCancel(p.ctx) - p.serviceClient.connectCancel = connectCancel - - if err := p.upClickCallback(connectCtx); err != nil { - log.Errorf("failed to handle up click after switching profile: %v", err) - } - - connectCancel() - - p.refresh() - p.loadSettingsCallback() - } - } - }() - - } - ctx, cancel := context.WithCancel(context.Background()) - manageItem := p.profileMenuItem.AddSubMenuItem("Manage Profiles", "") - p.manageProfilesSubItem = &subItem{manageItem, ctx, cancel} - - go func() { - for { - select { - case <-ctx.Done(): - return - case _, ok := <-manageItem.ClickedCh: - if !ok { - return - } - p.eventHandler.runSelfCommand(p.ctx, "profiles", "true") - p.refresh() - p.loadSettingsCallback() - } - } - }() - - // Add Logout menu item - ctx2, cancel2 := context.WithCancel(context.Background()) - logoutItem := p.profileMenuItem.AddSubMenuItem("Deregister", "") - p.logoutSubItem = &subItem{logoutItem, ctx2, cancel2} - - go func() { - for { - select { - case <-ctx2.Done(): - return - case _, ok := <-logoutItem.ClickedCh: - if !ok { - return - } - if err := p.eventHandler.logout(p.ctx); err != nil { - log.Errorf("logout failed: %v", err) - p.serviceClient.notifier.Send("Error", "Failed to deregister") - } else { - p.serviceClient.notifier.Send("Success", "Deregistered successfully") - } - } - } - }() - - if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username { - p.profileMenuItem.SetTitle(activeProf.ProfileName) - } else { - p.profileMenuItem.SetTitle(fmt.Sprintf("Profile: %s (User: %s)", activeProf.ProfileName, activeProf.Username)) - p.emailMenuItem.Hide() - } - -} - -func (p *profileMenu) clear(profiles []Profile) { - for _, item := range p.profileSubItems { - item.Remove() - item.cancel() - } - p.profileSubItems = make([]*subItem, 0, len(profiles)) - p.profilesState = profiles - - if p.manageProfilesSubItem != nil { - p.manageProfilesSubItem.Remove() - p.manageProfilesSubItem.cancel() - p.manageProfilesSubItem = nil - } - - if p.logoutSubItem != nil { - p.logoutSubItem.Remove() - p.logoutSubItem.cancel() - p.logoutSubItem = nil - } -} - -// setEnabled greys out (Disable) the profile menu and every existing -// sub-item when the daemon reports the kill switch active, so the user -// sees the menu but cannot enter "Manage Profiles" or switch profile. -// Previously this used Hide() on the parent, but Fyne's systray on -// Windows does not propagate Hide() to a parent that already has -// children — the submenu kept popping up and accepting clicks. Disable -// is the reliable visual lock. -func (p *profileMenu) setEnabled(enabled bool) { - if p.profileMenuItem == nil { - return - } - p.mu.Lock() - defer p.mu.Unlock() - - if enabled { - p.profileMenuItem.Enable() - p.profileMenuItem.SetTooltip("") - } else { - p.profileMenuItem.Disable() - p.profileMenuItem.SetTooltip("Profiles are disabled by daemon") - } - - apply := func(item *systray.MenuItem) { - if item == nil { - return - } - if enabled { - item.Enable() - } else { - item.Disable() - } - } - for _, sub := range p.profileSubItems { - if sub != nil { - apply(sub.MenuItem) - } - } - if p.manageProfilesSubItem != nil { - apply(p.manageProfilesSubItem.MenuItem) - } - if p.logoutSubItem != nil { - apply(p.logoutSubItem.MenuItem) - } -} - -func (p *profileMenu) updateMenu() { - // check every second - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - // get profilesList - profiles, err := p.getProfiles() - if err != nil { - log.Errorf("failed to list profiles: %v", err) - continue - } - - sort.Slice(profiles, func(i, j int) bool { - if profiles[i].Name != profiles[j].Name { - return profiles[i].Name < profiles[j].Name - } - return profiles[i].ID < profiles[j].ID - }) - - p.mu.Lock() - state := p.profilesState - p.mu.Unlock() - - sort.Slice(state, func(i, j int) bool { - return state[i].Name < state[j].Name - }) - - if slices.Equal(profiles, state) { - continue - } - - p.refresh() - case <-p.ctx.Done(): - return // context cancelled - - } - } -} diff --git a/client/ui/quickactions.go b/client/ui/quickactions.go deleted file mode 100644 index bf47ac434..000000000 --- a/client/ui/quickactions.go +++ /dev/null @@ -1,349 +0,0 @@ -//go:build !(linux && 386) - -//go:generate fyne bundle -o quickactions_assets.go assets/connected.png -//go:generate fyne bundle -o quickactions_assets.go -append assets/disconnected.png -package main - -import ( - "context" - _ "embed" - "fmt" - "runtime" - "sync/atomic" - "time" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/widget" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/proto" -) - -type quickActionsUiState struct { - connectionStatus string - isToggleButtonEnabled bool - isConnectionChanged bool - toggleAction func() -} - -func newQuickActionsUiState() quickActionsUiState { - return quickActionsUiState{ - connectionStatus: string(internal.StatusIdle), - isToggleButtonEnabled: false, - isConnectionChanged: false, - } -} - -type clientConnectionStatusProvider interface { - connectionStatus(ctx context.Context) (string, error) -} - -type daemonClientConnectionStatusProvider struct { - client proto.DaemonServiceClient -} - -func (d daemonClientConnectionStatusProvider) connectionStatus(ctx context.Context) (string, error) { - childCtx, cancel := context.WithTimeout(ctx, 400*time.Millisecond) - defer cancel() - status, err := d.client.Status(childCtx, &proto.StatusRequest{}) - if err != nil { - return "", err - } - - return status.Status, nil -} - -type clientCommand interface { - execute() error -} - -type connectCommand struct { - connectClient func() error -} - -func (c connectCommand) execute() error { - return c.connectClient() -} - -type disconnectCommand struct { - disconnectClient func() error -} - -func (c disconnectCommand) execute() error { - return c.disconnectClient() -} - -type quickActionsViewModel struct { - provider clientConnectionStatusProvider - connect clientCommand - disconnect clientCommand - uiChan chan quickActionsUiState - isWatchingConnectionStatus atomic.Bool -} - -func newQuickActionsViewModel(ctx context.Context, provider clientConnectionStatusProvider, connect, disconnect clientCommand, uiChan chan quickActionsUiState) { - viewModel := quickActionsViewModel{ - provider: provider, - connect: connect, - disconnect: disconnect, - uiChan: uiChan, - } - - viewModel.isWatchingConnectionStatus.Store(true) - - // base UI status - uiChan <- newQuickActionsUiState() - - // this retrieves the current connection status - // and pushes the UI state that reflects it via uiChan - go viewModel.watchConnectionStatus(ctx) -} - -func (q *quickActionsViewModel) updateUiState(ctx context.Context) { - uiState := newQuickActionsUiState() - connectionStatus, err := q.provider.connectionStatus(ctx) - - if err != nil { - log.Errorf("Status: Error - %v", err) - q.uiChan <- uiState - return - } - - if connectionStatus == string(internal.StatusConnected) { - uiState.toggleAction = func() { - q.executeCommand(q.disconnect) - } - } else { - uiState.toggleAction = func() { - q.executeCommand(q.connect) - } - } - - uiState.isToggleButtonEnabled = true - uiState.connectionStatus = connectionStatus - q.uiChan <- uiState -} - -func (q *quickActionsViewModel) watchConnectionStatus(ctx context.Context) { - ticker := time.NewTicker(1000 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if q.isWatchingConnectionStatus.Load() { - q.updateUiState(ctx) - } - } - } -} - -func (q *quickActionsViewModel) executeCommand(command clientCommand) { - uiState := newQuickActionsUiState() - // newQuickActionsUiState starts with Idle connection status, - // and all that's necessary here is to just disable the toggle button. - uiState.connectionStatus = "" - - q.uiChan <- uiState - - q.isWatchingConnectionStatus.Store(false) - - err := command.execute() - - if err != nil { - log.Errorf("Status: Error - %v", err) - q.isWatchingConnectionStatus.Store(true) - } else { - uiState = newQuickActionsUiState() - uiState.isConnectionChanged = true - q.uiChan <- uiState - } -} - -func getSystemTrayName() string { - os := runtime.GOOS - switch os { - case "darwin": - return "menu bar" - default: - return "system tray" - } -} - -func (s *serviceClient) getNetBirdImage(name string, content []byte) *canvas.Image { - imageSize := fyne.NewSize(64, 64) - - resource := fyne.NewStaticResource(name, content) - image := canvas.NewImageFromResource(resource) - image.FillMode = canvas.ImageFillContain - image.SetMinSize(imageSize) - image.Resize(imageSize) - - return image -} - -type quickActionsUiComponents struct { - content *fyne.Container - toggleConnectionButton *widget.Button - connectedLabelText, disconnectedLabelText string - connectedImage, disconnectedImage *canvas.Image - connectedCircleRes, disconnectedCircleRes fyne.Resource -} - -// applyQuickActionsUiState applies a single UI state to the quick actions window. -// It closes the window and returns true if the connection status has changed, -// in which case the caller should stop processing further states. -func (s *serviceClient) applyQuickActionsUiState( - uiState quickActionsUiState, - components quickActionsUiComponents, -) bool { - if uiState.isConnectionChanged { - fyne.DoAndWait(func() { - s.wQuickActions.Close() - }) - return true - } - - var logo *canvas.Image - var buttonText string - var buttonIcon fyne.Resource - - if uiState.connectionStatus == string(internal.StatusConnected) { - buttonText = components.connectedLabelText - buttonIcon = components.connectedCircleRes - logo = components.connectedImage - } else if uiState.connectionStatus == string(internal.StatusIdle) { - buttonText = components.disconnectedLabelText - buttonIcon = components.disconnectedCircleRes - logo = components.disconnectedImage - } - - fyne.DoAndWait(func() { - if buttonText != "" { - components.toggleConnectionButton.SetText(buttonText) - } - - if buttonIcon != nil { - components.toggleConnectionButton.SetIcon(buttonIcon) - } - - if uiState.isToggleButtonEnabled { - components.toggleConnectionButton.Enable() - } else { - components.toggleConnectionButton.Disable() - } - - components.toggleConnectionButton.OnTapped = func() { - if uiState.toggleAction != nil { - go uiState.toggleAction() - } - } - - components.toggleConnectionButton.Refresh() - - // the second position in the content's object array is the NetBird logo. - if logo != nil { - components.content.Objects[1] = logo - components.content.Refresh() - } - }) - - return false -} - -// showQuickActionsUI displays a simple window with the NetBird logo and a connection toggle button. -func (s *serviceClient) showQuickActionsUI() { - s.wQuickActions = s.app.NewWindow("NetBird") - vmCtx, vmCancel := context.WithCancel(s.ctx) - s.wQuickActions.SetOnClosed(vmCancel) - - client, err := s.getSrvClient(defaultFailTimeout) - - connCmd := connectCommand{ - connectClient: func() error { - return s.menuUpClick(s.ctx) - }, - } - - disConnCmd := disconnectCommand{ - disconnectClient: func() error { - return s.menuDownClick() - }, - } - - if err != nil { - log.Errorf("get service client: %v", err) - return - } - - uiChan := make(chan quickActionsUiState, 1) - newQuickActionsViewModel(vmCtx, daemonClientConnectionStatusProvider{client: client}, connCmd, disConnCmd, uiChan) - - connectedImage := s.getNetBirdImage("netbird.png", iconAbout) - disconnectedImage := s.getNetBirdImage("netbird-disconnected.png", iconAboutDisconnected) - - connectedCircle := canvas.NewImageFromResource(resourceConnectedPng) - disconnectedCircle := canvas.NewImageFromResource(resourceDisconnectedPng) - - connectedLabelText := "Disconnect" - disconnectedLabelText := "Connect" - - toggleConnectionButton := widget.NewButtonWithIcon(disconnectedLabelText, disconnectedCircle.Resource, func() { - // This button's tap function will be set when an ui state arrives via the uiChan channel. - }) - - // Button starts disabled until the first ui state arrives. - toggleConnectionButton.Disable() - - hintLabelText := fmt.Sprintf("You can always access NetBird from your %s.", getSystemTrayName()) - hintLabel := widget.NewLabel(hintLabelText) - - content := container.NewVBox( - layout.NewSpacer(), - disconnectedImage, - layout.NewSpacer(), - container.NewCenter(toggleConnectionButton), - layout.NewSpacer(), - container.NewCenter(hintLabel), - ) - - // this watches for ui state updates. - go func() { - - for { - select { - case <-vmCtx.Done(): - return - case uiState, ok := <-uiChan: - if !ok { - return - } - - closed := s.applyQuickActionsUiState( - uiState, - quickActionsUiComponents{ - content, - toggleConnectionButton, - connectedLabelText, disconnectedLabelText, - connectedImage, disconnectedImage, - connectedCircle.Resource, disconnectedCircle.Resource, - }, - ) - if closed { - return - } - } - } - }() - - s.wQuickActions.SetContent(content) - s.wQuickActions.Resize(fyne.NewSize(400, 200)) - s.wQuickActions.SetFixedSize(true) - s.wQuickActions.Show() -} diff --git a/client/ui/quickactions_assets.go b/client/ui/quickactions_assets.go deleted file mode 100644 index 9ff5e85a2..000000000 --- a/client/ui/quickactions_assets.go +++ /dev/null @@ -1,23 +0,0 @@ -// auto-generated -// Code generated by '$ fyne bundle'. DO NOT EDIT. - -package main - -import ( - _ "embed" - "fyne.io/fyne/v2" -) - -//go:embed assets/connected.png -var resourceConnectedPngData []byte -var resourceConnectedPng = &fyne.StaticResource{ - StaticName: "assets/connected.png", - StaticContent: resourceConnectedPngData, -} - -//go:embed assets/disconnected.png -var resourceDisconnectedPngData []byte -var resourceDisconnectedPng = &fyne.StaticResource{ - StaticName: "assets/disconnected.png", - StaticContent: resourceDisconnectedPngData, -} diff --git a/client/ui/recenter_linux.go b/client/ui/recenter_linux.go new file mode 100644 index 000000000..25c468d7f --- /dev/null +++ b/client/ui/recenter_linux.go @@ -0,0 +1,13 @@ +//go:build linux && !(linux && 386) + +package main + +// recenterOnShowPredicate returns a per-show predicate; re-centering is only +// needed under the minimal-WM / in-process-XEmbed-tray environment, which neither +// centers small windows nor restores position across hide -> show. Evaluated per +// show, not at startup, because the XEmbed tray can appear after the UI starts +// (panel and autostarted app race at login); xembedTrayAvailable is a cheap, +// side-effect-free probe safe to call repeatedly. +func recenterOnShowPredicate() func() bool { + return xembedTrayAvailable +} diff --git a/client/ui/recenter_other.go b/client/ui/recenter_other.go new file mode 100644 index 000000000..18ffe8730 --- /dev/null +++ b/client/ui/recenter_other.go @@ -0,0 +1,10 @@ +//go:build (!linux || (linux && 386)) && !freebsd && !android && !ios && !js + +package main + +// recenterOnShowPredicate returns nil off Linux: macOS and Windows WMs restore +// window position across hide -> show themselves, so Go-side re-centering would +// only fight a window the user moved. +func recenterOnShowPredicate() func() bool { + return nil +} diff --git a/client/ui/services/autostart.go b/client/ui/services/autostart.go new file mode 100644 index 000000000..98e893f04 --- /dev/null +++ b/client/ui/services/autostart.go @@ -0,0 +1,52 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "errors" + "fmt" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// Autostart facade over Wails' AutostartManager. The OS autostart entry registration +// is the single source of truth; nothing is mirrored to preferences. +type Autostart struct { + mgr *application.AutostartManager +} + +func NewAutostart(mgr *application.AutostartManager) *Autostart { + return &Autostart{mgr: mgr} +} + +func (a *Autostart) Supported(_ context.Context) bool { + _, err := a.mgr.Status() + return !errors.Is(err, application.ErrAutostartNotSupported) +} + +// IsEnabled returns false without error on unsupported platforms. +func (a *Autostart) IsEnabled(_ context.Context) (bool, error) { + enabled, err := a.mgr.IsEnabled() + if err != nil { + if errors.Is(err, application.ErrAutostartNotSupported) { + return false, nil + } + return false, fmt.Errorf("read autostart state: %w", err) + } + return enabled, nil +} + +// SetEnabled takes effect on the next login, not immediately. +func (a *Autostart) SetEnabled(_ context.Context, enabled bool) error { + if enabled { + if err := a.mgr.Enable(); err != nil { + return fmt.Errorf("enable autostart: %w", err) + } + return nil + } + if err := a.mgr.Disable(); err != nil { + return fmt.Errorf("disable autostart: %w", err) + } + return nil +} diff --git a/client/ui/services/compat.go b/client/ui/services/compat.go new file mode 100644 index 000000000..0cc6dd99f --- /dev/null +++ b/client/ui/services/compat.go @@ -0,0 +1,40 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/proto" +) + +// Compat answers whether the running daemon is new enough to drive this UI. +type Compat struct { + conn DaemonConn +} + +func NewCompat(conn DaemonConn) *Compat { + return &Compat{conn: conn} +} + +// DaemonReady probes the WailsUIReady RPC once. A true result means the daemon +// implements it and is compatible. An Unimplemented response means the daemon +// predates this UI and is too old; the caller should surface an upgrade prompt. +// Any other error (daemon not running, transport failure) is returned so the +// frontend can tell "outdated" apart from "not reachable". +func (c *Compat) DaemonReady(ctx context.Context) (bool, error) { + client, err := c.conn.Client() + if err != nil { + return false, err + } + if _, err := client.WailsUIReady(ctx, &proto.WailsUIReadyRequest{}); err != nil { + if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/client/ui/services/conn.go b/client/ui/services/conn.go new file mode 100644 index 000000000..531abe7d9 --- /dev/null +++ b/client/ui/services/conn.go @@ -0,0 +1,13 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import "github.com/netbirdio/netbird/client/proto" + +// DaemonConn returns a lazy gRPC client to the NetBird daemon. +// All services receive a DaemonConn so they share a single connection. +type DaemonConn interface { + Client() (proto.DaemonServiceClient, error) +} + +func ptrStr(s string) *string { return &s } diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go new file mode 100644 index 000000000..f78ce4c0f --- /dev/null +++ b/client/ui/services/connection.go @@ -0,0 +1,290 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/user" + "runtime" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// LoginParams are the inputs to Login. +type LoginParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` + ManagementURL string `json:"managementUrl"` + SetupKey string `json:"setupKey"` + PreSharedKey string `json:"preSharedKey"` + Hostname string `json:"hostname"` + Hint string `json:"hint"` +} + +// LoginResult is the daemon's reply to Login. +type LoginResult struct { + NeedsSSOLogin bool `json:"needsSsoLogin"` + UserCode string `json:"userCode"` + VerificationURI string `json:"verificationUri"` + VerificationURIComplete string `json:"verificationUriComplete"` + // ProfileID is the ID of the profile this login ran against, or "" when the + // caller named the profile itself and no ID was resolved. Pass it back in + // WaitSSOParams so the account email lands on this profile even if the + // active one changes during SSO. + ProfileID string `json:"profileId"` +} + +// WaitSSOParams are the inputs to waitSSOLogin. +type WaitSSOParams struct { + UserCode string `json:"userCode"` + Hostname string `json:"hostname"` + // ProfileID is the profile the login was started for, used to file the + // account email against it rather than against whichever profile is active + // when the flow returns. Optional: empty falls back to the active profile. + ProfileID string `json:"profileId"` +} + +// UpParams selects the profile to bring up. +type UpParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +// LogoutParams selects the profile to log out. +type LogoutParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +// Connection groups the daemon RPCs that drive login / connect / disconnect. +type Connection struct { + conn DaemonConn + classifier errorClassifier +} + +// NewConnection wires up a Connection. translator or prefs may be nil, in which +// case classifyDaemonError falls back to the bare error key. +func NewConnection(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Connection { + return &Connection{conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}} +} + +func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, error) { + cli, err := s.conn.Client() + if err != nil { + return LoginResult{}, err + } + + // No pre-Login Down: Login dislodges a pending WaitSSOLogin itself, and a + // defensive Down would only flash an Idle blink in the tray during handoff. + + // Fall back to the daemon's active profile and the current OS user. + profileName := p.ProfileName + username := p.Username + // Only set when the daemon told us the ID. A caller-supplied ProfileName is + // a handle — a display name or an ID prefix resolve too — and the state file + // is named after the ID, so passing a handle on would name the wrong file. + profileID := "" + if profileName == "" { + if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil { + // Address the active profile by ID (the daemon resolves it as a + // handle); names can collide, the ID cannot. + profileName = active.GetId() + profileID = profileName + if username == "" { + username = active.GetUsername() + } + } + } + if username == "" { + if u, uerr := user.Current(); uerr == nil { + username = u.Username + } + } + + req := &proto.LoginRequest{ + ManagementUrl: p.ManagementURL, + SetupKey: p.SetupKey, + Hostname: p.Hostname, + // a login driven by the UI always has a graphical session available + IsUnixDesktopClient: true, + } + if profileName != "" { + req.ProfileName = ptrStr(profileName) + } + if username != "" { + req.Username = ptrStr(username) + } + if p.PreSharedKey != "" { + req.OptionalPreSharedKey = ptrStr(p.PreSharedKey) + } + hint := p.Hint + if hint == "" && profileID != "" { + if state, serr := profilemanager.NewProfileManager().GetProfileState(profilemanager.ID(profileID)); serr == nil { + hint = state.Email + } else { + log.Debugf("failed to get profile state for login hint: %v", serr) + } + } + if hint != "" { + req.Hint = ptrStr(hint) + } + + resp, err := cli.Login(ctx, req) + if err != nil { + return LoginResult{}, s.classifyDaemonError(err) + } + log.Infof("daemon login response received, needs SSO login: %v", resp.GetNeedsSSOLogin()) + return LoginResult{ + NeedsSSOLogin: resp.GetNeedsSSOLogin(), + UserCode: resp.GetUserCode(), + VerificationURI: resp.GetVerificationURI(), + VerificationURIComplete: resp.GetVerificationURIComplete(), + ProfileID: profileID, + }, nil +} + +func (s *Connection) Up(ctx context.Context, p UpParams) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + log.Infof("sending up request to daemon") + // Always async: status updates flow via SubscribeStatus. + req := &proto.UpRequest{Async: true} + if p.ProfileName != "" { + req.ProfileName = ptrStr(p.ProfileName) + } + if p.Username != "" { + req.Username = ptrStr(p.Username) + } + if _, err = cli.Up(ctx, req); err != nil { + return s.classifyDaemonError(err) + } + return nil +} + +// WaitSSOLoginAndUp blocks until the SSO login completes and then brings the +// connection up, both from the Go side. Keeping the post-login Up here rather +// than as a frontend continuation is deliberate: during SSO the tray window is +// hidden and the webview is suspended (macOS App Nap / hidden-window timer +// throttling), so a frontend-driven Up would not run until the user woke the +// window (e.g. by hovering the tray icon). Doing it in Go connects the moment +// the daemon reports SSO success. Returns the authenticated user's email. +func (s *Connection) WaitSSOLoginAndUp(ctx context.Context, wait WaitSSOParams, up UpParams) (string, error) { + email, err := s.waitSSOLogin(ctx, wait) + if err != nil { + return "", err + } + if err := ctx.Err(); err != nil { + return "", err + } + if err := s.Up(ctx, up); err != nil { + return "", err + } + return email, nil +} + +func (s *Connection) Down(ctx context.Context) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + if _, err = cli.Down(ctx, &proto.DownRequest{}); err != nil { + return s.classifyDaemonError(err) + } + return nil +} + +// OpenURL opens url in an external browser; the embedded webview blocks +// window.open, so the SSO verification page can't pop inline. Honors $BROWSER +// before the platform default. +func (s *Connection) OpenURL(url string) error { + if browser := os.Getenv("BROWSER"); browser != "" { + return exec.Command(browser, url).Start() + } + switch runtime.GOOS { + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + return exec.Command("open", url).Start() + case "linux": + return exec.Command("xdg-open", url).Start() + default: + return fmt.Errorf("unsupported platform") + } +} + +func (s *Connection) Logout(ctx context.Context, p LogoutParams) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + req := &proto.LogoutRequest{} + if p.ProfileName != "" { + req.ProfileName = ptrStr(p.ProfileName) + } + if p.Username != "" { + req.Username = ptrStr(p.Username) + } + if _, err = cli.Logout(ctx, req); err != nil { + return s.classifyDaemonError(err) + } + + return nil +} + +// waitSSOLogin blocks until the daemon reports the SSO login result and returns +// the authenticated user's email. It is unexported because the frontend drives +// SSO through the exported WaitSSOLoginAndUp. +func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + log.Infof("waiting for SSO login to complete") + resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{ + UserCode: p.UserCode, + Hostname: p.Hostname, + }) + if err != nil { + return "", s.classifyDaemonError(err) + } + log.Infof("SSO login completed, daemon reported success") + + // Persist the account email the same way the CLI does after its own + // WaitSSOLogin: the daemon returns it but cannot store it, since it runs as + // root and the per-profile state file is user-owned (see Profiles.List). + // Without this the profile has no email, so Profiles.List shows no account + // and later logins and session extends go out without a login_hint — + // leaving the IdP to guess which account was meant. + if email := resp.GetEmail(); email != "" { + state := &profilemanager.ProfileState{Email: email} + pm := profilemanager.NewProfileManager() + + // Against the profile the login was started for: SSO spans seconds of + // user interaction, and a profile switch in that window would otherwise + // file the email under the wrong profile. + if p.ProfileID != "" { + err = pm.SetProfileState(profilemanager.ID(p.ProfileID), state) + } else { + err = pm.SetActiveProfileState(state) + } + if err != nil { + // Non-fatal: the login itself succeeded. + log.Warnf("failed to store account email: %v", err) + } + } + + return resp.GetEmail(), nil +} + +// classifyDaemonError maps a gRPC error to a localised ClientError. +func (s *Connection) classifyDaemonError(err error) *ClientError { + return s.classifier.classify(err) +} diff --git a/client/ui/services/cursor_darwin.go b/client/ui/services/cursor_darwin.go new file mode 100644 index 000000000..a927ff719 --- /dev/null +++ b/client/ui/services/cursor_darwin.go @@ -0,0 +1,42 @@ +//go:build darwin + +package services + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework Cocoa -framework AppKit +#import +#import + +typedef struct CursorPoint { + int x; + int y; + int ok; +} CursorPoint; + +// NSEvent.mouseLocation is Y-up from primary's bottom-left; flip against +// the primary's frame height so the point matches Wails' Y-down Screen.Bounds. +CursorPoint nbGetCursorPos(void) { + CursorPoint p = {0, 0, 0}; + NSArray *screens = [NSScreen screens]; + if (screens == nil || screens.count == 0) return p; + NSScreen *primary = [screens firstObject]; + if (primary == nil) return p; + NSPoint loc = [NSEvent mouseLocation]; + p.x = (int)loc.x; + p.y = (int)(primary.frame.size.height - loc.y); + p.ok = 1; + return p; +} +*/ +import "C" + +import "github.com/wailsapp/wails/v3/pkg/application" + +func getCursorPosition(_ *application.App) (application.Point, bool) { + res := C.nbGetCursorPos() + if res.ok == 0 { + return application.Point{}, false + } + return application.Point{X: int(res.x), Y: int(res.y)}, true +} diff --git a/client/ui/services/cursor_linux.go b/client/ui/services/cursor_linux.go new file mode 100644 index 000000000..3294f3c95 --- /dev/null +++ b/client/ui/services/cursor_linux.go @@ -0,0 +1,59 @@ +//go:build linux + +package services + +/* +#cgo pkg-config: x11 +#cgo LDFLAGS: -lX11 +#include +#include + +typedef struct CursorPoint { + int x; + int y; + int ok; +} CursorPoint; + +// XQueryPointer works on X11 and, via XWayland, on Wayland sessions. +// ok=0 when no X server is reachable. +CursorPoint nbGetCursorPos(void) { + CursorPoint p = {0, 0, 0}; + Display *dpy = XOpenDisplay(NULL); + if (!dpy) return p; + Window root = DefaultRootWindow(dpy); + if (root == 0) { XCloseDisplay(dpy); return p; } + Window root_return = 0, child_return = 0; + int root_x = 0, root_y = 0, win_x = 0, win_y = 0; + unsigned int mask_return = 0; + if (XQueryPointer(dpy, root, &root_return, &child_return, + &root_x, &root_y, &win_x, &win_y, &mask_return)) { + p.x = root_x; + p.y = root_y; + p.ok = 1; + } + XCloseDisplay(dpy); + return p; +} +*/ +import "C" + +import "github.com/wailsapp/wails/v3/pkg/application" + +func getCursorPosition(app *application.App) (application.Point, bool) { + res := C.nbGetCursorPos() + if res.ok == 0 { + return application.Point{}, false + } + p := application.Point{X: int(res.x), Y: int(res.y)} + // X11 root coords are physical pixels; Screen.Bounds is in DIPs. + if app == nil || app.Screen == nil { + return p, true + } + // The wails GTK3 backend caches screens from the active window; a tray app + // has none at startup, so the cache is empty and PhysicalToDipPoint would + // dereference a nil nearest screen. Raw pixels are correct there anyway. + if app.Screen.ScreenNearestPhysicalPoint(p) == nil { + return p, true + } + return app.Screen.PhysicalToDipPoint(p), true +} diff --git a/client/ui/services/cursor_other.go b/client/ui/services/cursor_other.go new file mode 100644 index 000000000..7f35ff438 --- /dev/null +++ b/client/ui/services/cursor_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !windows && !linux && !freebsd && !android && !ios && !js + +package services + +import "github.com/wailsapp/wails/v3/pkg/application" + +func getCursorPosition(_ *application.App) (application.Point, bool) { + return application.Point{}, false +} diff --git a/client/ui/services/cursor_windows.go b/client/ui/services/cursor_windows.go new file mode 100644 index 000000000..42ed32d74 --- /dev/null +++ b/client/ui/services/cursor_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package services + +import ( + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/w32" +) + +func getCursorPosition(app *application.App) (application.Point, bool) { + x, y, ok := w32.GetCursorPos() + if !ok || app == nil || app.Screen == nil { + return application.Point{}, false + } + // GetCursorPos is in physical pixels; Screen.Bounds is in DIPs. + return app.Screen.PhysicalToDipPoint(application.Point{X: x, Y: y}), true +} diff --git a/client/ui/services/daemon_feed.go b/client/ui/services/daemon_feed.go new file mode 100644 index 000000000..632581fe9 --- /dev/null +++ b/client/ui/services/daemon_feed.go @@ -0,0 +1,590 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/updater" +) + +const ( + EventStatusSnapshot = "netbird:status" + // EventDaemonNotification carries each SubscribeEvents message. Auto-update + // SystemEvents are also forwarded to updater.Holder.OnSystemEvent so the typed + // update state needs no second daemon subscription. + EventDaemonNotification = "netbird:event" + // EventProfileChanged fires after a daemon-side switch (payload: the new + // ProfileRef). The daemon emits no profile event, so this is the only signal + // that lets a flip driven from one surface paint in the others. + EventProfileChanged = "netbird:profile:changed" + // EventSessionWarning is a typed sibling of EventDaemonNotification so + // subscribers needn't filter the notification firehose. Consumers branch on + // SessionWarning.Final to tell the T-10 event from the T-2 fallback. + EventSessionWarning = "netbird:session:warning" + + // StatusDaemonUnavailable is the synthetic Status emitted when the daemon's + // gRPC socket is unreachable. No internal.Status* collides with this label. + StatusDaemonUnavailable = "DaemonUnavailable" + + // Daemon connection status strings — mirror internal.Status* in + // client/internal/state.go. + StatusConnected = "Connected" + StatusConnecting = "Connecting" + StatusIdle = "Idle" + StatusNeedsLogin = "NeedsLogin" + StatusLoginFailed = "LoginFailed" + StatusSessionExpired = "SessionExpired" + + // SeverityCritical is the lower-cased proto SystemEvent_CRITICAL severity, as + // emitted by systemEventFromProto. Critical events bypass the notifications gate. + SeverityCritical = "critical" +) + +// Emitter sends a named payload to the frontend. Satisfied by Wails app.Event. +type Emitter interface { + Emit(name string, data ...any) bool +} + +// SystemEvent is the frontend-facing shape of a daemon SystemEvent. +type SystemEvent struct { + ID string `json:"id"` + Severity string `json:"severity"` + Category string `json:"category"` + Message string `json:"message"` + UserMessage string `json:"userMessage"` + Timestamp int64 `json:"timestamp"` + Metadata map[string]string `json:"metadata"` +} + +// PeerStatus is the frontend-facing shape of a daemon PeerState. +type PeerStatus struct { + IP string `json:"ip"` + IPv6 string `json:"ipv6"` + PubKey string `json:"pubKey"` + ConnStatus string `json:"connStatus"` + ConnStatusUpdateUnix int64 `json:"connStatusUpdateUnix"` + Relayed bool `json:"relayed"` + LocalIceCandidateType string `json:"localIceCandidateType"` + RemoteIceCandidateType string `json:"remoteIceCandidateType"` + LocalIceCandidateEndpoint string `json:"localIceCandidateEndpoint"` + RemoteIceCandidateEndpoint string `json:"remoteIceCandidateEndpoint"` + Fqdn string `json:"fqdn"` + BytesRx int64 `json:"bytesRx"` + BytesTx int64 `json:"bytesTx"` + LatencyMs int64 `json:"latencyMs"` + RelayAddress string `json:"relayAddress"` + LastHandshakeUnix int64 `json:"lastHandshakeUnix"` + RosenpassEnabled bool `json:"rosenpassEnabled"` + Networks []string `json:"networks"` +} + +// PeerLink is this peer's connection to its mgmt or signal server. +type PeerLink struct { + URL string `json:"url"` + Connected bool `json:"connected"` + Error string `json:"error,omitempty"` +} + +// LocalPeer mirrors LocalPeerState. +type LocalPeer struct { + IP string `json:"ip"` + IPv6 string `json:"ipv6"` + PubKey string `json:"pubKey"` + Fqdn string `json:"fqdn"` + Networks []string `json:"networks"` +} + +// Status is the snapshot the frontend renders on the dashboard. +type Status struct { + Status string `json:"status"` + DaemonVersion string `json:"daemonVersion"` + Management PeerLink `json:"management"` + Signal PeerLink `json:"signal"` + Local LocalPeer `json:"local"` + Peers []PeerStatus `json:"peers"` + Events []SystemEvent `json:"events"` + // NetworksRevision bumps whenever the daemon's routed-networks set or their + // selected state changes, so consumers know when to re-fetch ListNetworks + // instead of polling every snapshot. + NetworksRevision uint64 `json:"networksRevision"` + // SessionExpiresAt is the absolute UTC instant the SSO session expires; nil + // when the peer is not SSO-tracked or login expiration is disabled. + SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"` +} + +// DaemonFeed fans the daemon's two long-running gRPC streams (SubscribeStatus, +// SubscribeEvents) out to the frontend and tray, and exposes a one-shot Status +// RPC for callers wanting the current snapshot without subscribing. +// +// Profile-switch suppression: BeginProfileSwitch makes statusStreamLoop swallow +// the transient stale Connected and Idle pushes the daemon emits during Down, so +// consumers see Connecting → new-profile-state instead of the full blink. +// +// Two flags govern the switch lifecycle, evaluated independently by +// consumeForSwitch on every push because their lifetimes differ: +// +// switchInProgress (suppression): clears on the first real push from the new +// Up. Daemon-side StatusConnecting comes BEFORE any NeedsLogin, so +// suppression must release here before the terminal arrives. +// switchLoginWatch (trigger): outlives suppression. Watches for NeedsLogin +// / LoginFailed / SessionExpired along the Up's retry loop and emits +// EventTriggerLogin so the React orchestrator opens browser-login. +// +// ┌────────────────────────────────────────────┬──────────────────────────────────┐ +// │ Incoming daemon status │ Action │ +// ├────────────────────────────────────────────┼──────────────────────────────────┤ +// │ Connected, Idle (while switchInProgress) │ Suppress (the blink we hide) │ +// │ Connecting │ Emit, clear switchInProgress │ +// │ NeedsLogin, LoginFailed, SessionExpired │ Emit, clear both flags, also │ +// │ │ emit EventTriggerLogin │ +// │ Connected, Idle (while only login-watch) │ Emit, clear switchLoginWatch │ +// │ DaemonUnavailable │ Emit, clear both flags │ +// │ (timeout elapsed) │ Clear flags, emit normally │ +// └────────────────────────────────────────────┴──────────────────────────────────┘ +type DaemonFeed struct { + conn DaemonConn + emitter Emitter + updater *updater.Holder + // logCtl attaches/detaches the GUI file log in response to the daemon's log + // level (a marked SystemEvent on the SubscribeEvents stream). nil when the GUI + // doesn't manage its log (server build / not wired), in which case the marker + // is ignored. + logCtl LogController + + mu sync.Mutex + cancel context.CancelFunc + streamWg sync.WaitGroup + + switchMu sync.Mutex + switchInProgress bool + switchInProgressUntil time.Time + switchLoginWatch bool + switchLoginWatchUntil time.Time +} + +// LogController is the subset of guilog.DebugLog that DaemonFeed drives: Apply +// turns the GUI file log on/off for a daemon level; Path is the gui-client.log +// path to register with the daemon (empty when the GUI doesn't own its log). +type LogController interface { + Apply(level string) + Path() string +} + +// NewDaemonFeed builds the feed. logCtl may be nil (server build / GUI log not +// managed), in which case log-level markers on the event stream are ignored. +func NewDaemonFeed(conn DaemonConn, emitter Emitter, updaterHolder *updater.Holder, logCtl LogController) *DaemonFeed { + return &DaemonFeed{conn: conn, emitter: emitter, updater: updaterHolder, logCtl: logCtl} +} + +// BeginProfileSwitch arms suppression for a switch from Connected/Connecting, +// where the daemon emits stale Connected updates during Down's teardown then an +// Idle before the new Up; statusStreamLoop drops those, and a synthetic +// Connecting snapshot is emitted so consumers paint optimistically. A 30s safety +// timeout clears the flag if no follow-up status arrives. +func (s *DaemonFeed) BeginProfileSwitch() { + now := time.Now() + s.switchMu.Lock() + s.switchInProgress = true + s.switchInProgressUntil = now.Add(30 * time.Second) + s.switchLoginWatch = true + s.switchLoginWatchUntil = now.Add(30 * time.Second) + s.switchMu.Unlock() + s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusConnecting}) +} + +// CancelProfileSwitch aborts a switch midway (tray Disconnect while Connecting): +// clears suppression so the next daemon Idle paints through, and disarms the +// login-watch so the abort doesn't pop a browser-login after the user cancelled. +func (s *DaemonFeed) CancelProfileSwitch() { + s.switchMu.Lock() + s.switchInProgress = false + s.switchLoginWatch = false + s.switchMu.Unlock() +} + +// Watch starts the two background stream loops. Idempotent (a second call while +// running is a no-op); both loops self-restart via exponential backoff. +func (s *DaemonFeed) Watch(ctx context.Context) { + s.mu.Lock() + if s.cancel != nil { + s.mu.Unlock() + return + } + ctx, cancel := context.WithCancel(ctx) + s.cancel = cancel + s.mu.Unlock() + + s.streamWg.Add(2) + go s.statusStreamLoop(ctx) + go s.toastStreamLoop(ctx) +} + +// ServiceShutdown is the Wails service hook fired on app exit. +func (s *DaemonFeed) ServiceShutdown() error { + s.mu.Lock() + cancel := s.cancel + s.cancel = nil + s.mu.Unlock() + if cancel != nil { + cancel() + } + s.streamWg.Wait() + return nil +} + +// Get returns the current daemon status snapshot. An unreachable daemon socket +// yields Status{Status: StatusDaemonUnavailable} rather than an error, so the +// frontend keys off a single status enum without a parallel "error" path. +func (s *DaemonFeed) Get(ctx context.Context) (Status, error) { + cli, err := s.conn.Client() + if err != nil { + if isDaemonUnreachable(err) { + return Status{Status: StatusDaemonUnavailable}, nil + } + return Status{}, err + } + resp, err := cli.Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true}) + if err != nil { + if isDaemonUnreachable(err) { + return Status{Status: StatusDaemonUnavailable}, nil + } + return Status{}, err + } + return statusFromProto(resp), nil +} + +// consumeForSwitch decides, for an incoming push during a profile switch, +// whether to suppress it (suppress) and whether the switch landed in a state +// needing the SSO flow (triggerLogin: NeedsLogin / SessionExpired / LoginFailed). +// +// The two flags have different lifetimes: suppression clears on Connecting, but +// the trigger watcher must survive past it to catch the eventual NeedsLogin — +// daemon-side StatusConnecting fires before loginToManagement, which is what may +// then set StatusNeedsLogin. +func (s *DaemonFeed) consumeForSwitch(st Status) (suppress, triggerLogin bool) { + s.switchMu.Lock() + defer s.switchMu.Unlock() + + now := time.Now() + if s.switchInProgress && now.After(s.switchInProgressUntil) { + s.switchInProgress = false + } + if s.switchLoginWatch && now.After(s.switchLoginWatchUntil) { + s.switchLoginWatch = false + } + + if s.switchInProgress { + switch { + case strings.EqualFold(st.Status, StatusConnecting), + strings.EqualFold(st.Status, StatusNeedsLogin), + strings.EqualFold(st.Status, StatusLoginFailed), + strings.EqualFold(st.Status, StatusSessionExpired), + strings.EqualFold(st.Status, StatusDaemonUnavailable): + // New flow has begun (Up started, or daemon refused it). + s.switchInProgress = false + default: + // Stale Connected from teardown or transient Idle: suppress so the + // optimistic Connecting stays painted. Login-watch stays armed. + return true, false + } + } + + if s.switchLoginWatch { + switch { + case strings.EqualFold(st.Status, StatusNeedsLogin), + strings.EqualFold(st.Status, StatusLoginFailed), + strings.EqualFold(st.Status, StatusSessionExpired): + // SSO-needed terminal: trigger browser-login without a second click. + s.switchLoginWatch = false + return false, true + case strings.EqualFold(st.Status, StatusConnected), + strings.EqualFold(st.Status, StatusIdle), + strings.EqualFold(st.Status, StatusDaemonUnavailable): + // Terminal but not SSO — disarm without triggering. + s.switchLoginWatch = false + } + } + + return false, false +} + +// statusStreamLoop subscribes to SubscribeStatus and re-emits each snapshot on +// the Wails event bus. The first message is the current snapshot; later ones +// fire on connection-state changes only — no polling. +func (s *DaemonFeed) statusStreamLoop(ctx context.Context) { + defer s.streamWg.Done() + + bo := backoff.WithContext(&backoff.ExponentialBackOff{ + InitialInterval: time.Second, + RandomizationFactor: backoff.DefaultRandomizationFactor, + Multiplier: backoff.DefaultMultiplier, + MaxInterval: 10 * time.Second, + MaxElapsedTime: 0, + Stop: backoff.Stop, + Clock: backoff.SystemClock, + }, ctx) + + // unavailable fires the synthetic event once per outage, not on every retry. + unavailable := false + emitUnavailable := func() { + if unavailable { + return + } + unavailable = true + s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusDaemonUnavailable}) + } + + op := func() error { + return s.subscribeAndStreamStatus(ctx, &unavailable, emitUnavailable) + } + + if err := backoff.Retry(op, bo); err != nil && ctx.Err() == nil { + log.Errorf("status stream ended: %v", err) + } +} + +// subscribeAndStreamStatus is one attempt of the status backoff loop: open +// SubscribeStatus and re-emit every snapshot until it errors. A daemon- +// unreachable failure also flips the synthetic-unavailable signal. +func (s *DaemonFeed) subscribeAndStreamStatus(ctx context.Context, unavailable *bool, emitUnavailable func()) error { + cli, err := s.conn.Client() + if err != nil { + emitUnavailable() + return fmt.Errorf("get client: %w", err) + } + stream, err := cli.SubscribeStatus(ctx, &proto.StatusRequest{GetFullPeerStatus: true}) + if err != nil { + if isDaemonUnreachable(err) { + emitUnavailable() + } + return fmt.Errorf("subscribe status: %w", err) + } + for { + resp, err := stream.Recv() + if err != nil { + return s.handleStatusRecvErr(ctx, err, emitUnavailable) + } + *unavailable = false + s.emitStatus(statusFromProto(resp)) + } +} + +// handleStatusRecvErr maps a SubscribeStatus Recv error into the backoff loop's +// return: ctx cancellation stops the loop, an unreachable socket flips the +// synthetic-unavailable signal, everything else is retryable. +func (s *DaemonFeed) handleStatusRecvErr(ctx context.Context, err error, emitUnavailable func()) error { + if ctx.Err() != nil { + return ctx.Err() + } + if isDaemonUnreachable(err) { + emitUnavailable() + } + return fmt.Errorf("status stream recv: %w", err) +} + +// emitStatus pushes a snapshot to the frontend, dropping the transient +// stale-Connected / Idle pushes that occur mid profile switch. +func (s *DaemonFeed) emitStatus(st Status) { + log.Infof("backend event: status status=%q peers=%d", st.Status, len(st.Peers)) + suppress, triggerLogin := s.consumeForSwitch(st) + if suppress { + log.Debugf("suppressing status=%q during profile switch", st.Status) + return + } + s.emitter.Emit(EventStatusSnapshot, st) + if triggerLogin { + s.emitter.Emit(EventTriggerLogin) + } +} + +// toastStreamLoop subscribes to SubscribeEvents and re-emits every SystemEvent +// on the Wails event bus. Local name differs from the RPC so the file's two +// streams aren't both called streamLoop. +func (s *DaemonFeed) toastStreamLoop(ctx context.Context) { + defer s.streamWg.Done() + + bo := backoff.WithContext(&backoff.ExponentialBackOff{ + InitialInterval: time.Second, + RandomizationFactor: backoff.DefaultRandomizationFactor, + Multiplier: backoff.DefaultMultiplier, + MaxInterval: 10 * time.Second, + MaxElapsedTime: 0, + Stop: backoff.Stop, + Clock: backoff.SystemClock, + }, ctx) + + op := func() error { + return s.subscribeAndStreamEvents(ctx) + } + + if err := backoff.Retry(op, bo); err != nil && ctx.Err() == nil { + log.Errorf("event stream ended: %v", err) + } +} + +// subscribeAndStreamEvents is one attempt of the event backoff loop: open +// SubscribeEvents and fan out every SystemEvent until it errors. +func (s *DaemonFeed) subscribeAndStreamEvents(ctx context.Context) error { + cli, err := s.conn.Client() + if err != nil { + return fmt.Errorf("get client: %w", err) + } + stream, err := cli.SubscribeEvents(ctx, &proto.SubscribeRequest{}) + if err != nil { + return fmt.Errorf("subscribe: %w", err) + } + + // Re-register the GUI log path on every (re)connect so a daemon restart + // re-learns it. Best-effort — a failure must not abort the stream. Done even + // when file logging is off, so the path is known ahead of any debug toggle. + if s.logCtl != nil && s.logCtl.Path() != "" { + if _, err := cli.RegisterUILog(ctx, &proto.RegisterUILogRequest{Path: s.logCtl.Path()}); err != nil { + log.Warnf("register UI log path: %v", err) + } + } + for { + ev, err := stream.Recv() + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("stream recv: %w", err) + } + s.dispatchSystemEvent(ev) + } +} + +// dispatchSystemEvent fans one daemon SystemEvent out to the frontend +// notification stream, the typed session-warning event (when the metadata +// carries one), and the updater holder (when present). +func (s *DaemonFeed) dispatchSystemEvent(ev *proto.SystemEvent) { + se := systemEventFromProto(ev) + log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage) + // Internal refresh signal (CLI-driven profile add/remove), not a notification: + // translate and stop so it never reaches Recent Events or fires an OS toast. + if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindProfileListChanged { + s.emitter.Emit(EventProfileChanged, ProfileRef{}) + return + } + // Internal control signal driving the GUI file log on/off — handle and stop + // so it never reaches Recent Events or toasts. + if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindLogLevelChanged { + if s.logCtl != nil { + s.logCtl.Apply(se.Metadata[proto.MetadataLevelKey]) + } + return + } + s.emitter.Emit(EventDaemonNotification, se) + if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok { + s.emitter.Emit(EventSessionWarning, warn) + } + if s.updater != nil { + s.updater.OnSystemEvent(ev) + } +} + +func statusFromProto(resp *proto.StatusResponse) Status { + full := resp.GetFullStatus() + mgmt := full.GetManagementState() + sig := full.GetSignalState() + local := full.GetLocalPeerState() + + st := Status{ + Status: resp.GetStatus(), + DaemonVersion: resp.GetDaemonVersion(), + NetworksRevision: full.GetNetworksRevision(), + Management: PeerLink{ + URL: mgmt.GetURL(), + Connected: mgmt.GetConnected(), + Error: mgmt.GetError(), + }, + Signal: PeerLink{ + URL: sig.GetURL(), + Connected: sig.GetConnected(), + Error: sig.GetError(), + }, + Local: LocalPeer{ + IP: local.GetIP(), + IPv6: local.GetIpv6(), + PubKey: local.GetPubKey(), + Fqdn: local.GetFqdn(), + Networks: append([]string{}, local.GetNetworks()...), + }, + } + + for _, p := range full.GetPeers() { + st.Peers = append(st.Peers, PeerStatus{ + IP: p.GetIP(), + IPv6: p.GetIpv6(), + PubKey: p.GetPubKey(), + ConnStatus: p.GetConnStatus(), + ConnStatusUpdateUnix: p.GetConnStatusUpdate().GetSeconds(), + Relayed: p.GetRelayed(), + LocalIceCandidateType: p.GetLocalIceCandidateType(), + RemoteIceCandidateType: p.GetRemoteIceCandidateType(), + LocalIceCandidateEndpoint: p.GetLocalIceCandidateEndpoint(), + RemoteIceCandidateEndpoint: p.GetRemoteIceCandidateEndpoint(), + Fqdn: p.GetFqdn(), + BytesRx: p.GetBytesRx(), + BytesTx: p.GetBytesTx(), + LatencyMs: p.GetLatency().AsDuration().Milliseconds(), + RelayAddress: p.GetRelayAddress(), + LastHandshakeUnix: p.GetLastWireguardHandshake().GetSeconds(), + RosenpassEnabled: p.GetRosenpassEnabled(), + Networks: append([]string{}, p.GetNetworks()...), + }) + } + for _, e := range full.GetEvents() { + st.Events = append(st.Events, systemEventFromProto(e)) + } + if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() { + t := ts.AsTime().UTC() + st.SessionExpiresAt = &t + } + return st +} + +func systemEventFromProto(e *proto.SystemEvent) SystemEvent { + out := SystemEvent{ + ID: e.GetId(), + Severity: strings.ToLower(strings.TrimPrefix(e.GetSeverity().String(), "SystemEvent_")), + Category: strings.ToLower(strings.TrimPrefix(e.GetCategory().String(), "SystemEvent_")), + Message: e.GetMessage(), + UserMessage: e.GetUserMessage(), + Metadata: map[string]string{}, + } + if ts := e.GetTimestamp(); ts != nil { + out.Timestamp = ts.GetSeconds() + } + for k, v := range e.GetMetadata() { + out.Metadata[k] = v + } + return out +} + +// isDaemonUnreachable reports whether a gRPC error means the daemon socket isn't +// answering, versus the daemon responding with an application-level code. Only +// the former should flip the tray to "Not running" — a daemon returning e.g. +// FailedPrecondition is alive and must not be reported as down. +func isDaemonUnreachable(err error) bool { + if err == nil { + return false + } + st, ok := status.FromError(err) + if !ok { + return true + } + return st.Code() == codes.Unavailable +} diff --git a/client/ui/services/debug.go b/client/ui/services/debug.go new file mode 100644 index 000000000..d1f6555a8 --- /dev/null +++ b/client/ui/services/debug.go @@ -0,0 +1,140 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/version" +) + +type DebugBundleParams struct { + Anonymize bool `json:"anonymize"` + // AnonymizeLevel is "default" or "strict"; strict also anonymizes + // private IP ranges, peer names, and WireGuard public keys. + AnonymizeLevel string `json:"anonymizeLevel"` + SystemInfo bool `json:"systemInfo"` + UploadURL string `json:"uploadUrl"` + LogFileCount uint32 `json:"logFileCount"` +} + +// DebugBundleResult: Path is set for local-only bundles, UploadedKey on upload +// success, UploadFailureReason on upload failure. +type DebugBundleResult struct { + Path string `json:"path"` + UploadedKey string `json:"uploadedKey"` + UploadFailureReason string `json:"uploadFailureReason"` +} + +// LogLevel carries a logrus level name: "error", "warn", "info", "debug", "trace". +type LogLevel struct { + Level string `json:"level"` +} + +type Debug struct { + conn DaemonConn +} + +func NewDebug(conn DaemonConn) *Debug { + return &Debug{conn: conn} +} + +func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleResult, error) { + cli, err := s.conn.Client() + if err != nil { + return DebugBundleResult{}, err + } + resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{ + Anonymize: p.Anonymize, + AnonymizeLevel: p.AnonymizeLevel, + SystemInfo: p.SystemInfo, + UploadURL: p.UploadURL, + LogFileCount: p.LogFileCount, + CliVersion: version.NetbirdVersion(), + }) + if err != nil { + return DebugBundleResult{}, err + } + return DebugBundleResult{ + Path: resp.GetPath(), + UploadedKey: resp.GetUploadedKey(), + UploadFailureReason: resp.GetUploadFailureReason(), + }, nil +} + +func (s *Debug) GetLogLevel(ctx context.Context) (LogLevel, error) { + cli, err := s.conn.Client() + if err != nil { + return LogLevel{}, err + } + resp, err := cli.GetLogLevel(ctx, &proto.GetLogLevelRequest{}) + if err != nil { + return LogLevel{}, err + } + return LogLevel{Level: resp.GetLevel().String()}, nil +} + +// RevealFile opens the OS file manager focused on path. Needed because Wails' +// Browser.OpenURL refuses non-http(s) schemes like file://. +func (s *Debug) RevealFile(_ context.Context, path string) error { + if path == "" { + return fmt.Errorf("empty path") + } + return revealFile(path) +} + +// RegisterUILog reports the GUI log path to the daemon for bundle collection; +// the daemon runs as root and can't resolve the user's config dir. Called on +// each daemon (re)connect. +func (s *Debug) RegisterUILog(ctx context.Context, path string) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.RegisterUILog(ctx, &proto.RegisterUILogRequest{Path: path}) + return err +} + +func (s *Debug) StartBundleCapture(ctx context.Context, timeoutSeconds int32) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + req := &proto.StartBundleCaptureRequest{} + if timeoutSeconds > 0 { + req.Timeout = durationpb.New(time.Duration(timeoutSeconds) * time.Second) + } + _, err = cli.StartBundleCapture(ctx, req) + return err +} + +func (s *Debug) StopBundleCapture(ctx context.Context) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.StopBundleCapture(ctx, &proto.StopBundleCaptureRequest{}) + return err +} + +func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + // proto.LogLevel_value keys are upper-case enum names; callers pass + // lowercase logrus names. Upper-case before lookup or a valid level + // silently falls through to INFO. + level, ok := proto.LogLevel_value[strings.ToUpper(lvl.Level)] + if !ok { + level = int32(proto.LogLevel_INFO) + } + _, err = cli.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel(level)}) + return err +} diff --git a/client/ui/services/debug_reveal_other.go b/client/ui/services/debug_reveal_other.go new file mode 100644 index 000000000..bef0a58bb --- /dev/null +++ b/client/ui/services/debug_reveal_other.go @@ -0,0 +1,20 @@ +//go:build !android && !ios && !freebsd && !js && !windows + +package services + +import ( + "os/exec" + "path/filepath" + "runtime" +) + +// revealFile opens the OS file manager focused on path. +func revealFile(path string) error { + var cmd *exec.Cmd + if runtime.GOOS == "darwin" { + cmd = exec.Command("open", "-R", path) + } else { + cmd = exec.Command("xdg-open", filepath.Dir(path)) + } + return cmd.Start() +} diff --git a/client/ui/services/debug_reveal_windows.go b/client/ui/services/debug_reveal_windows.go new file mode 100644 index 000000000..790d23fab --- /dev/null +++ b/client/ui/services/debug_reveal_windows.go @@ -0,0 +1,54 @@ +package services + +import ( + "fmt" + "os/exec" + "path/filepath" + "unsafe" + + "golang.org/x/sys/windows" +) + +// SW_SHOWNORMAL for ShellExecuteW's nShowCmd. +const swShowNormal = 1 + +var ( + shell32 = windows.NewLazySystemDLL("shell32.dll") + procShellExecute = shell32.NewProc("ShellExecuteW") +) + +// revealFile opens Explorer focused on path. The debug bundle is written by the +// daemon (running as SYSTEM) into C:\Windows\SystemTemp, whose ACL denies the +// logged-in user. A plain "explorer /select" can't traverse it, so we elevate +// via the ShellExecuteW "runas" verb (UAC prompt) — the elevated Explorer can +// read the folder and highlight the file. +func revealFile(path string) error { + verb, err := windows.UTF16PtrFromString("runas") + if err != nil { + return fmt.Errorf("encode verb: %w", err) + } + file, err := windows.UTF16PtrFromString("explorer.exe") + if err != nil { + return fmt.Errorf("encode file: %w", err) + } + params, err := windows.UTF16PtrFromString("/select," + path) + if err != nil { + return fmt.Errorf("encode params: %w", err) + } + + // ShellExecuteW returns an HINSTANCE; a value <=32 is an error code. + ret, _, _ := procShellExecute.Call( + 0, + uintptr(unsafe.Pointer(verb)), + uintptr(unsafe.Pointer(file)), + uintptr(unsafe.Pointer(params)), + 0, + swShowNormal, + ) + if ret <= 32 { + // Elevation declined or failed: fall back to an unelevated reveal of the + // parent directory so the user at least lands near the bundle. + return exec.Command("explorer", filepath.Dir(path)).Start() //nolint:gosec + } + return nil +} diff --git a/client/ui/services/errors.go b/client/ui/services/errors.go new file mode 100644 index 000000000..0c6f2f20f --- /dev/null +++ b/client/ui/services/errors.go @@ -0,0 +1,172 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "encoding/json" + "strings" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + gcodes "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" +) + +// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error +// carries one. +func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) { + for _, detail := range gstatus.Convert(err).Details() { + info, ok := detail.(*errdetails.ErrorInfo) + if !ok { + continue + } + if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain { + return info, true + } + } + return nil, false +} + +// ErrorTranslator localises daemon errors; runtime impl is *i18n.Bundle. +type ErrorTranslator interface { + Translate(lang i18n.LanguageCode, key string, args ...string) string +} + +// LanguagePreference reports the current UI language; runtime impl is *preferences.Store. +type LanguagePreference interface { + Get() preferences.UIPreferences +} + +// ClientError is a structured error returned to the frontend. The frontend +// translates Code via i18n; Short is an English fallback; Long carries the +// unwrapped daemon message. +type ClientError struct { + Code string `json:"code"` + Short string `json:"short"` + Long string `json:"long"` + // Command is a command the user can run to complete the operation + // themselves, set when the daemon refused it for want of privileges. The + // frontend offers it for copying. + Command string `json:"command,omitempty"` +} + +// Error returns the short message for plain Go callers. +func (e *ClientError) Error() string { + if e == nil { + return "" + } + return e.Short +} + +// MarshalJSON emits the struct so the Wails binding sends an object, not the +// default "error: ..." string. +func (e *ClientError) MarshalJSON() ([]byte, error) { + if e == nil { + return []byte("null"), nil + } + type alias ClientError + return json.Marshal((*alias)(e)) +} + +// errorClassifier maps gRPC errors to a localised ClientError. Shared by the +// daemon-facing services so the frontend gets a clean short message instead of +// the wrapped gRPC chain. +type errorClassifier struct { + translator ErrorTranslator + prefs LanguagePreference +} + +// classify maps a gRPC error to a ClientError by matching known substrings to a +// stable code. A missing locale entry surfaces as a visible "error." +// string — a deliberate fail-loud signal to update the bundle. +func (c errorClassifier) classify(err error) *ClientError { + if err == nil { + return nil + } + + msg := err.Error() + grpcCode := gcodes.Unknown + if st, ok := gstatus.FromError(err); ok { + msg = st.Message() + grpcCode = st.Code() + } + + // A refusal for want of privileges carries its own summary and the command + // that performs the operation, both written for the user. Surface them + // verbatim: no substring guessing, and no localisation of a message the + // daemon composed. + if info, ok := privilegeErrorInfo(err); ok { + summary := info.GetMetadata()[ipcauth.ErrorMetaSummary] + if summary == "" { + summary = msg + } + return &ClientError{ + Code: "privilege_required", + Short: summary, + Long: summary, + Command: info.GetMetadata()[ipcauth.ErrorMetaCommand], + } + } + + lower := strings.ToLower(msg) + + code := "unknown" + switch { + case strings.Contains(lower, "token used before issued"), + strings.Contains(lower, "token is not valid yet"): + code = "jwt_clock_skew" + case strings.Contains(lower, "token is expired"), + strings.Contains(lower, "token has expired"): + code = "jwt_expired" + case strings.Contains(lower, "token signature is invalid"): + code = "jwt_signature_invalid" + case strings.Contains(lower, "peer login has expired"): + code = "session_expired" + case strings.Contains(lower, "invalid setup-key"), + strings.Contains(lower, "invalid setup key"): + code = "invalid_setup_key" + case strings.Contains(lower, "permission denied"): + code = "permission_denied" + case strings.Contains(lower, "no connection could be made"), + strings.Contains(lower, "connection refused"), + strings.Contains(lower, "context deadline exceeded"): + code = "daemon_unreachable" + } + + // Fall back to the gRPC status code when the message didn't match a known + // substring — the daemon now forwards the innermost code with a clean desc + // that no longer contains the English marker text. + if code == "unknown" { + switch grpcCode { + case gcodes.PermissionDenied: + code = "permission_denied" + case gcodes.Unavailable, gcodes.DeadlineExceeded: + code = "daemon_unreachable" + } + } + + return &ClientError{ + Code: code, + Short: c.translateShort(code), + Long: msg, + } +} + +// translateShort resolves the localised short message for code, returning the +// bare "error." key when no translation is available so the gap stays visible. +func (c errorClassifier) translateShort(code string) string { + key := "error." + code + if c.translator == nil { + return key + } + lang := i18n.DefaultLanguage + if c.prefs != nil { + if pref := c.prefs.Get().Language; pref != "" { + lang = pref + } + } + return c.translator.Translate(lang, key) +} diff --git a/client/ui/services/errors_test.go b/client/ui/services/errors_test.go new file mode 100644 index 000000000..2f8f3d039 --- /dev/null +++ b/client/ui/services/errors_test.go @@ -0,0 +1,50 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + gcodes "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" +) + +func TestErrorClassifier_Classify(t *testing.T) { + c := errorClassifier{} // nil translator → Short is the bare "error." key + + t.Run("permission denied by gRPC code with a clean desc", func(t *testing.T) { + // The daemon now forwards the innermost status: code + clean desc that + // no longer carries the English "permission denied" marker. + err := gstatus.Error(gcodes.PermissionDenied, "peer is already registered by a different User or a Setup Key") + + ce := c.classify(err) + require.NotNil(t, ce) + require.Equal(t, "permission_denied", ce.Code) + require.Equal(t, "error.permission_denied", ce.Short) + require.Equal(t, "peer is already registered by a different User or a Setup Key", ce.Long) + }) + + t.Run("substring match still wins for unclassified codes", func(t *testing.T) { + err := gstatus.Error(gcodes.Unknown, "peer login has expired") + + ce := c.classify(err) + require.NotNil(t, ce) + require.Equal(t, "session_expired", ce.Code) + }) + + t.Run("unavailable code maps to daemon_unreachable", func(t *testing.T) { + ce := c.classify(gstatus.Error(gcodes.Unavailable, "transport closing")) + require.Equal(t, "daemon_unreachable", ce.Code) + }) + + t.Run("unmatched stays unknown", func(t *testing.T) { + ce := c.classify(errors.New("something odd")) + require.Equal(t, "unknown", ce.Code) + }) + + t.Run("nil error", func(t *testing.T) { + require.Nil(t, c.classify(nil)) + }) +} diff --git a/client/ui/services/foreground_other.go b/client/ui/services/foreground_other.go new file mode 100644 index 000000000..e6e16a21a --- /dev/null +++ b/client/ui/services/foreground_other.go @@ -0,0 +1,11 @@ +//go:build !windows && !android && !ios && !freebsd && !js + +package services + +import "github.com/wailsapp/wails/v3/pkg/application" + +func raiseToForeground(w *application.WebviewWindow) { + if w != nil { + w.Focus() + } +} diff --git a/client/ui/services/foreground_windows.go b/client/ui/services/foreground_windows.go new file mode 100644 index 000000000..215f30093 --- /dev/null +++ b/client/ui/services/foreground_windows.go @@ -0,0 +1,43 @@ +//go:build windows + +package services + +import ( + "syscall" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/w32" +) + +var procAttachThreadInput = syscall.NewLazyDLL("user32.dll").NewProc("AttachThreadInput") + +func attachThreadInput(attach, attachTo w32.HANDLE, on bool) { + var flag uintptr + if on { + flag = 1 + } + _, _, _ = procAttachThreadInput.Call(uintptr(attach), uintptr(attachTo), flag) +} + +func raiseToForeground(w *application.WebviewWindow) { + if w == nil { + return + } + application.InvokeSync(func() { + ptr := w.NativeWindow() + if ptr == nil { + return + } + hwnd := w32.HWND(uintptr(ptr)) + + fgThread, _ := w32.GetWindowThreadProcessId(w32.GetForegroundWindow()) + appThread := w32.GetCurrentThreadId() + if fgThread != appThread { + attachThreadInput(fgThread, appThread, true) + defer attachThreadInput(fgThread, appThread, false) + } + w32.ShowWindow(hwnd, w32.SW_SHOW) + w32.BringWindowToTop(hwnd) + w32.SetForegroundWindow(hwnd) + }) +} diff --git a/client/ui/services/forwarding.go b/client/ui/services/forwarding.go new file mode 100644 index 000000000..4ba979ad0 --- /dev/null +++ b/client/ui/services/forwarding.go @@ -0,0 +1,83 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/proto" +) + +// PortRange is a port range; both ends are inclusive. +type PortRange struct { + Start uint32 `json:"start"` + End uint32 `json:"end"` +} + +// PortInfo holds exactly one of Port or Range (the daemon's oneof). +type PortInfo struct { + Port *uint32 `json:"port,omitempty"` + Range *PortRange `json:"range,omitempty"` +} + +// ForwardingRule is one entry from the daemon's reverse-proxy table. +type ForwardingRule struct { + Protocol string `json:"protocol"` + DestinationPort PortInfo `json:"destinationPort"` + TranslatedAddress string `json:"translatedAddress"` + TranslatedHostname string `json:"translatedHostname"` + TranslatedPort PortInfo `json:"translatedPort"` +} + +// Forwarding groups the daemon RPCs that surface exposed/forwarded services. +type Forwarding struct { + conn DaemonConn +} + +func NewForwarding(conn DaemonConn) *Forwarding { + return &Forwarding{conn: conn} +} + +func (s *Forwarding) List(ctx context.Context) ([]ForwardingRule, error) { + cli, err := s.conn.Client() + if err != nil { + return nil, err + } + resp, err := cli.ForwardingRules(ctx, &proto.EmptyRequest{}) + if err != nil { + return nil, err + } + out := make([]ForwardingRule, 0, len(resp.GetRules())) + for _, r := range resp.GetRules() { + out = append(out, forwardingRuleFromProto(r)) + } + return out, nil +} + +func forwardingRuleFromProto(r *proto.ForwardingRule) ForwardingRule { + return ForwardingRule{ + Protocol: r.GetProtocol(), + DestinationPort: portInfoFromProto(r.GetDestinationPort()), + TranslatedAddress: r.GetTranslatedAddress(), + TranslatedHostname: r.GetTranslatedHostname(), + TranslatedPort: portInfoFromProto(r.GetTranslatedPort()), + } +} + +func portInfoFromProto(p *proto.PortInfo) PortInfo { + if p == nil { + return PortInfo{} + } + switch sel := p.GetPortSelection().(type) { + case *proto.PortInfo_Port: + port := sel.Port + return PortInfo{Port: &port} + case *proto.PortInfo_Range_: + r := sel.Range + if r == nil { + return PortInfo{} + } + return PortInfo{Range: &PortRange{Start: r.GetStart(), End: r.GetEnd()}} + } + return PortInfo{} +} diff --git a/client/ui/services/i18n.go b/client/ui/services/i18n.go new file mode 100644 index 000000000..755bbe02d --- /dev/null +++ b/client/ui/services/i18n.go @@ -0,0 +1,30 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/ui/i18n" +) + +// I18n is the Wails-bound facade over i18n.Bundle; the translation logic lives +// in client/ui/i18n. +type I18n struct { + bundle *i18n.Bundle +} + +func NewI18n(bundle *i18n.Bundle) *I18n { + return &I18n{bundle: bundle} +} + +// Languages returns the shipped locales. +func (s *I18n) Languages(_ context.Context) ([]i18n.Language, error) { + return s.bundle.Languages(), nil +} + +// Bundle returns the full key->text map so the React side can drive its own +// translation library off the same source bundles. +func (s *I18n) Bundle(_ context.Context, code i18n.LanguageCode) (map[string]string, error) { + return s.bundle.BundleFor(code) +} diff --git a/client/ui/services/network.go b/client/ui/services/network.go new file mode 100644 index 000000000..c2d127494 --- /dev/null +++ b/client/ui/services/network.go @@ -0,0 +1,88 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/proto" +) + +type Network struct { + ID string `json:"id"` + Range string `json:"range"` + Selected bool `json:"selected"` + Domains []string `json:"domains"` + ResolvedIPs map[string][]string `json:"resolvedIps"` +} + +// SelectNetworksParams: All targets every available network; Append merges IDs into the existing selection. +type SelectNetworksParams struct { + NetworkIDs []string `json:"networkIds"` + Append bool `json:"append"` + All bool `json:"all"` +} + +type Networks struct { + conn DaemonConn +} + +func NewNetworks(conn DaemonConn) *Networks { + return &Networks{conn: conn} +} + +func (s *Networks) List(ctx context.Context) ([]Network, error) { + cli, err := s.conn.Client() + if err != nil { + return nil, err + } + resp, err := cli.ListNetworks(ctx, &proto.ListNetworksRequest{}) + if err != nil { + return nil, err + } + out := make([]Network, 0, len(resp.GetRoutes())) + for _, n := range resp.GetRoutes() { + out = append(out, networkFromProto(n)) + } + return out, nil +} + +func (s *Networks) Select(ctx context.Context, p SelectNetworksParams) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.SelectNetworks(ctx, &proto.SelectNetworksRequest{ + NetworkIDs: p.NetworkIDs, + Append: p.Append, + All: p.All, + }) + return err +} + +func (s *Networks) Deselect(ctx context.Context, p SelectNetworksParams) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + _, err = cli.DeselectNetworks(ctx, &proto.SelectNetworksRequest{ + NetworkIDs: p.NetworkIDs, + Append: p.Append, + All: p.All, + }) + return err +} + +func networkFromProto(n *proto.Network) Network { + resolved := make(map[string][]string, len(n.GetResolvedIPs())) + for k, v := range n.GetResolvedIPs() { + resolved[k] = append([]string{}, v.GetIps()...) + } + return Network{ + ID: n.GetID(), + Range: n.GetRange(), + Selected: n.GetSelected(), + Domains: append([]string{}, n.GetDomains()...), + ResolvedIPs: resolved, + } +} diff --git a/client/ui/services/preferences.go b/client/ui/services/preferences.go new file mode 100644 index 000000000..77faa4ef6 --- /dev/null +++ b/client/ui/services/preferences.go @@ -0,0 +1,40 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" +) + +// Preferences is the Wails-bound facade over preferences.Store; the context.Context-first +// signatures are what the binding generator requires. +type Preferences struct { + store *preferences.Store +} + +func NewPreferences(store *preferences.Store) *Preferences { + return &Preferences{store: store} +} + +func (s *Preferences) Get(_ context.Context) (preferences.UIPreferences, error) { + return s.store.Get(), nil +} + +func (s *Preferences) SetLanguage(_ context.Context, lang i18n.LanguageCode) error { + return s.store.SetLanguage(lang) +} + +func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode) error { + return s.store.SetViewMode(mode) +} + +func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error { + return s.store.SetOnboardingCompleted(done) +} + +func (s *Preferences) SetKeepConnectedOnQuit(_ context.Context, keep bool) error { + return s.store.SetKeepConnectedOnQuit(keep) +} diff --git a/client/ui/services/profile.go b/client/ui/services/profile.go new file mode 100644 index 000000000..e76ab3db6 --- /dev/null +++ b/client/ui/services/profile.go @@ -0,0 +1,202 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "os/user" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +type Profile struct { + // ID is the daemon-generated on-disk identity of the profile. Display + // names can collide and be renamed, so the ID is the stable handle the + // daemon resolves switch/remove/logout requests against. + ID string `json:"id"` + Name string `json:"name"` + IsActive bool `json:"isActive"` + // Email is read from the user-owned per-profile state file (CLI writes it + // after SSO login), not via ListProfiles: the daemon runs as root and can't + // reach it, while the UI runs as the logged-in user. + Email string `json:"email"` +} + +type ProfileRef struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +type ActiveProfile struct { + // ID is the active profile's stable on-disk identity. Use it (not the + // display name) as the handle for daemon requests and active-profile + // comparisons, since names can collide. + ID string `json:"id"` + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +// RenameProfileParams selects a profile by handle and carries its new display +// name. +type RenameProfileParams struct { + // Handle selects the profile to rename: an exact ID, a unique ID prefix, + // or a unique display name. The daemon resolves it server-side. + Handle string `json:"handle"` + // NewName is the new free-form display name. The daemon sanitizes it + // (strips control characters, trims, caps length) but keeps spaces, emoji, + // punctuation, and non-ASCII letters. + NewName string `json:"newName"` + + Username string `json:"username"` +} + +type Profiles struct { + conn DaemonConn +} + +func NewProfiles(conn DaemonConn) *Profiles { + return &Profiles{conn: conn} +} + +// Username returns the OS username the daemon expects for profile lookups. +func (s *Profiles) Username() (string, error) { + u, err := user.Current() + if err != nil { + return "", err + } + return u.Username, nil +} + +func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error) { + cli, err := s.conn.Client() + if err != nil { + return nil, err + } + resp, err := cli.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username}) + if err != nil { + return nil, err + } + pm := profilemanager.NewProfileManager() + out := make([]Profile, 0, len(resp.GetProfiles())) + for _, p := range resp.GetProfiles() { + prof := Profile{ID: p.GetId(), Name: p.GetName(), IsActive: p.GetIsActive()} + if state, err := pm.GetProfileState(profilemanager.ID(p.GetId())); err == nil { + prof.Email = state.Email + } + out = append(out, prof) + } + return out, nil +} + +func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) { + cli, err := s.conn.Client() + if err != nil { + return ActiveProfile{}, err + } + resp, err := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}) + if err != nil { + return ActiveProfile{}, err + } + return ActiveProfile{ + ID: resp.GetId(), + ProfileName: resp.GetProfileName(), + Username: resp.GetUsername(), + }, nil +} + +// Switch sends a profile switch to the daemon and returns the resolved +// on-disk ID of the now-active profile. ProfileName is treated as a handle +// (exact ID, unique ID prefix, or unique display name); the daemon resolves +// it server-side and echoes back the canonical ID. +func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + req := &proto.SwitchProfileRequest{} + if p.ProfileName != "" { + req.ProfileName = ptrStr(p.ProfileName) + } + if p.Username != "" { + req.Username = ptrStr(p.Username) + } + resp, err := cli.SwitchProfile(ctx, req) + if err != nil { + return "", err + } + return resp.GetId(), nil +} + +// Add creates a profile with the given display name and returns its +// daemon-generated on-disk ID, so callers can address the new profile by ID +// (e.g. to write config or switch to it) without re-resolving the name. +func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + resp, err := cli.AddProfile(ctx, &proto.AddProfileRequest{ + ProfileName: p.ProfileName, + Username: p.Username, + }) + if err != nil { + return "", err + } + return resp.GetId(), nil +} + +func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{ + ProfileName: p.ProfileName, + Username: p.Username, + }) + if err != nil { + return err + } + + // The daemon deletes what it owns but runs as root, so it leaves the + // user-owned state file holding the account email behind. Logout keeps the + // email on purpose so later logins can pass it as the login_hint; profile + // removal is what deletes it. Legacy profiles are keyed by name rather than by a + // generated ID, so a recreated profile of the same name would inherit the + // deleted one's email and offer it as the login_hint. + // + // Keyed on the ID the daemon resolved, not on the request handle: that may + // have been a display name or an ID prefix, which would name a different + // file (or none). + if id := resp.GetId(); id != "" { + if err := profilemanager.NewProfileManager().RemoveProfileState(id); err != nil { + // Non-fatal: the profile itself is gone. + log.Warnf("failed to remove profile state for %s: %v", id, err) + } + } + + return nil +} + +// Rename changes a profile's display name. The on-disk ID is unaffected, so +// the active profile and any ID-based references stay valid (the default +// profile can be renamed too — only its display name changes). Returns the +// profile's previous display name as confirmation. +func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, error) { + cli, err := s.conn.Client() + if err != nil { + return "", err + } + resp, err := cli.RenameProfile(ctx, &proto.RenameProfileRequest{ + Username: p.Username, + Handle: p.Handle, + NewProfileName: p.NewName, + }) + if err != nil { + return "", err + } + return resp.GetOldProfileName(), nil +} diff --git a/client/ui/services/profileswitcher.go b/client/ui/services/profileswitcher.go new file mode 100644 index 000000000..727b2473f --- /dev/null +++ b/client/ui/services/profileswitcher.go @@ -0,0 +1,105 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// ProfileSwitcher holds the switch policy shared by the tray and React +// frontend so both flip profiles identically. SwitchActive (plain selection: +// header dropdown, tray submenu) always connects after the switch; +// SwitchActiveNoConnect (manage-profiles screen) never does, so the user can +// still adjust the management URL before connecting. prevStatus from +// DaemonFeed.Get at entry only decides the teardown: +// +// Connected/Connecting/NeedsLogin/LoginFailed/SessionExpired → Down first. +// Idle → no Down. +type ProfileSwitcher struct { + profiles *Profiles + connection *Connection + feed *DaemonFeed +} + +func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *DaemonFeed) *ProfileSwitcher { + return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed} +} + +// SwitchActive switches to the named profile and always connects afterwards. +func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error { + return s.switchActive(ctx, p, true) +} + +// SwitchActiveNoConnect switches to the named profile without connecting, +// tearing down any existing connection first. +func (s *ProfileSwitcher) SwitchActiveNoConnect(ctx context.Context, p ProfileRef) error { + return s.switchActive(ctx, p, false) +} + +func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connect bool) error { + prevStatus := "" + if s.feed != nil { + if st, err := s.feed.Get(ctx); err == nil { + prevStatus = st.Status + } else { + log.Warnf("profileswitcher: get status: %v", err) + } + } + + needsDown := strings.EqualFold(prevStatus, StatusConnected) || + strings.EqualFold(prevStatus, StatusConnecting) || + strings.EqualFold(prevStatus, StatusNeedsLogin) || + strings.EqualFold(prevStatus, StatusLoginFailed) || + strings.EqualFold(prevStatus, StatusSessionExpired) + + log.Infof("profileswitcher: switch profile=%q prevStatus=%q connect=%v needsDown=%v", + p.ProfileName, prevStatus, connect, needsDown) + + // Optimistic Connecting paint plus stale-push suppression during Down (see + // DaemonFeed suppression table); also arms the login-watch that pops + // browser-login when the new profile turns out to need SSO. + if connect && s.feed != nil { + s.feed.BeginProfileSwitch() + } + + resolvedID, err := s.profiles.Switch(ctx, p) + if err != nil { + return fmt.Errorf("switch profile %q: %w", p.ProfileName, err) + } + + // Mirror into the user-side ProfileManager state: the CLI's `netbird up` + // reads this file and sends the ID back in the Up RPC, so if it diverges + // the daemon reverts the UI switch on the next CLI `up`. Best-effort — the + // daemon is authoritative; a failure only leaves the CLI's view stale. + // Use the daemon-resolved ID rather than the handle we sent, since the + // on-disk state is keyed by ID, not display name. + if err := profilemanager.NewProfileManager().SwitchProfile(profilemanager.ID(resolvedID)); err != nil { + log.Warnf("profileswitcher: mirror to user-side ProfileManager failed: %v", err) + } + + if needsDown { + if err := s.connection.Down(ctx); err != nil { + log.Errorf("profileswitcher: Down: %v", err) + } + } + + if connect { + if err := s.connection.Up(ctx, UpParams(p)); err != nil { + return fmt.Errorf("connect %q: %w", p.ProfileName, err) + } + } + + // The daemon emits no profile event, so fan out ourselves or the React + // ProfileContext stays on the old profile after a tray-initiated switch. + if s.feed != nil && s.feed.emitter != nil { + s.feed.emitter.Emit(EventProfileChanged, p) + } + + return nil +} diff --git a/client/ui/services/session.go b/client/ui/services/session.go new file mode 100644 index 000000000..facb8e898 --- /dev/null +++ b/client/ui/services/session.go @@ -0,0 +1,48 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/client/ui/authsession" +) + +// Re-exports so generated bindings reference services.* without importing authsession. +type ( + ExtendStartParams = authsession.ExtendStartParams + ExtendStartResult = authsession.ExtendStartResult + ExtendWaitParams = authsession.ExtendWaitParams + ExtendResult = authsession.ExtendResult +) + +// Session wraps authsession.Session, exposing only the subset the React frontend +// calls; the tray uses authsession.Session directly, keeping the generated TS surface minimal. +type Session struct { + inner *authsession.Session + classifier errorClassifier +} + +// NewSession wraps inner; the caller retains ownership and may use it directly. +// translator or prefs may be nil, in which case errors fall back to the bare code key. +func NewSession(inner *authsession.Session, translator ErrorTranslator, prefs LanguagePreference) *Session { + return &Session{inner: inner, classifier: errorClassifier{translator: translator, prefs: prefs}} +} + +// RequestExtend starts the SSO session-extension flow; the result carries the verification URI to open. +func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) { + res, err := s.inner.RequestExtend(ctx, p) + if err != nil { + return ExtendStartResult{}, s.classifier.classify(err) + } + return res, nil +} + +// WaitExtend blocks until the RequestExtend flow completes; the deadline is nil when the peer is ineligible. +func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) { + res, err := s.inner.WaitExtend(ctx, p) + if err != nil { + return ExtendResult{}, s.classifier.classify(err) + } + return res, nil +} diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go new file mode 100644 index 000000000..74e6f913c --- /dev/null +++ b/client/ui/services/settings.go @@ -0,0 +1,321 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "reflect" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/proto" +) + +type MDMFields struct { + ManagementURL string `json:"managementURL"` + PreSharedKey bool `json:"preSharedKey"` + WireguardPort bool `json:"wireguardPort"` + RosenpassEnabled bool `json:"rosenpassEnabled"` + RosenpassPermissive bool `json:"rosenpassPermissive"` + DisableClientRoutes bool `json:"disableClientRoutes"` + DisableServerRoutes bool `json:"disableServerRoutes"` + AllowServerSSH *bool `json:"allowServerSSH"` + DisableAutoConnect bool `json:"disableAutoConnect"` + DisableAutostart bool `json:"disableAutostart"` + BlockInbound bool `json:"blockInbound"` + DisableMetricsCollection bool `json:"disableMetricsCollection"` + SplitTunnelMode bool `json:"splitTunnelMode"` + SplitTunnelApps bool `json:"splitTunnelApps"` + DisableAdvancedView bool `json:"disableAdvancedView"` +} + +type Features struct { + DisableProfiles bool `json:"disableProfiles"` + DisableNetworks bool `json:"disableNetworks"` + DisableUpdateSettings bool `json:"disableUpdateSettings"` +} + +type Restrictions struct { + MDM MDMFields `json:"mdm"` + Features Features `json:"features"` +} + +// Privilege tells the frontend whether this process may perform the changes the +// daemon restricts to root/administrator, and carries the command for each so a +// disabled control can show the way to do it. +type Privilege struct { + Privileged bool `json:"privileged"` + // Actor names what the operation requires ("root", "administrator privileges"). + Actor string `json:"actor"` + // Commands equivalent to the settings the daemon guards, ready to copy. + AllowSSHServer string `json:"allowSshServer"` + EnableSSHRoot string `json:"enableSshRoot"` + DisableSSHAuth string `json:"disableSshAuth"` +} + +type ConfigParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` +} + +type Config struct { + ManagementURL string `json:"managementUrl"` + AdminURL string `json:"adminUrl"` + ConfigFile string `json:"configFile"` + LogFile string `json:"logFile"` + PreSharedKeySet bool `json:"preSharedKeySet"` + InterfaceName string `json:"interfaceName"` + WireguardPort int64 `json:"wireguardPort"` + MTU int64 `json:"mtu"` + DisableAutoConnect bool `json:"disableAutoConnect"` + ServerSSHAllowed bool `json:"serverSshAllowed"` + RosenpassEnabled bool `json:"rosenpassEnabled"` + RosenpassPermissive bool `json:"rosenpassPermissive"` + DisableNotifications bool `json:"disableNotifications"` + BlockInbound bool `json:"blockInbound"` + NetworkMonitor bool `json:"networkMonitor"` + DisableClientRoutes bool `json:"disableClientRoutes"` + DisableServerRoutes bool `json:"disableServerRoutes"` + DisableDNS bool `json:"disableDns"` + DisableIPv6 bool `json:"disableIpv6"` + BlockLANAccess bool `json:"blockLanAccess"` + EnableSSHRoot bool `json:"enableSshRoot"` + EnableSSHSFTP bool `json:"enableSshSftp"` + EnableSSHLocalPortForwarding bool `json:"enableSshLocalPortForwarding"` + EnableSSHRemotePortForwarding bool `json:"enableSshRemotePortForwarding"` + DisableSSHAuth bool `json:"disableSshAuth"` + SSHJWTCacheTTL int32 `json:"sshJwtCacheTtl"` +} + +// SetConfigParams is a partial update — only non-nil pointer fields are sent +// to the daemon; nil fields are preserved. +type SetConfigParams struct { + ProfileName string `json:"profileName"` + Username string `json:"username"` + ManagementURL string `json:"managementUrl"` + AdminURL string `json:"adminUrl"` + InterfaceName *string `json:"interfaceName,omitempty"` + WireguardPort *int64 `json:"wireguardPort,omitempty"` + MTU *int64 `json:"mtu,omitempty"` + PreSharedKey *string `json:"preSharedKey,omitempty"` + DisableAutoConnect *bool `json:"disableAutoConnect,omitempty"` + ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"` + RosenpassEnabled *bool `json:"rosenpassEnabled,omitempty"` + RosenpassPermissive *bool `json:"rosenpassPermissive,omitempty"` + DisableNotifications *bool `json:"disableNotifications,omitempty"` + BlockInbound *bool `json:"blockInbound,omitempty"` + NetworkMonitor *bool `json:"networkMonitor,omitempty"` + DisableClientRoutes *bool `json:"disableClientRoutes,omitempty"` + DisableServerRoutes *bool `json:"disableServerRoutes,omitempty"` + DisableDNS *bool `json:"disableDns,omitempty"` + DisableIPv6 *bool `json:"disableIpv6,omitempty"` + DisableFirewall *bool `json:"disableFirewall,omitempty"` + BlockLANAccess *bool `json:"blockLanAccess,omitempty"` + EnableSSHRoot *bool `json:"enableSshRoot,omitempty"` + EnableSSHSFTP *bool `json:"enableSshSftp,omitempty"` + EnableSSHLocalPortForwarding *bool `json:"enableSshLocalPortForwarding,omitempty"` + EnableSSHRemotePortForwarding *bool `json:"enableSshRemotePortForwarding,omitempty"` + DisableSSHAuth *bool `json:"disableSshAuth,omitempty"` + SSHJWTCacheTTL *int32 `json:"sshJwtCacheTtl,omitempty"` +} + +type Settings struct { + conn DaemonConn + classifier errorClassifier + // daemonAddr is where the daemon listens, used to tell whether it runs as + // this user and would therefore authorize us: see Privilege. + daemonAddr string +} + +func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings { + return &Settings{ + conn: conn, + classifier: errorClassifier{translator: translator, prefs: prefs}, + daemonAddr: daemonAddr, + } +} + +func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error) { + cli, err := s.conn.Client() + if err != nil { + return Config{}, err + } + resp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{ + ProfileName: p.ProfileName, + Username: p.Username, + }) + if err != nil { + return Config{}, err + } + return Config{ + ManagementURL: resp.GetManagementUrl(), + AdminURL: resp.GetAdminURL(), + ConfigFile: resp.GetConfigFile(), + LogFile: resp.GetLogFile(), + PreSharedKeySet: resp.GetPreSharedKey() != "", + InterfaceName: resp.GetInterfaceName(), + WireguardPort: resp.GetWireguardPort(), + MTU: resp.GetMtu(), + DisableAutoConnect: resp.GetDisableAutoConnect(), + ServerSSHAllowed: resp.GetServerSSHAllowed(), + RosenpassEnabled: resp.GetRosenpassEnabled(), + RosenpassPermissive: resp.GetRosenpassPermissive(), + DisableNotifications: resp.GetDisableNotifications(), + BlockInbound: resp.GetBlockInbound(), + NetworkMonitor: resp.GetNetworkMonitor(), + DisableClientRoutes: resp.GetDisableClientRoutes(), + DisableServerRoutes: resp.GetDisableServerRoutes(), + DisableDNS: resp.GetDisableDns(), + DisableIPv6: resp.GetDisableIpv6(), + BlockLANAccess: resp.GetBlockLanAccess(), + EnableSSHRoot: resp.GetEnableSSHRoot(), + EnableSSHSFTP: resp.GetEnableSSHSFTP(), + EnableSSHLocalPortForwarding: resp.GetEnableSSHLocalPortForwarding(), + EnableSSHRemotePortForwarding: resp.GetEnableSSHRemotePortForwarding(), + DisableSSHAuth: resp.GetDisableSSHAuth(), + SSHJWTCacheTTL: resp.GetSshJWTCacheTTL(), + }, nil +} + +func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error { + cli, err := s.conn.Client() + if err != nil { + return err + } + req := &proto.SetConfigRequest{ + ProfileName: p.ProfileName, + Username: p.Username, + ManagementUrl: p.ManagementURL, + AdminURL: p.AdminURL, + InterfaceName: p.InterfaceName, + WireguardPort: p.WireguardPort, + Mtu: p.MTU, + OptionalPreSharedKey: p.PreSharedKey, + DisableAutoConnect: p.DisableAutoConnect, + ServerSSHAllowed: p.ServerSSHAllowed, + RosenpassEnabled: p.RosenpassEnabled, + RosenpassPermissive: p.RosenpassPermissive, + DisableNotifications: p.DisableNotifications, + BlockInbound: p.BlockInbound, + NetworkMonitor: p.NetworkMonitor, + DisableClientRoutes: p.DisableClientRoutes, + DisableServerRoutes: p.DisableServerRoutes, + DisableDns: p.DisableDNS, + DisableIpv6: p.DisableIPv6, + DisableFirewall: p.DisableFirewall, + BlockLanAccess: p.BlockLANAccess, + EnableSSHRoot: p.EnableSSHRoot, + EnableSSHSFTP: p.EnableSSHSFTP, + EnableSSHLocalPortForwarding: p.EnableSSHLocalPortForwarding, + EnableSSHRemotePortForwarding: p.EnableSSHRemotePortForwarding, + DisableSSHAuth: p.DisableSSHAuth, + SshJWTCacheTTL: p.SSHJWTCacheTTL, + } + if _, err := cli.SetConfig(ctx, req); err != nil { + // Classified so the frontend gets the daemon's guidance instead of the + // gRPC envelope, which is what a refused privileged change looks like. + return s.classifier.classify(err) + } + return nil +} + +// Privilege reports whether this UI process could carry out the changes the +// daemon restricts to root/administrator, and the command that performs the one +// users hit in the SSH settings. It applies the daemon's own rule to what it can +// see locally, so the frontend can present those controls as unavailable up front +// instead of letting a save fail. No daemon round-trip, so it also works while the +// daemon is down. +// +// Being root or an elevated administrator is one way. The other is running as the +// daemon's own user while the daemon is unprivileged, which the daemon accepts +// because such a caller can already rewrite the config it reads; that is the +// rootless-container and Windows netstack-mode case, and it is read from the +// ownership of the socket or pipe the daemon created. +func (s *Settings) Privilege() Privilege { + id, err := ipcauth.CurrentProcessIdentity() + if err != nil { + // Fail closed: report unprivileged, which only ever disables controls. + log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err) + return newPrivilege(false) + } + if id.IsPrivileged() { + return newPrivilege(true) + } + return newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr)) +} + +func newPrivilege(privileged bool) Privilege { + return Privilege{ + Privileged: privileged, + Actor: ipcauth.PrivilegedActor(), + AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"), + EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"), + DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"), + } +} + +func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) { + cli, err := s.conn.Client() + if err != nil { + return Restrictions{}, err + } + active, err := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}) + if err != nil { + return Restrictions{}, fmt.Errorf("get active profile: %w", err) + } + cfgResp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{ + ProfileName: active.GetId(), + Username: active.GetUsername(), + }) + if err != nil { + return Restrictions{}, err + } + featResp, err := cli.GetFeatures(ctx, &proto.GetFeaturesRequest{}) + if err != nil { + return Restrictions{}, err + } + r := Restrictions{ + Features: Features{ + DisableProfiles: featResp.GetDisableProfiles(), + DisableNetworks: featResp.GetDisableNetworks(), + DisableUpdateSettings: featResp.GetDisableUpdateSettings(), + }, + } + applyMDMRestrictions(&r.MDM, cfgResp) + r.MDM.DisableAdvancedView = featResp.GetDisableAdvancedView() + return r, nil +} + +func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) { + managed := cfgResp.GetMDMManagedFields() + if len(managed) == 0 { + return + } + set := make(map[string]struct{}, len(managed)) + for _, k := range managed { + set[k] = struct{}{} + } + v := reflect.ValueOf(mdm).Elem() + t := v.Type() + for i := 0; i < t.NumField(); i++ { + if v.Field(i).Kind() != reflect.Bool { + continue + } + if t.Field(i).Name == "DisableAdvancedView" { + continue + } + if _, ok := set[t.Field(i).Tag.Get("json")]; ok { + v.Field(i).SetBool(true) + } + } + if _, ok := set["managementURL"]; ok { + mdm.ManagementURL = cfgResp.GetManagementUrl() + } + if _, ok := set["allowServerSSH"]; ok { + allowed := cfgResp.GetServerSSHAllowed() + mdm.AllowServerSSH = &allowed + } +} diff --git a/client/ui/services/shutdown.go b/client/ui/services/shutdown.go new file mode 100644 index 000000000..0da51940c --- /dev/null +++ b/client/ui/services/shutdown.go @@ -0,0 +1,24 @@ +package services + +import "sync/atomic" + +var ( + sessionEnding atomic.Bool + quitting atomic.Bool +) + +func BeginSessionEnd() { + sessionEnding.Store(true) +} + +func AbortSessionEnd() { + sessionEnding.Store(false) +} + +func BeginShutdown() { + quitting.Store(true) +} + +func ShuttingDown() bool { + return sessionEnding.Load() || quitting.Load() +} diff --git a/client/ui/services/uilog.go b/client/ui/services/uilog.go new file mode 100644 index 000000000..0d794c137 --- /dev/null +++ b/client/ui/services/uilog.go @@ -0,0 +1,36 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + log "github.com/sirupsen/logrus" +) + +// UILog forwards frontend console output into logrus, tagging the JS origin +// as the "ui" field to stay distinct from logrus's Go-caller source. +type UILog struct{} + +func NewUILog() *UILog { return &UILog{} } + +// Log maps an unrecognised level to info; empty source becomes "unknown". +func (s *UILog) Log(_ context.Context, level, source, msg string) { + origin := "unknown" + if source != "" { + origin = source + } + entry := log.WithField("ui", origin) + switch level { + case "trace": + entry.Trace(msg) + case "debug": + entry.Debug(msg) + case "warn", "warning": + entry.Warn(msg) + case "error": + entry.Error(msg) + default: + entry.Info(msg) + } +} diff --git a/client/ui/services/update.go b/client/ui/services/update.go new file mode 100644 index 000000000..b743b9858 --- /dev/null +++ b/client/ui/services/update.go @@ -0,0 +1,80 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/updater" + "github.com/netbirdio/netbird/version" +) + +// UpdateResult mirrors TriggerUpdateResponse. +type UpdateResult struct { + Success bool `json:"success"` + ErrorMsg string `json:"errorMsg"` +} + +// Update is the Wails-bound facade over the daemon's update RPCs. The state +// machine and push event live in client/ui/updater. +type Update struct { + conn DaemonConn + holder *updater.Holder +} + +func NewUpdate(conn DaemonConn, holder *updater.Holder) *Update { + return &Update{conn: conn, holder: holder} +} + +func (s *Update) GetState() updater.State { + return s.holder.Get() +} + +// DownloadURL returns the platform-appropriate installer download link for +// manual (non-enforced) updates. +func (s *Update) DownloadURL() string { + return version.DownloadUrl() +} + +// Quit exits the app. Scheduled off the calling goroutine so the JS caller's +// response returns before the runtime tears down. +func (s *Update) Quit() { + go func() { + time.Sleep(100 * time.Millisecond) + application.Get().Quit() + }() +} + +func (s *Update) Trigger(ctx context.Context) (UpdateResult, error) { + cli, err := s.conn.Client() + if err != nil { + return UpdateResult{}, err + } + resp, err := cli.TriggerUpdate(ctx, &proto.TriggerUpdateRequest{}) + if err != nil { + return UpdateResult{}, err + } + return UpdateResult{ + Success: resp.GetSuccess(), + ErrorMsg: resp.GetErrorMsg(), + }, nil +} + +func (s *Update) GetInstallerResult(ctx context.Context) (UpdateResult, error) { + cli, err := s.conn.Client() + if err != nil { + return UpdateResult{}, err + } + resp, err := cli.GetInstallerResult(ctx, &proto.InstallerResultRequest{}) + if err != nil { + return UpdateResult{}, err + } + return UpdateResult{ + Success: resp.GetSuccess(), + ErrorMsg: resp.GetErrorMsg(), + }, nil +} diff --git a/client/ui/services/version.go b/client/ui/services/version.go new file mode 100644 index 000000000..8caea9f72 --- /dev/null +++ b/client/ui/services/version.go @@ -0,0 +1,22 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/netbirdio/netbird/version" +) + +// Version reports only the GUI's own version; the daemon version comes from +// the status feed's DaemonVersion field. +type Version struct{} + +func NewVersion() *Version { + return &Version{} +} + +// GUI returns the UI binary's version, stamped via ldflags ("development" if un-stamped). +func (v *Version) GUI(_ context.Context) string { + return version.NetbirdVersion() +} diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go new file mode 100644 index 000000000..5f7aaa7bd --- /dev/null +++ b/client/ui/services/windowmanager.go @@ -0,0 +1,626 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "net/url" + "strconv" + "sync" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" +) + +// LanguageSubscriber delivers UI preference changes so window titles follow the language. +type LanguageSubscriber interface { + Subscribe() (<-chan preferences.UIPreferences, func()) +} + +// EventTriggerLogin asks the frontend's startLogin() to begin an SSO flow. +const EventTriggerLogin = "trigger-login" + +// EventBrowserLoginCancel signals the user dismissed the BrowserLogin popup. +const EventBrowserLoginCancel = "browser-login:cancel" + +// EventSettingsOpen tells the mounted settings window which tab to show. +const EventSettingsOpen = "netbird:settings:open" + +var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950 + +// WindowHeight is shared by the main and Settings windows. +const WindowHeight = 660 + +// Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed). +var microsoftWindowsTheme = &application.WindowTheme{ + BorderColour: u32ptr(0x00211E1C), + TitleBarColour: u32ptr(0x00211E1C), + TitleTextColour: u32ptr(0x00E9E7E4), +} + +// MicrosoftWindowsAppearanceOptions is the shared Windows chrome (Mica + dark + custom title bar). +func MicrosoftWindowsAppearanceOptions() application.WindowsWindow { + return application.WindowsWindow{ + BackdropType: application.Mica, + Theme: application.Dark, + CustomTheme: application.ThemeSettings{ + DarkModeActive: microsoftWindowsTheme, + DarkModeInactive: microsoftWindowsTheme, + LightModeActive: microsoftWindowsTheme, + LightModeInactive: microsoftWindowsTheme, + }, + } +} + +// AppleMacOSAppearanceOptions is the shared macOS chrome; FullScreenNone keeps the fixed-size layout. +func AppleMacOSAppearanceOptions() application.MacWindow { + return application.MacWindow{ + InvisibleTitleBarHeight: 38, + Backdrop: application.MacBackdropNormal, + TitleBar: application.MacTitleBarHiddenInset, + CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone, + } +} + +// LinuxAppearanceOptions is the shared Linux chrome; opaque so fake-translucency compositors paint it. +func LinuxAppearanceOptions(icon []byte) application.LinuxWindow { + return application.LinuxWindow{ + Icon: icon, + WindowIsTranslucent: false, + } +} + +// DialogWindowOptions is the baseline for every auxiliary dialog window; callers override per-dialog. +func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.WebviewWindowOptions { + return application.WebviewWindowOptions{ + Name: name, + Title: title, + Width: 360, + Height: 320, + DisableResize: true, + AlwaysOnTop: true, + Hidden: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + CloseButtonState: application.ButtonEnabled, + BackgroundColour: WindowBackgroundColour, + URL: url, + Mac: AppleMacOSAppearanceOptions(), + Windows: MicrosoftWindowsAppearanceOptions(), + Linux: LinuxAppearanceOptions(linuxIcon), + } +} + +// WindowManager owns the auxiliary windows (main is created in main.go). Settings is created +// eagerly and hidden on close to keep React state; the rest are created on open, destroyed on +// close, so the macOS dock-reopen handler finds no hidden window to resurrect. +type WindowManager struct { + app *application.App + mainWindow *application.WebviewWindow + translator ErrorTranslator + prefs LanguagePreference + linuxIcon []byte + settings *application.WebviewWindow + browserLogin *application.WebviewWindow + sessionExpiration *application.WebviewWindow + installProgress *application.WebviewWindow + welcome *application.WebviewWindow + errorDialog *application.WebviewWindow + // hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close. + hiddenForLogin []application.Window + mu sync.Mutex + // recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor + // restores position; nil on full desktops so re-centering can't fight a user-moved window. + recenterOnShow func() bool +} + +// NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The +// Settings window is created here (hidden) so the first OpenSettings is instant. +func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager { + s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon} + // Re-title live windows on language flip. Wired internally so the binding generator + // doesn't try to expose the interface param. + if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil { + ch, _ := sub.Subscribe() + go func() { + var last i18n.LanguageCode + for p := range ch { + if p.Language == "" || p.Language == last { + continue + } + last = p.Language + s.retitleAll() + } + }() + } + s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "settings", + Title: s.title("window.title.settings"), + Width: 900, + Height: WindowHeight, + Hidden: true, + DisableResize: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + CloseButtonState: application.ButtonEnabled, + BackgroundColour: WindowBackgroundColour, + URL: "/#/settings", + Mac: AppleMacOSAppearanceOptions(), + Windows: MicrosoftWindowsAppearanceOptions(), + Linux: LinuxAppearanceOptions(linuxIcon), + }) + // Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen. + s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + if ShuttingDown() { + return + } + e.Cancel() + s.app.Event.Emit(EventSettingsOpen, "general") + s.settings.Hide() + }) + return s +} + +// OpenSettings shows the settings window on tab (empty → General), switching tab via +// EventSettingsOpen rather than SetURL (which would remount the provider tree). +func (s *WindowManager) OpenSettings(tab string) { + target := tab + if target == "" { + target = "general" + } + s.app.Event.Emit(EventSettingsOpen, target) + s.settings.Show() + s.settings.Focus() + // Re-center (minimal-WM only; see centerWhenReady). + s.centerWhenReady(s.settings) +} + +// OpenBrowserLogin shows the SSO popup, creating it on first use. +func (s *WindowManager) OpenBrowserLogin(uri string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.browserLogin == nil { + startURL := "/#/dialog/browser-login" + if uri != "" { + startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri) + } + s.hideOtherWindowsLocked("browser-login") + opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon) + // Not always-on-top: it would obscure the browser tab the user logs in through. + opts.AlwaysOnTop = false + opts.InitialPosition = application.WindowCentered + // Open on the active (where users cursor is) display, like the session-expiration dialog. + opts.Screen = s.getScreenBasedOnCursorPosition() + s.browserLogin = s.app.Window.NewWithOptions(opts) + bl := s.browserLogin + bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + // Only a live user red-X still has this registered; programmatic closers + // nil s.browserLogin first and clean up themselves. Guarding here stops a + // stale close event from wiping a replacement popup's state. + userClosed := s.browserLogin == bl + if userClosed { + s.browserLogin = nil + s.restoreHiddenWindowsLocked() + } + s.mu.Unlock() + if userClosed { + s.app.Event.Emit(EventBrowserLoginCancel) + } + }) + s.centerOnCursorScreen(s.browserLogin) + return + } + if uri != "" { + s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri)) + } + s.centerOnCursorScreen(s.browserLogin) + s.browserLogin.Show() + s.browserLogin.Focus() +} + +// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the +// app's focal window: tray "Open" and dock activation hand off to it, not the main window. +func (s *WindowManager) BrowserLoginWindow() *application.WebviewWindow { + s.mu.Lock() + defer s.mu.Unlock() + return s.browserLogin +} + +// InstallProgressWindow returns the live install-progress window, or nil. Same focal-window +// contract as BrowserLoginWindow; install supersedes everything, so check this first. +func (s *WindowManager) InstallProgressWindow() *application.WebviewWindow { + s.mu.Lock() + defer s.mu.Unlock() + return s.installProgress +} + +func (s *WindowManager) CloseBrowserLogin() { + s.mu.Lock() + w := s.browserLogin + s.browserLogin = nil + // The WindowClosing hook no-ops on a programmatic close, so restore here — + // but only if a popup was actually open. The frontend calls this even when no + // popup was ever shown (e.g. resetDialog() after an early RequestExtend failure, + // or connection.ts's catch path), and hiddenForLogin is shared with + // OpenInstallProgress, so an unconditional restore could re-show windows a + // still-running install-progress is hiding. + if w != nil { + s.restoreHiddenWindowsLocked() + } + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds +// the countdown. Singleton, destroyed on close. +func (s *WindowManager) OpenSessionExpiration(seconds int) { + s.mu.Lock() + defer s.mu.Unlock() + startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds) + if s.sessionExpiration == nil { + opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon) + opts.Screen = s.getScreenBasedOnCursorPosition() + opts.InitialPosition = application.WindowCentered + s.sessionExpiration = s.app.Window.NewWithOptions(opts) + s.sessionExpiration.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.sessionExpiration = nil + s.mu.Unlock() + }) + s.centerOnCursorScreen(s.sessionExpiration) + return + } + s.sessionExpiration.SetURL(startURL) + s.centerOnCursorScreen(s.sessionExpiration) + s.sessionExpiration.Show() + s.sessionExpiration.Focus() +} + +func (s *WindowManager) CloseSessionExpiration() { + s.mu.Lock() + w := s.sessionExpiration + s.sessionExpiration = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// CloseRenewFlow tears down the SSO session-renewal UI in a single call: it +// closes the browser-login popup and the session-expiration window together. +func (s *WindowManager) CloseRenewFlow() { + s.mu.Lock() + bl := s.browserLogin + se := s.sessionExpiration + s.browserLogin = nil + s.sessionExpiration = nil + if se != nil { + kept := s.hiddenForLogin[:0] + for _, w := range s.hiddenForLogin { + if w != se { + kept = append(kept, w) + } + } + s.hiddenForLogin = kept + } + s.restoreHiddenWindowsLocked() + s.mu.Unlock() + + // Close after unlock so the re-entrant handlers can take s.mu. + if bl != nil { + bl.Close() + } + if se != nil { + se.Close() + } +} + +// OpenInstallProgress shows the install-progress window and hides the rest for the duration +// (restored on close). It owns its own result polling since the daemon restarts mid-install. +func (s *WindowManager) OpenInstallProgress(version string) { + s.mu.Lock() + defer s.mu.Unlock() + startURL := "/#/dialog/install-progress" + if version != "" { + startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version) + } + if s.installProgress == nil { + s.hideOtherWindowsLocked("install-progress") + s.installProgress = s.app.Window.NewWithOptions( + DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL, s.linuxIcon), + ) + s.installProgress.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.installProgress = nil + s.restoreHiddenWindowsLocked() + s.mu.Unlock() + }) + s.centerWhenReady(s.installProgress) + return + } + s.installProgress.SetURL(startURL) + s.installProgress.Show() + s.installProgress.Focus() + s.centerWhenReady(s.installProgress) +} + +func (s *WindowManager) CloseInstallProgress() { + s.mu.Lock() + w := s.installProgress + s.installProgress = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenWelcome shows the first-launch onboarding window. Singleton, destroyed on close. +func (s *WindowManager) OpenWelcome() { + s.mu.Lock() + defer s.mu.Unlock() + if s.welcome == nil { + opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon) + opts.Width = 420 + opts.InitialPosition = application.WindowCentered + s.welcome = s.app.Window.NewWithOptions(opts) + w := s.welcome + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.welcome = nil + s.mu.Unlock() + }) + s.centerWhenReady(s.welcome) + return + } + s.welcome.Show() + s.welcome.Focus() + s.centerWhenReady(s.welcome) +} + +func (s *WindowManager) CloseWelcome() { + s.mu.Lock() + w := s.welcome + s.welcome = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenError shows the custom error dialog; title/message/command are pre-localised +// and ride in the start URL. command is optional and, when set, is offered for +// copying so the user can run the operation the daemon refused. A second error +// replaces the open one via SetURL. Singleton, destroyed on close. +func (s *WindowManager) OpenError(title, message, command string) { + if ShuttingDown() { + return + } + s.mu.Lock() + defer s.mu.Unlock() + startURL := errorDialogURL(title, message, command) + if s.errorDialog == nil { + s.errorDialog = s.app.Window.NewWithOptions( + DialogWindowOptions("error", s.title("window.title.error"), startURL, s.linuxIcon), + ) + s.errorDialog.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.errorDialog = nil + s.mu.Unlock() + }) + s.centerWhenReady(s.errorDialog) + return + } + s.errorDialog.SetURL(startURL) + s.errorDialog.Show() + s.errorDialog.Focus() + s.centerWhenReady(s.errorDialog) +} + +func (s *WindowManager) CloseError() { + s.mu.Lock() + w := s.errorDialog + s.errorDialog = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} + +// OpenMain brings the main window forward; the welcome handoff uses it instead of the tray. +func (s *WindowManager) OpenMain() { + s.ShowMain() +} + +// ShowMain brings the main window forward (re-centering on minimal WMs). The single entry +// point every surface (tray, SIGUSR1, welcome) should use so centering applies uniformly. +func (s *WindowManager) ShowMain() { + if s.mainWindow == nil { + return + } + s.mainWindow.Show() + s.mainWindow.Focus() + // Re-center (minimal-WM only; see centerWhenReady). + s.centerWhenReady(s.mainWindow) +} + +// SetRecenterOnShow installs the recenterOnShow predicate (see the field). +func (s *WindowManager) SetRecenterOnShow(pred func() bool) { + s.recenterOnShow = pred +} + +// centerWhenReady centers w only on minimal WMs (recenterOnShow); elsewhere it +// returns so it never fights a user-moved window. On GTK4 an inline Center() +// no-ops until the GdkSurface is realized (async, after Show) and InvokeAsync +// would deadlock, so a background goroutine retries until Position is non-zero, +// bounded so a window genuinely at the origin can't spin forever. +func (s *WindowManager) centerWhenReady(w *application.WebviewWindow) { + if w == nil || s.recenterOnShow == nil || !s.recenterOnShow() { + return + } + go func() { + for i := 0; i < 50; i++ { // ~1s budget at 20ms steps + w.Center() + if x, y := w.Position(); x != 0 || y != 0 { + return // surface realized + } + time.Sleep(20 * time.Millisecond) + } + }() +} + +// centerOnCursorScreen centers w on the cursor's display; guards no-op on headless sessions. +// On minimal WMs it uses the same realize-detection retry loop as centerWhenReady. +func (s *WindowManager) centerOnCursorScreen(w *application.WebviewWindow) { + if w == nil { + return + } + place := func() { + screen := s.getScreenBasedOnCursorPosition() + if screen == nil { + return + } + width, height := w.Size() + if width <= 0 || height <= 0 { + return + } + wa := screen.WorkArea + if wa.Width <= 0 || wa.Height <= 0 { + return + } + w.SetPosition(wa.X+(wa.Width-width)/2, wa.Y+(wa.Height-height)/2) + } + place() + if s.recenterOnShow == nil || !s.recenterOnShow() { + return + } + go func() { + for i := 0; i < 50; i++ { + place() + if x, y := w.Position(); x != 0 || y != 0 { + return + } + time.Sleep(20 * time.Millisecond) + } + }() +} + +// title resolves a window-title i18n key in the current language, or the raw key if unavailable. +func (s *WindowManager) title(key string) string { + if s.translator == nil { + return key + } + lang := i18n.DefaultLanguage + if s.prefs != nil { + if pref := s.prefs.Get().Language; pref != "" { + lang = pref + } + } + return s.translator.Translate(lang, key) +} + +// retitleAll re-applies the localised title to every live auxiliary window. Pointers are +// snapshotted under s.mu; SetTitle is then safe to call after releasing the lock. +func (s *WindowManager) retitleAll() { + s.mu.Lock() + type pair struct { + win *application.WebviewWindow + key string + } + wins := []pair{ + {s.settings, "window.title.settings"}, + {s.browserLogin, "window.title.signIn"}, + {s.sessionExpiration, "window.title.sessionExpiration"}, + {s.installProgress, "window.title.updating"}, + {s.welcome, "window.title.welcome"}, + {s.errorDialog, "window.title.error"}, + } + s.mu.Unlock() + for _, p := range wins { + if p.win != nil { + p.win.SetTitle(s.title(p.key)) + } + } +} + +// hideOtherWindowsLocked hides every visible window except keepName, recording +// them in hiddenForLogin for restoreHiddenWindowsLocked. Caller must hold s.mu. +func (s *WindowManager) hideOtherWindowsLocked(keepName string) { + for _, w := range s.app.Window.GetAll() { + if w == nil || w.Name() == keepName { + continue + } + if !w.IsVisible() { + continue + } + w.Hide() + s.hiddenForLogin = append(s.hiddenForLogin, w) + } +} + +// restoreHiddenWindowsLocked re-shows windows hidden by hideOtherWindowsLocked +// (caller holds s.mu). If the main window was among them, raiseToForeground +// lifts it above the SSO browser, which still owns the foreground — a plain +// Show/Focus would be demoted to a taskbar flash and leave it stranded behind. +func (s *WindowManager) restoreHiddenWindowsLocked() { + mainRestored := false + for _, w := range s.hiddenForLogin { + if w == nil { + continue + } + w.Show() + if w == s.mainWindow { + mainRestored = true + } + } + s.hiddenForLogin = nil + if mainRestored && s.mainWindow != nil { + raiseToForeground(s.mainWindow) + } +} + +// getScreenBasedOnCursorPosition returns the cursor's display, falling back to the +// main-window screen, then nil (OS-default placement). +func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen { + if s.app == nil || s.app.Screen == nil { + return nil + } + if p, ok := getCursorPosition(s.app); ok { + if sc := s.app.Screen.ScreenNearestDipPoint(p); sc != nil { + return sc + } + } + if s.mainWindow != nil { + if sc, err := s.mainWindow.GetScreen(); err == nil { + return sc + } + } + return nil +} + +// errorDialogURL builds the error window's start URL with title/message/command as escaped query params. +func errorDialogURL(title, message, command string) string { + q := url.Values{} + if title != "" { + q.Set("title", title) + } + if message != "" { + q.Set("message", message) + } + if command != "" { + q.Set("command", command) + } + startURL := "/#/dialog/error" + if enc := q.Encode(); enc != "" { + startURL += "?" + enc + } + return startURL +} + +// u32ptr returns a pointer to v, for the optional *uint32 Wails theme fields. +func u32ptr(v uint32) *uint32 { return &v } diff --git a/client/ui/services/windowtheme_windows.go b/client/ui/services/windowtheme_windows.go new file mode 100644 index 000000000..7dbc1164b --- /dev/null +++ b/client/ui/services/windowtheme_windows.go @@ -0,0 +1,14 @@ +package services + +import "github.com/wailsapp/wails/v3/pkg/w32" + +// Wails assigns w32.AllowDarkModeForWindow only on builds >= 18334 but calls it +// without a nil check when a window requests the Dark theme, crashing older +// builds such as Windows Server 2019 (17763). Those builds still get a dark +// title bar via the pre-20H1 DWM attribute that w32.SetTheme applies, so a +// no-op stub keeps the Dark theme fully working there. +func init() { + if w32.AllowDarkModeForWindow == nil { + w32.AllowDarkModeForWindow = func(w32.HWND, bool) uintptr { return 0 } + } +} diff --git a/client/ui/shutdown_other.go b/client/ui/shutdown_other.go new file mode 100644 index 000000000..6e617233f --- /dev/null +++ b/client/ui/shutdown_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !android && !ios && !freebsd && !js + +package main + +func endSessionInterceptor() func(hwnd uintptr, msg uint32, wParam, lParam uintptr) (uintptr, bool) { + return nil +} diff --git a/client/ui/shutdown_windows.go b/client/ui/shutdown_windows.go new file mode 100644 index 000000000..fbb92a518 --- /dev/null +++ b/client/ui/shutdown_windows.go @@ -0,0 +1,36 @@ +//go:build windows + +package main + +import ( + "os" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/services" +) + +const ( + wmQueryEndSession = 0x0011 + wmEndSession = 0x0016 +) + +func endSessionInterceptor() func(hwnd uintptr, msg uint32, wParam, lParam uintptr) (uintptr, bool) { + return func(_ uintptr, msg uint32, wParam, _ uintptr) (uintptr, bool) { + switch msg { + case wmQueryEndSession: + services.BeginSessionEnd() + return 1, true + case wmEndSession: + if wParam == 0 { + services.AbortSessionEnd() + return 0, true + } + log.Info("windows session is ending; exiting immediately") + os.Exit(0) + return 0, true + default: + return 0, false + } + } +} diff --git a/client/ui/signal_unix.go b/client/ui/signal_unix.go index 99de99f0f..876c5dd6e 100644 --- a/client/ui/signal_unix.go +++ b/client/ui/signal_unix.go @@ -1,76 +1,31 @@ -//go:build !windows && !(linux && 386) +//go:build !windows && !android && !ios && !freebsd && !js package main import ( "context" "os" - "os/exec" "os/signal" "syscall" log "github.com/sirupsen/logrus" ) -// setupSignalHandler sets up a signal handler to listen for SIGUSR1. -// When received, it opens the quick actions window. -func (s *serviceClient) setupSignalHandler(ctx context.Context) { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGUSR1) +// listenForShowSignal lets external tools surface the running UI by signalling its pid (SIGUSR1). +func listenForShowSignal(ctx context.Context, tray *Tray) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGUSR1) go func() { for { select { case <-ctx.Done(): + signal.Stop(sigCh) return - case <-sigChan: - log.Info("received SIGUSR1 signal, opening quick actions window") - s.openQuickActions() + case <-sigCh: + log.Debug("SIGUSR1 received, showing window") + tray.ShowWindow() } } }() } - -// openQuickActions opens the quick actions window by spawning a new process. -func (s *serviceClient) openQuickActions() { - proc, err := os.Executable() - if err != nil { - log.Errorf("get executable path: %v", err) - return - } - - cmd := exec.CommandContext(s.ctx, proc, - "--quick-actions=true", - "--daemon-addr="+s.addr, - ) - - if out := s.attachOutput(cmd); out != nil { - defer func() { - if err := out.Close(); err != nil { - log.Errorf("close log file %s: %v", s.logFile, err) - } - }() - } - - log.Infof("running command: %s --quick-actions=true --daemon-addr=%s", proc, s.addr) - - if err := cmd.Start(); err != nil { - log.Errorf("start quick actions window: %v", err) - return - } - - go func() { - if err := cmd.Wait(); err != nil { - log.Debugf("quick actions window exited: %v", err) - } - }() -} - -// sendShowWindowSignal sends SIGUSR1 to the specified PID. -func sendShowWindowSignal(pid int32) error { - process, err := os.FindProcess(int(pid)) - if err != nil { - return err - } - return process.Signal(syscall.SIGUSR1) -} diff --git a/client/ui/signal_windows.go b/client/ui/signal_windows.go index 58f46374f..86caa6d0c 100644 --- a/client/ui/signal_windows.go +++ b/client/ui/signal_windows.go @@ -5,9 +5,6 @@ package main import ( "context" "errors" - "fmt" - "os" - "os/exec" "time" log "github.com/sirupsen/logrus" @@ -17,155 +14,65 @@ import ( const ( quickActionsTriggerEventName = `Global\NetBirdQuickActionsTriggerEvent` waitTimeout = 5 * time.Second - // SYNCHRONIZE is needed for WaitForSingleObject, EVENT_MODIFY_STATE for ResetEvent. - desiredAccesses = windows.SYNCHRONIZE | windows.EVENT_MODIFY_STATE + desiredAccesses = windows.SYNCHRONIZE | windows.EVENT_MODIFY_STATE + + // WAIT_TIMEOUT return code; not exposed by golang.org/x/sys/windows. + waitTimeoutCode uint32 = 0x00000102 ) -func getEventNameUint16Pointer() (*uint16, error) { - eventNamePtr, err := windows.UTF16PtrFromString(quickActionsTriggerEventName) - if err != nil { - log.Errorf("Failed to convert event name '%s' to UTF16: %v", quickActionsTriggerEventName, err) - return nil, err - } - - return eventNamePtr, nil -} - -// setupSignalHandler sets up signal handling for Windows. -// Windows doesn't support SIGUSR1, so this uses a similar approach using windows.Events. -func (s *serviceClient) setupSignalHandler(ctx context.Context) { - eventNamePtr, err := getEventNameUint16Pointer() +// listenForShowSignal shows the main window when an external process pulses the named event. +func listenForShowSignal(ctx context.Context, tray *Tray) { + namePtr, err := windows.UTF16PtrFromString(quickActionsTriggerEventName) if err != nil { + log.Errorf("trigger event name: %v", err) return } - eventHandle, err := windows.CreateEvent(nil, 1, 0, eventNamePtr) - + handle, err := windows.CreateEvent(nil, 1, 0, namePtr) if err != nil { - if errors.Is(err, windows.ERROR_ALREADY_EXISTS) { - log.Warnf("Quick actions trigger event '%s' already exists. Attempting to open.", quickActionsTriggerEventName) - eventHandle, err = windows.OpenEvent(desiredAccesses, false, eventNamePtr) - if err != nil { - log.Errorf("Failed to open existing quick actions trigger event '%s': %v", quickActionsTriggerEventName, err) - return - } - log.Infof("Successfully opened existing quick actions trigger event '%s'.", quickActionsTriggerEventName) - } else { - log.Errorf("Failed to create quick actions trigger event '%s': %v", quickActionsTriggerEventName, err) + if !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + log.Errorf("create trigger event %q: %v", quickActionsTriggerEventName, err) + return + } + handle, err = windows.OpenEvent(desiredAccesses, false, namePtr) + if err != nil { + log.Errorf("open trigger event %q: %v", quickActionsTriggerEventName, err) return } } - if eventHandle == windows.InvalidHandle { - log.Errorf("Obtained an invalid handle for quick actions trigger event '%s'", quickActionsTriggerEventName) + if handle == windows.InvalidHandle { + log.Errorf("invalid handle for trigger event %q", quickActionsTriggerEventName) return } - log.Infof("Quick actions handler waiting for signal on event: %s", quickActionsTriggerEventName) - - go s.waitForEvent(ctx, eventHandle) + go waitForTrigger(ctx, handle, tray) } -func (s *serviceClient) waitForEvent(ctx context.Context, eventHandle windows.Handle) { +func waitForTrigger(ctx context.Context, handle windows.Handle, tray *Tray) { defer func() { - if err := windows.CloseHandle(eventHandle); err != nil { - log.Errorf("Failed to close quick actions event handle '%s': %v", quickActionsTriggerEventName, err) + if err := windows.CloseHandle(handle); err != nil { + log.Errorf("close trigger event handle: %v", err) } }() + timeoutMs := uint32(waitTimeout / time.Millisecond) for { if ctx.Err() != nil { return } - - status, err := windows.WaitForSingleObject(eventHandle, uint32(waitTimeout.Milliseconds())) - - switch status { - case windows.WAIT_OBJECT_0: - log.Info("Received signal on quick actions event. Opening quick actions window.") - - // reset the event so it can be triggered again later (manual reset == 1) - if err := windows.ResetEvent(eventHandle); err != nil { - log.Errorf("Failed to reset quick actions event '%s': %v", quickActionsTriggerEventName, err) - } - - s.openQuickActions() - case uint32(windows.WAIT_TIMEOUT): - - default: - if isDone := logUnexpectedStatus(ctx, status, err); isDone { - return + ev, err := windows.WaitForSingleObject(handle, timeoutMs) + switch { + case err != nil: + log.Errorf("wait trigger event: %v", err) + return + case ev == waitTimeoutCode: + continue + case ev == windows.WAIT_OBJECT_0: + if err := windows.ResetEvent(handle); err != nil { + log.Errorf("reset trigger event: %v", err) } + tray.ShowWindow() } } } - -func logUnexpectedStatus(ctx context.Context, status uint32, err error) bool { - log.Errorf("Unexpected status %d from WaitForSingleObject for quick actions event '%s': %v", - status, quickActionsTriggerEventName, err) - select { - case <-time.After(5 * time.Second): - return false - case <-ctx.Done(): - return true - } -} - -// openQuickActions opens the quick actions window by spawning a new process. -func (s *serviceClient) openQuickActions() { - proc, err := os.Executable() - if err != nil { - log.Errorf("get executable path: %v", err) - return - } - - cmd := exec.CommandContext(s.ctx, proc, - "--quick-actions=true", - "--daemon-addr="+s.addr, - ) - - if out := s.attachOutput(cmd); out != nil { - defer func() { - if err := out.Close(); err != nil { - log.Errorf("close log file %s: %v", s.logFile, err) - } - }() - } - - log.Infof("running command: %s --quick-actions=true --daemon-addr=%s", proc, s.addr) - - if err := cmd.Start(); err != nil { - log.Errorf("error starting quick actions window: %v", err) - return - } - - go func() { - if err := cmd.Wait(); err != nil { - log.Debugf("quick actions window exited: %v", err) - } - }() -} - -func sendShowWindowSignal(pid int32) error { - _, err := os.FindProcess(int(pid)) - if err != nil { - return err - } - - eventNamePtr, err := getEventNameUint16Pointer() - if err != nil { - return err - } - - eventHandle, err := windows.OpenEvent(desiredAccesses, false, eventNamePtr) - if err != nil { - return err - } - - err = windows.SetEvent(eventHandle) - if err != nil { - return fmt.Errorf("error setting event: %w", err) - } - - return nil -} diff --git a/client/ui/tray.go b/client/ui/tray.go new file mode 100644 index 000000000..148dd50b3 --- /dev/null +++ b/client/ui/tray.go @@ -0,0 +1,553 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "runtime" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" + "github.com/netbirdio/netbird/client/ui/services" + "github.com/netbirdio/netbird/version" +) + +// Notification IDs are OS dedup keys that coalesce duplicate toasts; +// statusError is a tray-only sentinel for the error-icon state. +const ( + notifyIDUpdatePrefix = "netbird-update-" + notifyIDEvent = "netbird-event-" + notifyIDTrayError = "netbird-tray-error" + notifyIDMDMPolicy = "netbird-mdm-policy" + + statusError = "Error" + + quitDownTimeout = 5 * time.Second + + urlGitHubRepo = "https://github.com/netbirdio/netbird" + urlDocs = "https://docs.netbird.io" +) + +// TrayServices bundles the services the tray menu needs, grouped so NewTray +// stays under the linter's parameter-count threshold. +type TrayServices struct { + Connection *services.Connection + Settings *services.Settings + Profiles *services.Profiles + Networks *services.Networks + DaemonFeed *services.DaemonFeed + Notifier *Notifier + Update *services.Update + ProfileSwitcher *services.ProfileSwitcher + WindowManager *services.WindowManager + // Session is bound to authsession directly because the services wrapper + // only re-exposes the React subset. + Session *authsession.Session + Localizer *Localizer + Preferences *preferences.Store +} + +type Tray struct { + app *application.App + tray *application.SystemTray + window *application.WebviewWindow + svc TrayServices + // panelDark reports whether the desktop panel uses a dark scheme, so + // iconForState can pick the black vs white mono tray icon on Linux. Set + // by startTrayTheme (Linux only); nil elsewhere, where panelIsDark falls + // back to its default. + panelDark func() bool + loc *Localizer + + // menu and the *Item/*Submenu fields below are reassigned by buildMenu + // on every relayout — touch them only with menuMu held. Exceptions: + // the Connect/Disconnect OnClick closures capture their own item, and + // refreshSessionExpiresLabel snapshots its item under menuMu. + menu *application.Menu + statusItem *application.MenuItem + // sessionExpiresItem shows the SSO deadline as a remaining-time label, + // repainted by a 30s ticker. + sessionExpiresItem *application.MenuItem + upItem *application.MenuItem + downItem *application.MenuItem + exitNodeItem *application.MenuItem + exitNodeSubmenu *application.Menu + profileSubmenu *application.Menu + profileSubmenuItem *application.MenuItem + profileEmailItem *application.MenuItem + settingsItem *application.MenuItem + daemonVersionItem *application.MenuItem + + updater *trayUpdater + + // statusMu guards the daemon-status core mirrored on the tray. One mutex + // covers these fields because applyStatus writes them together on every + // Status push and the menu painters read them. + statusMu sync.Mutex + connected bool + lastStatus string + lastDaemonVersion string + // lastNetworksRevision is the daemon's routed-networks revision; a bump (or + // a connect/disconnect transition) gates the refreshExitNodes re-fetch so + // ListNetworks runs only when routes change. The peer-status route list + // can't substitute: it carries only actively-routed routes, not candidate + // exit nodes. + lastNetworksRevision uint64 + // pendingConnectLogin is set when handleConnect fires an Up on an idle + // daemon. The daemon flips to NeedsLogin if the peer is SSO-tracked with + // no cached token; applyStatus consumes the flag on that transition to + // open the browser-login flow, saving a second Connect click. + // Profile-switch reconnects are handled separately by + // DaemonFeed.statusStreamLoop. + pendingConnectLogin bool + + // sessionMu guards the cached SSO deadline used by the session row. + // Independent of statusMu so the 30s ticker reader and the Status-push + // writer don't block each other. + sessionMu sync.Mutex + sessionExpiresAt time.Time + + // profileMu guards the profile-domain state (active identity, the + // notifications gate, the in-flight switch cancel). Independent of + // statusMu so a long-running switch holding switchCancel doesn't block a + // Status-push reader of t.connected. + profileMu sync.Mutex + activeProfile string + activeUsername string + notificationsEnabled bool + switchCancel context.CancelFunc + + // profileLoadMu serializes loadProfiles so the applyStatus refresh can't + // race the ApplicationStarted seed or the post-switch reload — all + // manipulate profileSubmenu + SetMenu, which Wails isn't concurrency-safe + // against. + profileLoadMu sync.Mutex + + // profilesMu guards the cached profile rows that relayoutMenu repaints + // into a freshly built Profiles submenu, kept separate from the live + // submenu so a relayout always has a source to repaint from without + // re-hitting the daemon. + profilesMu sync.Mutex + profiles []services.Profile + profilesUser string + + // menuMu serialises relayoutMenu (buildMenu + SetMenu) and guards the + // menu/item-pointer fields above. relayoutMenu is the only post-startup + // SetMenu call site — a menu snapshot pushed outside the lock could + // reinstall a stale tree. + menuMu sync.Mutex + + // exitNodesMu guards the exitNodes row cache so relayoutMenu's read (and + // the Repaint copy) doesn't contend with status-push readers of statusMu. + exitNodesMu sync.Mutex + exitNodes []exitNodeEntry + // exitNodesRebuildMu serialises the ListNetworks fetch + submenu rebuild + + // SetMenu cycle so back-to-back Status pushes can't run it concurrently + // with itself. + exitNodesRebuildMu sync.Mutex + + // featureMu guards the daemon feature kill switches mirrored on the tray. + // Fetched at startup and refreshed on every config_changed event (the + // daemon re-applies MDM policy per engine spawn), so featuresDisabled can + // grey out menus without polling GetFeatures. + featureMu sync.Mutex + disableProfiles bool + disableNetworks bool +} + +func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray { + t := &Tray{ + app: app, + window: window, + svc: svc, + notificationsEnabled: true, + // Localizer is constructed by main so the first menu render is already + // in the right locale — no English flash then re-paint. + loc: svc.Localizer, + } + t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() }) + t.tray = app.SystemTray.New() + // Seed panel-theme detection before the first paint so the initial icon + // matches the panel's light/dark scheme (Linux only). + t.startTrayTheme() + t.applyIcon() + t.tray.SetTooltip(t.loc.T("tray.tooltip")) + // On Linux the SNI hover tooltip rides on the systray label, not + // SetTooltip (a no-op there); without a label Wails shows the literal + // "Wails". macOS/Windows are skipped (label paints visible text on + // macOS; Windows uses SetTooltip above). + if runtime.GOOS == "linux" { + t.tray.SetLabel(t.loc.T("tray.tooltip")) + } + t.menu = t.buildMenu() + t.tray.SetMenu(t.menu) + // macOS/Linux give click→menu natively, so bindTrayClick is a no-op there + // (binding OnClick→OpenMenu on macOS would freeze the tray); Windows has no + // native left-click handler so it wires one to open the main window, leaving + // the menu on right-click (see tray_click_*.go). On Linux AttachWindow is + // skipped — with applySmartDefaults it would pop the window alongside the + // menu (e.g. GNOME Shell AppIndicator). + bindTrayClick(t) + + app.Event.On(services.EventStatusSnapshot, t.onStatusEvent) + app.Event.On(services.EventDaemonNotification, t.onSystemEvent) + // Refresh the Profiles submenu on ProfileSwitcher's change event. A + // switch on an idle daemon drives no status transition, so without this + // hook a React-initiated switch leaves the tray's submenu stale. + app.Event.On(services.EventProfileChanged, func(*application.CustomEvent) { + go t.loadProfiles() + }) + // Defer the first profile load until the menu impl is live — Menu.Update() + // short-circuits while app.running is false, and AppKit's main queue isn't + // ready earlier (see d23ef34 InvokeSync nil-deref). + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + go t.loadProfiles() + go t.refreshRestrictions() + go t.runSessionExpiryTicker() + // Category registration must run after the notifications service + // Startup populates appName/registry path on Windows; before app.Run() + // the category lookup silently falls back to a plain notification. + t.registerSessionWarningCategory() + }) + + t.loc.Watch(func(i18n.LanguageCode) { t.applyLanguage() }) + + go t.loadConfig() + return t +} + +// ShowWindow brings the main window forward — used by SIGUSR1 / Windows event. +// Show() alone is not enough on macOS (makeKeyAndOrderFront skips activation, +// so the window pops up behind the active app); Focus() additionally calls +// activateIgnoringOtherApps:YES on macOS and SetForegroundWindow on Windows. +func (t *Tray) ShowWindow() { + // An install supersedes every other flow, so check it before BrowserLogin. + if w := t.svc.WindowManager.InstallProgressWindow(); w != nil { + w.Show() + w.Focus() + return + } + if w := t.svc.WindowManager.BrowserLoginWindow(); w != nil { + w.Show() + w.Focus() + return + } + if t.window == nil { + return + } + // Route through WindowManager so the main window is centered on first + // show — minimal WMs (fluxbox, the XEmbed tray path) otherwise drop it in + // the top-left corner. + if t.svc.WindowManager != nil { + t.svc.WindowManager.ShowMain() + return + } + t.window.Show() + t.window.Focus() +} + +// applyLanguage re-renders every translated surface in the Localizer's current +// language. Wails dispatches menu/tray APIs onto the UI thread internally, so +// calling them from the Localizer's background goroutine is safe; profileLoadMu +// prevents loadProfiles from racing the rebuild. +func (t *Tray) applyLanguage() { + t.tray.SetTooltip(t.loc.T("tray.tooltip")) + // Mirror the Linux label fix from NewTray (the SNI tooltip rides on the + // label). + if runtime.GOOS == "linux" { + t.tray.SetLabel(t.loc.T("tray.tooltip")) + } + t.relayoutMenu() +} + +// relayoutMenu rebuilds the entire tray menu, repaints the cached +// status/session/profile/exit-node state into the fresh items, and pushes the +// whole tree with a single SetMenu. +// +// A full rebuild is required because on KDE/Plasma the StatusNotifierItem host +// caches a submenu's layout on first open (GetLayout for that submenu id) and +// never re-fetches it on a LayoutUpdated(parent=0) signal — so Clear()+Add() +// into the same container froze both the visible rows and the click→id mapping, +// and stale ids no-op'd. buildMenu allocates a fresh submenu container id each +// time, which Plasma treats as unseen and re-queries (confirmed via +// dbus-monitor). This also covers the darwin detached-NSMenu workaround, since +// it rebuilds the whole tree against the cached top-level pointer. +// +// Rows come from the profilesMu/exitNodes caches, so it never re-hits the +// daemon or recurses back into loadProfiles. +func (t *Tray) relayoutMenu() { + t.menuMu.Lock() + defer t.menuMu.Unlock() + + t.menu = t.buildMenu() + + t.statusMu.Lock() + connected := t.connected + lastStatus := t.lastStatus + daemonVersion := t.lastDaemonVersion + t.statusMu.Unlock() + + t.sessionMu.Lock() + sessionDeadline := t.sessionExpiresAt + t.sessionMu.Unlock() + + t.exitNodesMu.Lock() + exitNodeEntries := append([]exitNodeEntry(nil), t.exitNodes...) + t.exitNodesMu.Unlock() + + disableProfiles, disableNetworks := t.featuresDisabled() + + daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable) + connecting := strings.EqualFold(lastStatus, services.StatusConnecting) + + if t.statusItem != nil && lastStatus != "" { + t.statusItem.SetLabel(t.loc.StatusLabel(lastStatus)) + t.statusItem.SetEnabled(statusRowEnabled()) + t.applyStatusIndicator(lastStatus) + } + if t.sessionExpiresItem != nil { + if sessionDeadline.IsZero() { + t.sessionExpiresItem.SetHidden(true) + } else { + t.sessionExpiresItem.SetLabel(t.sessionRowLabel(sessionDeadline)) + t.sessionExpiresItem.SetHidden(false) + } + } + if t.upItem != nil { + // Connect stays visible in the NeedsLogin states too — Up drives + // the SSO re-auth flow; hidden only when it would be a no-op. + t.upItem.SetHidden(connected || connecting || daemonUnavailable) + t.upItem.SetEnabled(!connected && !connecting && !daemonUnavailable) + } + if t.downItem != nil { + // Disconnect doubles as the Connecting abort path. + t.downItem.SetHidden(!connected && !connecting) + t.downItem.SetEnabled(connected || connecting) + } + if t.exitNodeItem != nil { + t.exitNodeItem.SetEnabled(connected && len(exitNodeEntries) > 0 && !disableNetworks) + } + if t.settingsItem != nil { + t.settingsItem.SetEnabled(!daemonUnavailable) + } + if t.profileSubmenuItem != nil { + t.profileSubmenuItem.SetEnabled(!daemonUnavailable && !disableProfiles) + } + if daemonVersion != "" && t.daemonVersionItem != nil { + t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", daemonVersion)) + } + if t.updater != nil { + t.updater.applyLanguage() + } + // buildMenu recreated empty submenus, so repaint both from their caches + // before SetMenu. Neither fill re-fetches. Do NOT re-take + // exitNodesRebuildMu here — refreshExitNodes already holds it when it + // calls relayoutMenu. + t.fillExitNodeSubmenu(exitNodeEntries) + t.fillProfileSubmenu() + + // Single push of the whole tree: on Linux one LayoutUpdated with fresh + // container ids; on darwin an NSMenu rebuild against the cached pointer. + t.tray.SetMenu(t.menu) +} + +func (t *Tray) buildMenu() *application.Menu { + menu := application.NewMenu() + + // Enabled state is platform-dependent (see statusRowEnabled): Windows keeps + // it enabled because the disabled mask would desaturate the coloured status + // dot; macOS/Linux disable it so the greyed label signals it isn't + // clickable. + t.statusItem = menu.Add(t.loc.T("tray.status.disconnected")). + SetEnabled(statusRowEnabled()). + SetBitmap(iconMenuDotIdle) + + menu.AddSeparator() + + // The OnClick closures capture the local item because t.upItem/t.downItem + // are menuMu-guarded and must not be read from the click goroutine. + upItem := menu.Add(t.loc.T("tray.menu.connect")) + upItem.OnClick(func(*application.Context) { t.handleConnect(upItem) }) + t.upItem = upItem + downItem := menu.Add(t.loc.T("tray.menu.disconnect")) + downItem.OnClick(func(*application.Context) { t.handleDisconnect(downItem) }) + downItem.SetHidden(true) + t.downItem = downItem + + menu.AddSeparator() + + // Populated asynchronously once the app has started — Menu.Update() is a + // no-op before app.running is true, so the initial fill is gated on the + // ApplicationStarted hook. + profilesLabel := t.loc.T("tray.menu.profiles") + t.profileSubmenu = menu.AddSubmenu(profilesLabel) + // AddSubmenu returns the child *Menu, so retrieve the parent *MenuItem via + // FindByLabel. + t.profileSubmenuItem = menu.FindByLabel(profilesLabel) + t.profileEmailItem = menu.Add("").SetEnabled(false) + t.profileEmailItem.SetHidden(true) + // Click opens the SessionExpiration window so the user can extend ahead of + // the daemon's T-FinalWarningLead auto-prompt. + t.sessionExpiresItem = menu.Add("").OnClick(func(*application.Context) { t.openSessionExtendFlow() }) + t.sessionExpiresItem.SetHidden(true) + + menu.AddSeparator() + // Accelerators on the Settings/Quit entries below are a no-op on Windows in + // Wails v3 alpha.95 (impl commented out in menuitem_windows.go); still set + // for forward compatibility. macOS/GTK render and fire them. + menu.Add(t.loc.T("tray.menu.open")).OnClick(func(*application.Context) { t.ShowWindow() }) + + menu.AddSeparator() + + // exitNodeSubmenu hosts one row per peer advertising a default route + // (0.0.0.0/0 or ::/0). FindByLabel grabs the parent so applyStatus can flip + // its enabled state independently of the children. + exitNodeLabel := t.loc.T("tray.menu.exitNode") + t.exitNodeSubmenu = menu.AddSubmenu(exitNodeLabel) + t.exitNodeItem = menu.FindByLabel(exitNodeLabel) + t.exitNodeItem.SetEnabled(false) + + menu.AddSeparator() + + // The label's trailing ellipsis follows the macOS HIG convention for items + // that open a window. + t.settingsItem = menu.Add(t.loc.T("tray.menu.settings")). + SetAccelerator("CmdOrCtrl+,"). + OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings("") }) + + aboutLabel := menuLabel(t.loc.T("tray.menu.about")) + about := menu.AddSubmenu(aboutLabel) + about.Add(t.loc.T("tray.menu.github")).OnClick(func(*application.Context) { + _ = t.app.Browser.OpenURL(urlGitHubRepo) + }) + about.Add(t.loc.T("tray.menu.documentation")).OnClick(func(*application.Context) { + _ = t.app.Browser.OpenURL(urlDocs) + }) + about.Add(t.loc.T("tray.menu.troubleshoot")).OnClick(func(*application.Context) { + t.svc.WindowManager.OpenSettings("troubleshooting") + }) + about.AddSeparator() + about.Add(t.loc.T("tray.menu.guiVersion", "version", version.NetbirdVersion())).SetEnabled(false) + t.daemonVersionItem = about.Add(t.loc.T("tray.menu.daemonVersion", "version", t.loc.T("tray.menu.versionUnknown"))).SetEnabled(false) + // trayUpdater rewrites the label between downloadLatest (opt-in) and + // installVersion (enforced) and drives the click. + updateItem := about.Add(t.loc.T("tray.menu.downloadLatest")). + OnClick(func(*application.Context) { t.updater.handleClick() }) + updateItem.SetHidden(true) + t.updater.attach(updateItem) + + menu.AddSeparator() + menu.Add(t.loc.T("tray.menu.quit")). + SetAccelerator("CmdOrCtrl+Q"). + OnClick(func(*application.Context) { t.handleQuit() }) + + return menu +} + +func (t *Tray) handleQuit() { + services.BeginShutdown() + t.profileMu.Lock() + if t.switchCancel != nil { + t.switchCancel() + t.switchCancel = nil + } + t.profileMu.Unlock() + t.svc.DaemonFeed.CancelProfileSwitch() + + if t.svc.Preferences == nil || !t.svc.Preferences.Get().KeepConnectedOnQuit { + ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout) + defer cancel() + if err := t.svc.Connection.Down(ctx); err != nil { + log.Errorf("disconnect on quit: %v", err) + } + } + t.app.Quit() +} + +// handleConnect receives the clicked item from the buildMenu closure — +// t.upItem is menuMu-guarded and must not be read here. +func (t *Tray) handleConnect(upItem *application.MenuItem) { + // NeedsLogin/SessionExpired/LoginFailed won't honor a plain Up RPC — they + // need the Login → WaitSSOLogin → Up sequence. Emit EventTriggerLogin so + // the React startLogin() (which owns the BrowserLogin popup) drives it; + // the hidden main webview is alive and subscribed, so only the popup shows. + t.statusMu.Lock() + needsLogin := strings.EqualFold(t.lastStatus, services.StatusNeedsLogin) || + strings.EqualFold(t.lastStatus, services.StatusSessionExpired) || + strings.EqualFold(t.lastStatus, services.StatusLoginFailed) + t.statusMu.Unlock() + if needsLogin { + t.app.Event.Emit(services.EventTriggerLogin) + return + } + upItem.SetEnabled(false) + // Arm the SSO auto-handoff: Up() is async and the daemon may flip to + // NeedsLogin on an SSO peer with no cached token. applyStatus consumes the + // flag on that transition to trigger browser-login without a second Connect + // click, and clears it on any terminal state. + t.statusMu.Lock() + t.pendingConnectLogin = true + t.statusMu.Unlock() + go func() { + if err := t.svc.Connection.Up(context.Background(), services.UpParams{}); err != nil { + log.Errorf("connect: %v", err) + t.notifyError(t.loc.T("notify.error.connect")) + t.statusMu.Lock() + t.pendingConnectLogin = false + t.statusMu.Unlock() + upItem.SetEnabled(true) + } + }() +} + +// handleDisconnect aborts any in-flight profile switch before sending Down — +// otherwise the switcher's queued Up would reconnect right after, making the +// click a no-op. Also clears Peers' optimistic-Connecting guard so the daemon's +// Idle push paints through instead of being swallowed by the suppression filter. +// Receives the clicked item from the buildMenu closure (see handleConnect). +func (t *Tray) handleDisconnect(downItem *application.MenuItem) { + downItem.SetEnabled(false) + t.profileMu.Lock() + if t.switchCancel != nil { + t.switchCancel() + t.switchCancel = nil + } + t.profileMu.Unlock() + t.svc.DaemonFeed.CancelProfileSwitch() + go func() { + if err := t.svc.Connection.Down(context.Background()); err != nil { + log.Errorf("disconnect: %v", err) + t.notifyError(t.loc.T("notify.error.disconnect")) + downItem.SetEnabled(true) + } + }() +} + +// notify wraps the Wails notification service with the tray's standard +// id-prefix scheme and swallows errors (notifications are best-effort). +func (t *Tray) notify(title, body, id string) { + if t.svc.Notifier == nil { + return + } + _ = safeSendNotification(t.svc.Notifier.SendNotification, title, notifications.NotificationOptions{ + ID: id, + Title: title, + Body: body, + }) +} + +// notifyError fires a generic "Error" notification for tray-driven action +// failures. Each tray click site already logs the underlying error; this +// adds the user-visible toast. +func (t *Tray) notifyError(message string) { + t.notify(t.loc.T("notify.error.title"), message, notifyIDTrayError) +} diff --git a/client/ui/tray_click_linux.go b/client/ui/tray_click_linux.go new file mode 100644 index 000000000..95f5dfe85 --- /dev/null +++ b/client/ui/tray_click_linux.go @@ -0,0 +1,40 @@ +//go:build linux && !(linux && 386) + +package main + +// bindTrayClick wires the tray icon's left-click handler on Linux. +// +// Expected behaviour per tray host: +// +// Host Left click Right click +// KDE Plasma, Waybar main window (Activate) menu (host-rendered) +// GNOME Shell + AppIndicator menu only menu only +// Minimal WMs via XEmbed host main window (Activate) XEmbed GTK popup +// +// OnClick fires only on org.kde.StatusNotifierItem.Activate — a real left +// click. KDE/Waybar send it over D-Bus; the in-process XEmbed host +// (xembed_host_linux.go) maps a Button1 press to the same Activate call. +// +// GNOME Shell + AppIndicator never sends Activate: it renders the dbusmenu +// on ANY click and only reports the menu opening via dbusmenu +// Event("opened"). Upstream Wails treated that event as a click, so on GNOME +// both buttons raised the main window on top of the menu, and on KDE/Waybar +// a right click raised it over the freshly opened menu. The netbirdio/wails +// fork (go.mod replace) drops that heuristic: a menu open never fires +// OnClick. On GNOME the main window is reached via the "Open NetBird" menu +// entry; left-click-opens-window is not achievable there anyway, since the +// host always opens the menu itself. +// +// We do NOT register OnDoubleClick: Wails' Linux SNI backend never fires it +// (unlike Windows). And we deliberately skip AttachWindow — it plus Wails3's +// applySmartDefaults would pop the window alongside the menu on GNOME Shell +// with the AppIndicator extension (see the bindTrayClick comment in tray.go). +// +// ShowWindow() is the same dispatcher the explicit "Open NetBird" menu entry +// and SIGUSR1 use: it brings the install-progress / browser-login window +// forward when one of those flows is active, otherwise routes through +// WindowManager.ShowMain so the window re-centers on minimal WMs / the XEmbed +// path instead of landing in the top-left corner. +func bindTrayClick(t *Tray) { + t.tray.OnClick(func() { t.ShowWindow() }) +} diff --git a/client/ui/tray_click_other.go b/client/ui/tray_click_other.go new file mode 100644 index 000000000..e6a29e419 --- /dev/null +++ b/client/ui/tray_click_other.go @@ -0,0 +1,13 @@ +//go:build !windows && !android && !ios && !freebsd && !js && (!linux || (linux && 386)) + +package main + +func bindTrayClick(*Tray) { + // No-op: macOS's native NSStatusItem opens the menu on click itself, and + // binding OnClick→anything blocking there froze the tray historically + // (see tray_click_windows.go). Windows wires an explicit handler + // (tray_click_windows.go); Linux opens the window on left-click + // (tray_click_linux.go). The (linux && 386) arm keeps a no-op fallback for + // the i386 Linux build, which excludes the cgo XEmbed/SNI files that + // tray_click_linux.go's build tag matches. +} diff --git a/client/ui/tray_click_windows.go b/client/ui/tray_click_windows.go new file mode 100644 index 000000000..17a6dc5df --- /dev/null +++ b/client/ui/tray_click_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package main + +// Open application window on left click, right click opens the tray menu +func bindTrayClick(t *Tray) { + t.tray.OnClick(func() { t.ShowWindow() }) +} diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go new file mode 100644 index 000000000..12da68a5c --- /dev/null +++ b/client/ui/tray_events.go @@ -0,0 +1,137 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/services" +) + +// onSystemEvent fires an OS notification for daemon SystemEvents that carry a +// user-facing message. Gated by the "Notifications" toggle; critical events bypass it. +func (t *Tray) onSystemEvent(ev *application.CustomEvent) { + se, ok := ev.Data.(services.SystemEvent) + if !ok { + return + } + // config_changed carries no UserMessage, so handle it before the message gate below. + if se.Category == "system" && se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypeConfigChanged { + log.Infof("config_changed event received (source=%s); refreshing tray restrictions", se.Metadata[proto.MetadataSourceKey]) + go t.refreshRestrictions() + go t.loadConfig() + // MDM gets a localised toast here; the daemon's English "policy_applied" + // event is suppressed in shouldSkipSystemEvent. Other sources stay silent. + if se.Metadata[proto.MetadataSourceKey] == proto.MetadataSourceMDM { + t.profileMu.Lock() + enabled := t.notificationsEnabled + t.profileMu.Unlock() + if enabled { + t.notify( + t.loc.T("notify.mdm.policyApplied.title"), + t.loc.T("notify.mdm.policyApplied.body"), + notifyIDMDMPolicy, + ) + } + } + return + } + // Session-warning and deadline-rejected events build their body locally from + // metadata; every other event needs a UserMessage. + isSessionWarning := se.Metadata[authsession.MetaWarning] == "true" + isDeadlineRejected := se.Metadata[authsession.MetaDeadlineRejected] != "" + if !isSessionWarning && !isDeadlineRejected && se.UserMessage == "" { + return + } + if shouldSkipSystemEvent(se) { + return + } + + critical := strings.EqualFold(se.Severity, services.SeverityCritical) + t.profileMu.Lock() + enabled := t.notificationsEnabled + t.profileMu.Unlock() + if !enabled && !critical { + return + } + + // Session-warning events route via stable metadata flags rather than + // category/severity so a daemon-side reword still lands here. Final warning + // auto-opens the SessionExpiration dialog with no notification (the dialog is + // the last-chance reminder; doubling up would be noise). + if isDeadlineRejected { + t.notify( + t.loc.T("notify.sessionDeadlineRejected.title"), + t.loc.T("notify.sessionDeadlineRejected.body"), + notifyIDSessionExpired, + ) + return + } + + if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" { + if se.Metadata[authsession.MetaFinal] == "true" { + t.openSessionExpiration() + return + } + t.notifySessionWarning( + t.loc.T("notify.sessionWarning.title"), + t.buildSessionWarningBody(se.Metadata), + ) + return + } + + body := se.UserMessage + if id := se.Metadata["id"]; id != "" { + body += fmt.Sprintf(" ID: %s", id) + } + t.notify(eventTitle(se), body, notifyIDEvent+se.ID) +} + +// eventTitle composes a notification title, e.g. "Critical: DNS", "Warning: Authentication". +func eventTitle(e services.SystemEvent) string { + prefix := titleCase(e.Severity) + if prefix == "" { + prefix = "Info" + } + category := titleCase(e.Category) + if category == "" { + category = "System" + } + return prefix + ": " + category +} + +func titleCase(s string) string { + if s == "" { + return "" + } + return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) +} + +// shouldSkipSystemEvent reports whether a daemon SystemEvent must not surface as +// a tray notification: +// - update-available announcements (trayUpdater emits its own) +// - install-progress signals (consumed by the install-progress window) +// - the ::/0 partner of an exit-node default route (0.0.0.0/0 already toasted) +func shouldSkipSystemEvent(se services.SystemEvent) bool { + // "policy_applied" carries a hardcoded English message; the localised toast + // fires on the paired config_changed (source=mdm) event instead. + if se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypePolicyApplied { + return true + } + if _, isUpdate := se.Metadata["new_version_available"]; isUpdate { + return true + } + if _, isProgress := se.Metadata["progress_window"]; isProgress { + return true + } + if se.Category == "network" && se.Metadata["network"] == "::/0" { + return true + } + return false +} diff --git a/client/ui/tray_exitnodes.go b/client/ui/tray_exitnodes.go new file mode 100644 index 000000000..3e6f30842 --- /dev/null +++ b/client/ui/tray_exitnodes.go @@ -0,0 +1,148 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "net/netip" + "sort" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/ui/services" +) + +// exitNodeEntry is one Exit Node submenu row; ID is the network's NetID, the Select/Deselect argument. +type exitNodeEntry struct { + ID string + Selected bool +} + +// fillExitNodeSubmenu uses a "✓ " prefix with plain Add, not AddCheckbox: Wails +// auto-toggles a checkbox on click before OnClick runs, so the deselect/select +// round-trip would briefly show two checked rows. Callers must hold exitNodesRebuildMu. +func (t *Tray) fillExitNodeSubmenu(nodes []exitNodeEntry) { + if t.exitNodeSubmenu == nil { + return + } + t.exitNodeSubmenu.Clear() + for _, n := range nodes { + id := n.ID + selected := n.Selected + label := id + if selected { + label = "✓ " + id + } + t.exitNodeSubmenu.Add(label).OnClick(func(*application.Context) { + t.toggleExitNode(id, selected) + }) + } +} + +// refreshExitNodes sources rows from Networks.List() rather than the Status stream +// because only ListNetworks carries the NetID + selected state Select/Deselect need. +// Serialized by exitNodesRebuildMu against overlapping Status pushes. +func (t *Tray) refreshExitNodes() { + t.exitNodesRebuildMu.Lock() + defer t.exitNodesRebuildMu.Unlock() + + t.statusMu.Lock() + connected := t.connected + t.statusMu.Unlock() + + var nodes []exitNodeEntry + if connected { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + list, err := t.svc.Networks.List(ctx) + cancel() + if err != nil { + log.Debugf("tray list networks: %v", err) + return + } + nodes = exitNodesFromNetworks(list) + } + + log.Infof("tray refreshExitNodes: %d exit node(s)", len(nodes)) + for _, n := range nodes { + log.Infof("tray exit node: id=%q selected=%v", n.ID, n.Selected) + } + + t.exitNodesMu.Lock() + changed := !equalExitNodes(nodes, t.exitNodes) + t.exitNodes = nodes + t.exitNodesMu.Unlock() + + // relayoutMenu repaints from the cached entries, so the old exitNodeItem needs no poking here. + if changed { + t.relayoutMenu() + } +} + +// toggleExitNode uses append=true: append=false would drop the whole current +// selection (default-on semantics), turning off every other routed network the +// user had enabled. Mutual exclusion of exit nodes is enforced daemon-side. +func (t *Tray) toggleExitNode(id string, selected bool) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + params := services.SelectNetworksParams{NetworkIDs: []string{id}, Append: true, All: false} + var err error + if selected { + err = t.svc.Networks.Deselect(ctx, params) + } else { + err = t.svc.Networks.Select(ctx, params) + } + if err != nil { + log.Errorf("tray toggle exit node %q: %v", id, err) + t.notifyError(t.loc.T("notify.error.exitNode", "name", id)) + return + } + t.refreshExitNodes() + }() +} + +// exitNodesFromNetworks keeps only networks whose range is a default route: those are the exit-node candidates. +func exitNodesFromNetworks(networks []services.Network) []exitNodeEntry { + out := []exitNodeEntry{} + for _, n := range networks { + if !rangeIsDefaultRoute(n.Range) { + continue + } + out = append(out, exitNodeEntry{ID: n.ID, Selected: n.Selected}) + } + sort.Slice(out, func(i, j int) bool { + return strings.ToLower(out[i].ID) < strings.ToLower(out[j].ID) + }) + return out +} + +// rangeIsDefaultRoute reports whether r contains a default route. The daemon may +// comma-join a v4+v6 pair ("0.0.0.0/0, ::/0"), so each part is parsed rather than string-compared. +func rangeIsDefaultRoute(r string) bool { + for _, part := range strings.Split(r, ",") { + pref, err := netip.ParsePrefix(strings.TrimSpace(part)) + if err != nil { + continue + } + if pref.Bits() == 0 && pref.Addr().IsUnspecified() { + return true + } + } + return false +} + +func equalExitNodes(a, b []exitNodeEntry) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/client/ui/tray_features.go b/client/ui/tray_features.go new file mode 100644 index 000000000..2ff99e2be --- /dev/null +++ b/client/ui/tray_features.go @@ -0,0 +1,37 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + + log "github.com/sirupsen/logrus" +) + +// refreshRestrictions re-reads the operator-disabled UI flags and re-gates the +// menu. Must run on every config_changed event: the daemon re-applies its MDM +// policy on each engine spawn. +func (t *Tray) refreshRestrictions() { + r, err := t.svc.Settings.GetRestrictions(context.Background()) + if err != nil { + log.Debugf("get restrictions: %v", err) + return + } + t.featureMu.Lock() + changed := t.disableProfiles != r.Features.DisableProfiles || + t.disableNetworks != r.Features.DisableNetworks + t.disableProfiles = r.Features.DisableProfiles + t.disableNetworks = r.Features.DisableNetworks + t.featureMu.Unlock() + // relayoutMenu rebuilds the whole tree, so skip the no-op refresh (common case). + if changed { + t.relayoutMenu() + } +} + +// featuresDisabled returns the cached flags under featureMu. +func (t *Tray) featuresDisabled() (profiles, networks bool) { + t.featureMu.Lock() + defer t.featureMu.Unlock() + return t.disableProfiles, t.disableNetworks +} diff --git a/client/ui/tray_icon.go b/client/ui/tray_icon.go new file mode 100644 index 000000000..6c7b85d79 --- /dev/null +++ b/client/ui/tray_icon.go @@ -0,0 +1,136 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "runtime" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/services" +) + +func (t *Tray) applyIcon() { + t.statusMu.Lock() + connected := t.connected + statusLabel := t.lastStatus + t.statusMu.Unlock() + hasUpdate := false + if t.updater != nil { + hasUpdate = t.updater.hasUpdate() + } + + log.Infof("tray applyIcon: connected=%v hasUpdate=%v status=%q goos=%s", + connected, hasUpdate, statusLabel, runtime.GOOS) + + icon, dark := t.iconForState() + if runtime.GOOS == "darwin" { + t.tray.SetTemplateIcon(icon) + return + } + if runtime.GOOS == "linux" { + // Wails' Linux SNI backend ignores SetDarkModeIcon (last write wins + // over SetIcon), so iconForState already picked the silhouette by + // panel theme; push that single icon. + t.tray.SetIcon(icon) + return + } + t.tray.SetIcon(icon) + if dark != nil { + t.tray.SetDarkModeIcon(dark) + } +} + +// panelIsDark defaults to true when no detector is wired (panelDark nil — +// non-Linux or portal unavailable), matching the common dark Linux panel. +func (t *Tray) panelIsDark() bool { + if t.panelDark == nil { + return true + } + return t.panelDark() +} + +func (t *Tray) iconForState() (icon, dark []byte) { + t.statusMu.Lock() + connected := t.connected + statusLabel := t.lastStatus + t.statusMu.Unlock() + hasUpdate := false + if t.updater != nil { + hasUpdate = t.updater.hasUpdate() + } + + connecting := strings.EqualFold(statusLabel, services.StatusConnecting) + errored := strings.EqualFold(statusLabel, statusError) || + strings.EqualFold(statusLabel, services.StatusDaemonUnavailable) + needsLogin := strings.EqualFold(statusLabel, services.StatusNeedsLogin) || + strings.EqualFold(statusLabel, services.StatusSessionExpired) || + strings.EqualFold(statusLabel, services.StatusLoginFailed) + + if runtime.GOOS == "darwin" { + switch { + case connecting: + return iconConnectingMacOS, nil + case errored: + return iconErrorMacOS, nil + case needsLogin: + return iconNeedsLoginMacOS, nil + case connected && hasUpdate: + return iconUpdateConnectedMacOS, nil + case connected: + return iconConnectedMacOS, nil + case hasUpdate: + return iconUpdateDisconnectedMacOS, nil + default: + return iconDisconnectedMacOS, nil + } + } + + if runtime.GOOS == "linux" { + // Theme resolved here (black for light panel, white for dark) since + // the SNI backend can't switch per theme (see applyIcon); second + // return is unused on Linux. + dark := t.panelIsDark() + pick := func(black, white []byte) ([]byte, []byte) { + if dark { + return white, nil + } + return black, nil + } + switch { + case connecting: + return pick(iconConnectingMono, iconConnectingMonoDark) + case errored: + return pick(iconErrorMono, iconErrorMonoDark) + case needsLogin: + return pick(iconNeedsLoginMono, iconNeedsLoginMonoDark) + case connected && hasUpdate: + return pick(iconUpdateConnectedMono, iconUpdateConnectedMonoDark) + case connected: + return pick(iconConnectedMono, iconConnectedMonoDark) + case hasUpdate: + return pick(iconUpdateDisconnectedMono, iconUpdateDisconnectedMonoDark) + default: + return pick(iconDisconnectedMono, iconDisconnectedMonoDark) + } + } + + // Windows: colored PNGs. + switch { + case connecting: + return iconConnecting, iconConnectingDark + case errored: + return iconError, iconErrorDark + case needsLogin: + return iconNeedsLogin, iconNeedsLogin + case connected && hasUpdate: + return iconUpdateConnected, iconUpdateConnectedDark + case connected: + return iconConnected, iconConnectedDark + case hasUpdate: + return iconUpdateDisconnected, iconUpdateDisconnectedDark + default: + return iconDisconnected, iconDisconnected + } +} diff --git a/client/ui/tray_label_other.go b/client/ui/tray_label_other.go new file mode 100644 index 000000000..e104bc8e8 --- /dev/null +++ b/client/ui/tray_label_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !android && !ios && !freebsd && !js + +package main + +// menuLabel is the identity on macOS/Linux, which render "&" literally; +// Windows escapes it separately (tray_label_windows.go) to dodge the Win32 mnemonic. +func menuLabel(s string) string { return s } diff --git a/client/ui/tray_label_windows.go b/client/ui/tray_label_windows.go new file mode 100644 index 000000000..8c27ae9ea --- /dev/null +++ b/client/ui/tray_label_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package main + +import "strings" + +// menuLabel doubles ampersands so Win32 draws a literal "&" instead of +// consuming it as the menu mnemonic prefix (Wails passes the label unescaped). +func menuLabel(s string) string { + return strings.ReplaceAll(s, "&", "&&") +} diff --git a/client/ui/tray_linux.go b/client/ui/tray_linux.go new file mode 100644 index 000000000..1d6e0a48b --- /dev/null +++ b/client/ui/tray_linux.go @@ -0,0 +1,71 @@ +//go:build linux && !386 + +package main + +import ( + "os" + "strings" +) + +// init runs before Wails' own init(), so the env vars are set in time. +func init() { + disableDMABUFRenderer() + disableCompositingMode() + disableWebKitSandboxIfNeeded() +} + +func disableDMABUFRenderer() { + if os.Getenv("WEBKIT_DISABLE_DMABUF_RENDERER") != "" { + return + } + + // WebKitGTK's DMA-BUF renderer leaves a blank-white window on many setups + // (VMs, containers, minimal WMs). Wails only disables it for NVIDIA+Wayland, + // but the issue is broader; software rendering is fine for a small UI. + _ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1") +} + +func disableCompositingMode() { + if os.Getenv("WEBKIT_DISABLE_COMPOSITING_MODE") != "" { + return + } + // Disabling the DMA-BUF renderer alone isn't enough on some Intel setups: the + // GL compositor still hits Mesa's unimplemented DRM-format-modifier paths and + // SIGSEGVs inside g_application_run before the first frame. + _ = os.Setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1") +} + +// disableWebKitSandboxIfNeeded works around WebKitGTK crashing at startup when +// its bwrap sandbox can't create an unprivileged user namespace (containers/VMs, +// or Ubuntu 24.04+ AppArmor restrictions). +func disableWebKitSandboxIfNeeded() { + if _, set := os.LookupEnv("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS"); set { + return + } + if unprivilegedUsernsAllowed() { + return + } + _ = os.Setenv("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1") +} + +// unprivilegedUsernsAllowed reports whether the kernel permits unprivileged +// user namespaces (needed by WebKit's bwrap sandbox). Absent knobs are treated +// as allowed, to avoid needlessly weakening the sandbox. +func unprivilegedUsernsAllowed() bool { + // Debian/Ubuntu legacy switch: 0 disables unprivileged user namespaces. + if v, err := os.ReadFile("/proc/sys/kernel/unprivileged_userns_clone"); err == nil { + if strings.TrimSpace(string(v)) == "0" { + return false + } + } + // Ubuntu 24.04+ AppArmor restriction: non-zero restricts/blocks them. + if v, err := os.ReadFile("/proc/sys/kernel/apparmor_restrict_unprivileged_userns"); err == nil { + if strings.TrimSpace(string(v)) != "0" { + return false + } + } + return true +} + +// Linux's tray provider needs the menu recreated rather than updated in place; +// tray.go's rebuildExitNodeMenu already does this, so no extra workaround here. diff --git a/client/ui/tray_notify.go b/client/ui/tray_notify.go new file mode 100644 index 000000000..5b2629419 --- /dev/null +++ b/client/ui/tray_notify.go @@ -0,0 +1,61 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + "github.com/netbirdio/netbird/client/ui/services" +) + +const notifyIDDaemonOutdated = "netbird-daemon-outdated" + +// sendFn fits both NotificationService.SendNotification and SendNotificationWithActions. +type sendFn func(notifications.NotificationOptions) error + +// safeSendNotification sends a best-effort OS notification, swallowing errors and panics. +// +// The panic guard is load-bearing on Linux: when Wails' notifier fails to +// connect the session bus at startup (headless, unreachable +// DBUS_SESSION_BUS_ADDRESS) it stays registered with a nil *dbus.Conn, so the +// next send nil-derefs inside godbus. Because sends run on a Wails +// event-dispatch goroutine that panic is fatal process-wide; recover() turns +// it into a logged no-op. +func safeSendNotification(send sendFn, what string, opts notifications.NotificationOptions) (err error) { + if services.ShuttingDown() { + return nil + } + defer func() { + if r := recover(); r != nil { + log.Errorf("notify %s: recovered from panic (notification bus unavailable): %v", what, r) + err = nil + } + }() + if err := send(opts); err != nil { + log.Errorf("notify %s: %v", what, err) + return err + } + return nil +} + +// notifyIfDaemonOutdated probes the daemon once and fires an OS toast when it +// is reachable but too old for this UI. A probe error means the daemon isn't +// reachable (not outdated), so it is left to the normal connection flow. +func notifyIfDaemonOutdated(compat *services.Compat, notifier *Notifier, loc *Localizer) { + ready, err := compat.DaemonReady(context.Background()) + if err != nil { + log.Debugf("daemon compatibility probe: %v", err) + return + } + if ready { + return + } + _ = safeSendNotification(notifier.SendNotification, "daemon-outdated", notifications.NotificationOptions{ + ID: notifyIDDaemonOutdated, + Title: loc.T("notify.daemonOutdated.title"), + Body: loc.T("notify.daemonOutdated.body"), + }) +} diff --git a/client/ui/tray_profiles.go b/client/ui/tray_profiles.go new file mode 100644 index 000000000..5251c138e --- /dev/null +++ b/client/ui/tray_profiles.go @@ -0,0 +1,194 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "fmt" + "sort" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/ui/services" +) + +// 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 services.Profile, profiles []services.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()) +} + +// loadConfig caches the active-profile identity and the notifications gate. +// Runs in a startup goroutine so a slow daemon does not block menu construction. +func (t *Tray) loadConfig() { + ctx := context.Background() + + active, err := t.svc.Profiles.GetActive(ctx) + if err != nil { + log.Debugf("get active profile: %v", err) + return + } + // Address the active profile by ID (the daemon resolves it as a handle), + // since display names can collide. ConfigParams no longer matches + // ActiveProfile's shape for a struct conversion now that it carries an ID. + cfg, err := t.svc.Settings.GetConfig(ctx, services.ConfigParams{ + ProfileName: active.ID, + Username: active.Username, + }) + if err != nil { + log.Debugf("get config: %v", err) + return + } + + t.profileMu.Lock() + t.activeProfile = active.ProfileName + t.activeUsername = active.Username + t.notificationsEnabled = !cfg.DisableNotifications + t.profileMu.Unlock() +} + +// loadProfiles fetches the profile list and relayouts the menu. Also called +// from applyStatus to catch flips from another channel (CLI, autoconnect), +// since the daemon emits no active-profile event. Full relayout (not +// Clear()+Add()) is required for KDE/Plasma — see relayoutMenu's doc comment. +func (t *Tray) loadProfiles() { + t.profileLoadMu.Lock() + defer t.profileLoadMu.Unlock() + ctx := context.Background() + + username, err := t.svc.Profiles.Username() + if err != nil { + log.Debugf("get current user: %v", err) + return + } + profiles, err := t.svc.Profiles.List(ctx, username) + if err != nil { + log.Debugf("list profiles: %v", err) + return + } + + t.profilesMu.Lock() + t.profiles = profiles + t.profilesUser = username + t.profilesMu.Unlock() + + t.relayoutMenu() +} + +// fillProfileSubmenu paints cached profile rows into the freshly built submenu. +// Pure UI: never fetches, never calls SetMenu (relayoutMenu owns the SetMenu). +func (t *Tray) fillProfileSubmenu() { + if t.profileSubmenu == nil { + return + } + t.profilesMu.Lock() + profiles := append([]services.Profile(nil), t.profiles...) + username := t.profilesUser + t.profilesMu.Unlock() + + sort.Slice(profiles, func(i, j int) bool { + if profiles[i].Name != profiles[j].Name { + return profiles[i].Name < profiles[j].Name + } + return profiles[i].ID < profiles[j].ID + }) + + // Wails' systray does not reliably propagate a disabled parent to its + // children on every platform, so disable each row explicitly. + disableProfiles, _ := t.featuresDisabled() + + t.profileSubmenu.Clear() + var activeName, activeEmail string + for _, p := range profiles { + id := p.ID + // Display names can collide, so disambiguate with a short ID suffix. + display := formatProfileLabel(p, profiles) + active := p.IsActive + // Add, not AddCheckbox: Wails auto-toggles a checkbox on click before + // OnClick fires, so both old and new would briefly show checked during + // the switch. A plain item with a "✓ " prefix avoids the race. + label := display + if active { + label = "✓ " + display + } + item := t.profileSubmenu.Add(label) + item.OnClick(func(*application.Context) { + log.Infof("tray profile click: profile=%q id=%q wasActive=%v", display, id, active) + if active { + return + } + t.switchProfile(id, display) + }) + item.SetEnabled(!disableProfiles) + if active { + activeName = display + activeEmail = p.Email + } + } + t.profileSubmenu.AddSeparator() + manageProfiles := t.profileSubmenu.Add(t.loc.T("tray.menu.manageProfiles")) + manageProfiles.OnClick(func(*application.Context) { + t.svc.WindowManager.OpenSettings("profiles") + }) + manageProfiles.SetEnabled(!disableProfiles) + log.Infof("tray fillProfileSubmenu: %d profile(s) for user %q, active=%q", len(profiles), username, activeName) + if t.profileSubmenuItem != nil && activeName != "" { + t.profileSubmenuItem.SetLabel(activeName) + } + if t.profileEmailItem != nil { + if activeEmail != "" { + t.profileEmailItem.SetLabel(fmt.Sprintf("(%s)", activeEmail)) + t.profileEmailItem.SetHidden(false) + } else { + t.profileEmailItem.SetHidden(true) + } + } +} + +// switchProfile cancels any in-flight switch before starting a new one, so +// rapid clicks converge to the last selected profile. Optimistic paint and +// event suppression live in ProfileSwitcher, shared with the React Status page. +// switchProfile sends handle (the profile's ID) to the daemon, which resolves +// it precisely even when display names collide. display is used only for the +// failure notification. +func (t *Tray) switchProfile(handle, display string) { + t.profileMu.Lock() + if t.switchCancel != nil { + t.switchCancel() + } + ctx, cancel := context.WithCancel(context.Background()) + t.switchCancel = cancel + t.profileMu.Unlock() + + go func() { + username, err := t.svc.Profiles.Username() + if err != nil { + log.Errorf("tray switchProfile: get current user: %v", err) + return + } + if err := t.svc.ProfileSwitcher.SwitchActive(ctx, services.ProfileRef{ + ProfileName: handle, + Username: username, + }); err != nil { + if ctx.Err() != nil { + return + } + log.Errorf("tray switchProfile: %v", err) + t.notifyError(t.loc.T("notify.error.switchProfile", "profile", display)) + return + } + t.loadProfiles() + }() +} diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go new file mode 100644 index 000000000..f25419894 --- /dev/null +++ b/client/ui/tray_session.go @@ -0,0 +1,317 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + nbstatus "github.com/netbirdio/netbird/client/status" + "github.com/netbirdio/netbird/client/ui/authsession" + "github.com/netbirdio/netbird/client/ui/services" +) + +const ( + notifyIDSessionExpired = "netbird-session-expired" + notifyIDSessionWarning = "netbird-session-warning" + + notifyCategorySessionWarning = "netbird-session-warning" + notifyActionExtendNow = "extend-now" + notifyActionDismiss = "dismiss" + + // finalWarningCountdownSeconds must stay in sync by hand with sessionwatch.FinalWarningLead. + finalWarningCountdownSeconds = 120 +) + +// handleSessionExpired notifies and brings the window forward so the user can reconnect. +func (t *Tray) handleSessionExpired() { + t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired) + if t.window != nil { + t.window.Show() + t.window.Focus() + } +} + +// applySessionExpiry refreshes the cached SSO deadline and reports whether it changed. +// Cache-only; the caller relayouts when this returns true. +func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) bool { + var d time.Time + if connected && deadline != nil { + d = *deadline + } + + t.sessionMu.Lock() + changed := !t.sessionExpiresAt.Equal(d) + t.sessionExpiresAt = d + t.sessionMu.Unlock() + + if changed { + switch { + case deadline == nil: + log.Infof("tray applySessionExpiry: deadline= connected=%v → row hidden", connected) + case deadline.IsZero(): + log.Infof("tray applySessionExpiry: deadline= connected=%v → row hidden", connected) + default: + log.Infof("tray applySessionExpiry: deadline=%s (in %s) connected=%v", + deadline.Format(time.RFC3339), time.Until(*deadline), connected) + } + } + return changed +} + +// runSessionExpiryTicker recomputes the "Expires in …" row label until process exit. +// The interval scales with the remaining time: coarse when the deadline is far off, +// down to 10s in the final two minutes so the label doesn't lag the ceiling-rounded +// countdown near expiry. The cached deadline is re-read every iteration, so an extend +// or reconnect that moves it is picked up on the next tick. +func (t *Tray) runSessionExpiryTicker() { + tm := time.NewTimer(sessionRefreshInterval(t.sessionRemaining())) + defer tm.Stop() + for range tm.C { + t.refreshSessionExpiresLabel() + tm.Reset(sessionRefreshInterval(t.sessionRemaining())) + } +} + +// sessionRemaining returns the time left on the cached SSO deadline, or 0 when unknown. +func (t *Tray) sessionRemaining() time.Duration { + t.sessionMu.Lock() + deadline := t.sessionExpiresAt + t.sessionMu.Unlock() + if deadline.IsZero() { + return 0 + } + return time.Until(deadline) +} + +// sessionRefreshInterval picks how long to wait before the next label recompute. +func sessionRefreshInterval(remaining time.Duration) time.Duration { + switch { + case remaining <= 0: + return 30 * time.Second + case remaining <= 2*time.Minute: + return 10 * time.Second + case remaining <= time.Hour: + return 30 * time.Second + default: + return time.Minute + } +} + +// refreshSessionExpiresLabel updates only the countdown label, no relayout, to avoid disturbing an open menu. +// The item is snapshotted under menuMu since buildMenu reassigns it on every relayout. +func (t *Tray) refreshSessionExpiresLabel() { + t.menuMu.Lock() + item := t.sessionExpiresItem + t.menuMu.Unlock() + if item == nil { + return + } + t.sessionMu.Lock() + deadline := t.sessionExpiresAt + t.sessionMu.Unlock() + if deadline.IsZero() { + return + } + item.SetLabel(t.sessionRowLabel(deadline)) +} + +func (t *Tray) sessionRowLabel(deadline time.Time) string { + remaining := time.Until(deadline) + if remaining <= 0 { + return t.loc.T("tray.status.sessionExpired") + } + return t.loc.T("tray.session.expiresIn", "remaining", t.formatSessionRemaining(remaining)) +} + +// formatSessionRemaining renders d as a localised long-form string picking the largest non-zero unit. +// Each unit is rounded up so the label never claims less time than actually remains, matching the +// upper-bound sense of the sub-minute "less than a minute" fragment. +// Singular/plural keys are split per language for proper translation. +func (t *Tray) formatSessionRemaining(d time.Duration) string { + switch { + case d < time.Minute: + return t.loc.T("tray.session.unit.lessThanMinute") + case d <= 59*time.Minute: + m := ceilDiv(d, time.Minute) + if m == 1 { + return t.loc.T("tray.session.unit.minute") + } + return t.loc.T("tray.session.unit.minutes", "count", strconv.Itoa(m)) + case d <= 23*time.Hour: + h := ceilDiv(d, time.Hour) + if h == 1 { + return t.loc.T("tray.session.unit.hour") + } + return t.loc.T("tray.session.unit.hours", "count", strconv.Itoa(h)) + default: + days := ceilDiv(d, 24*time.Hour) + if days == 1 { + return t.loc.T("tray.session.unit.day") + } + return t.loc.T("tray.session.unit.days", "count", strconv.Itoa(days)) + } +} + +// ceilDiv divides d by unit rounding up, assuming d > 0. +func ceilDiv(d, unit time.Duration) int { + return int((d + unit - time.Nanosecond) / unit) +} + +// registerSessionWarningCategory wires the OS notification category and response handler for the expiry warning. +// Errors are swallowed since the worst case is a plain notification without buttons. +func (t *Tray) registerSessionWarningCategory() { + if t.svc.Notifier == nil { + return + } + if err := t.svc.Notifier.RegisterNotificationCategory(notifications.NotificationCategory{ + ID: notifyCategorySessionWarning, + Actions: []notifications.NotificationAction{ + {ID: notifyActionExtendNow, Title: t.loc.T("notify.sessionWarning.extend")}, + {ID: notifyActionDismiss, Title: t.loc.T("notify.sessionWarning.dismiss")}, + }, + }); err != nil { + log.Debugf("register session-warning notification category: %v", err) + } + t.svc.Notifier.OnNotificationResponse(func(result notifications.NotificationResult) { + if result.Error != nil { + log.Debugf("notification response error: %v", result.Error) + return + } + if result.Response.CategoryID != notifyCategorySessionWarning { + return + } + switch result.Response.ActionIdentifier { + case notifyActionExtendNow, notifications.DefaultActionIdentifier: + // DefaultActionIdentifier is the body-click on platforms with no separate buttons; treat as Extend. + go t.runExtendSession() + case notifyActionDismiss: + go t.dismissSessionWarning() + } + }) +} + +// buildSessionWarningBody composes the localised notification body from the daemon's metadata. +// The daemon has no locale, so it ships an RFC3339 deadline the tray turns into a user-language sentence. +// Falls back to a generic string when metadata is missing or unparsable. +func (t *Tray) buildSessionWarningBody(meta map[string]string) string { + if meta == nil { + return t.loc.T("notify.sessionWarning.bodyGeneric") + } + raw := meta[authsession.MetaExpiresAt] + if raw == "" { + return t.loc.T("notify.sessionWarning.bodyGeneric") + } + deadline, err := authsession.ParseExpiresAt(raw) + if err != nil { + return t.loc.T("notify.sessionWarning.bodyGeneric") + } + remaining := nbstatus.FormatRemainingDuration(time.Until(deadline)) + return t.loc.T("notify.sessionWarning.body", "remaining", remaining) +} + +// notifySessionWarning sends the interactive expiry notification, falling back to plain notify when the +// with-actions variant is unavailable (older platform impls, or a bare Notifier in tests). +func (t *Tray) notifySessionWarning(title, body string) { + if t.svc.Notifier == nil { + return + } + err := safeSendNotification(t.svc.Notifier.SendNotificationWithActions, "session-warning with actions", notifications.NotificationOptions{ + ID: notifyIDSessionWarning, + Title: title, + Body: body, + CategoryID: notifyCategorySessionWarning, + }) + if err != nil { + // A recovered panic returns nil err, so a dead bus correctly skips this fallback (it would panic too). + t.notify(title, body, notifyIDSessionWarning) + } +} + +// runExtendSession drives the daemon's RequestExtend + WaitExtend pair, opening the browser via Connection.OpenURL. +// Errors surface as notifyError rather than foreground UI, since the warning may fire while the window is closed. +func (t *Tray) runExtendSession() { + if t.svc.Session == nil || t.svc.Connection == nil { + log.Debugf("session-warning: extend requested but services not wired") + return + } + ctx := context.Background() + + start, err := t.svc.Session.RequestExtend(ctx, services.ExtendStartParams{}) + if err != nil { + log.Warnf("session-warning: RequestExtend failed: %v", err) + t.notifyError(t.loc.T("notify.sessionWarning.failed")) + return + } + + uri := start.VerificationURIComplete + if uri == "" { + uri = start.VerificationURI + } + if uri != "" { + if err := t.svc.Connection.OpenURL(uri); err != nil { + log.Debugf("session-warning: opening verification URL: %v", err) + } + } + + result, err := t.svc.Session.WaitExtend(ctx, services.ExtendWaitParams{ + DeviceCode: start.DeviceCode, + UserCode: start.UserCode, + }) + if err != nil { + log.Warnf("session-warning: WaitExtend failed: %v", err) + t.notifyError(t.loc.T("notify.sessionWarning.failed")) + return + } + if result.Preempted { + // Another UI surface owns the flow; stay silent so the user only sees the surviving flow's outcome. + log.Debugf("session-warning: WaitExtend preempted by a newer flow") + return + } + t.notify(t.loc.T("notify.sessionWarning.successTitle"), t.loc.T("notify.sessionWarning.successBody"), notifyIDSessionWarning) +} + +// dismissSessionWarning tells the daemon to silence the fallback dialog for the current deadline. +// Best-effort: a failure only means the dialog will still appear. +func (t *Tray) dismissSessionWarning() { + if t.svc.Session == nil { + return + } + if err := t.svc.Session.DismissWarning(context.Background()); err != nil { + log.Debugf("session-warning: DismissWarning failed: %v", err) + } +} + +// openSessionExpiration fires the fallback dialog when the earlier warning notification wasn't dismissed. +// Idempotent on the WindowManager side. +func (t *Tray) openSessionExpiration() { + if t.svc.WindowManager == nil { + return + } + t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds) +} + +// openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time, +// for the "Expires in …" tray row. Once the deadline has elapsed the row reads "Session expired" and the +// click routes to the login flow instead. No-op when the deadline is unknown. +func (t *Tray) openSessionExtendFlow() { + t.sessionMu.Lock() + deadline := t.sessionExpiresAt + t.sessionMu.Unlock() + if deadline.IsZero() { + return + } + seconds := int(time.Until(deadline).Seconds()) + if seconds <= 0 { + t.app.Event.Emit(services.EventTriggerLogin) + return + } + if t.svc.WindowManager == nil { + return + } + t.svc.WindowManager.OpenSessionExpiration(seconds) +} diff --git a/client/ui/tray_status.go b/client/ui/tray_status.go new file mode 100644 index 000000000..793f1785a --- /dev/null +++ b/client/ui/tray_status.go @@ -0,0 +1,126 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "strings" + + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/netbirdio/netbird/client/ui/services" +) + +func (t *Tray) onStatusEvent(ev *application.CustomEvent) { + st, ok := ev.Data.(services.Status) + if !ok { + return + } + t.applyStatus(st) +} + +// applyStatus repaints the tray from a daemon snapshot. Icon refresh is skipped +// when no icon-relevant input changed: the daemon emits rapid SubscribeStatus +// bursts during health probes that would otherwise spam Shell_NotifyIcon. +func (t *Tray) applyStatus(st services.Status) { + t.statusMu.Lock() + connected := strings.EqualFold(st.Status, services.StatusConnected) + iconChanged := connected != t.connected || st.Status != t.lastStatus + // The daemon re-emits SessionExpired on every snapshot while expired; act + // only on the transition into it so the notification fires once. + sessionExpiredEnter := strings.EqualFold(st.Status, services.StatusSessionExpired) && + !strings.EqualFold(t.lastStatus, services.StatusSessionExpired) + + triggerLogin := t.consumePendingConnectLogin(st.Status) + + daemonVersionChanged := st.DaemonVersion != "" && st.DaemonVersion != t.lastDaemonVersion + t.connected = connected + t.lastStatus = st.Status + if daemonVersionChanged { + t.lastDaemonVersion = st.DaemonVersion + } + + revisionChanged := st.NetworksRevision != t.lastNetworksRevision + t.lastNetworksRevision = st.NetworksRevision + t.statusMu.Unlock() + + if triggerLogin { + t.app.Event.Emit(services.EventTriggerLogin) + } + + // Cache-only; the row is painted by the relayout below. + sessionChanged := t.applySessionExpiry(st.SessionExpiresAt, connected) + + if iconChanged { + t.applyIcon() + } + // All repainting goes through relayoutMenu (menuMu-serialised): applyStatus + // runs concurrently with itself and with relayouts, so in-place item + // mutation would race the buildMenu pointer swap. + if iconChanged || daemonVersionChanged || sessionChanged { + t.relayoutMenu() + } + // The revision is the only reliable signal: candidate routes never appear + // in the peer-status snapshot, so a removed exit node would go unnoticed. + if iconChanged || revisionChanged { + go t.refreshExitNodes() + } + // The daemon emits no active-profile event, so profile flips driven + // elsewhere (CLI, autoconnect) surface via status transitions. + if iconChanged { + go t.loadProfiles() + } + if sessionExpiredEnter { + t.handleSessionExpired() + } +} + +// consumePendingConnectLogin acts on the SSO auto-handoff flag armed by +// handleConnect. Returns true on NeedsLogin so the browser-login flow starts +// without a second Connect click; clears the flag on any terminal state so a +// stale flag can't fire on a later daemon flip. Must hold statusMu. +func (t *Tray) consumePendingConnectLogin(status string) bool { + if !t.pendingConnectLogin { + return false + } + switch { + case strings.EqualFold(status, services.StatusNeedsLogin): + t.pendingConnectLogin = false + return true + case strings.EqualFold(status, services.StatusConnected), + strings.EqualFold(status, services.StatusIdle), + strings.EqualFold(status, services.StatusLoginFailed), + strings.EqualFold(status, services.StatusSessionExpired), + strings.EqualFold(status, services.StatusDaemonUnavailable): + t.pendingConnectLogin = false + } + return false +} + +// applyStatusIndicator sets the status dot bitmap. Call only from relayoutMenu +// (menuMu held): on macOS the bitmap repaints via the relayout's trailing +// SetMenu, not here — the tree is half-built. +func (t *Tray) applyStatusIndicator(status string) { + if t.statusItem == nil { + return + } + t.statusItem.SetBitmap(statusIndicatorBitmap(status)) +} + +func statusIndicatorBitmap(status string) []byte { + switch { + case strings.EqualFold(status, services.StatusConnected): + return iconMenuDotConnected + case strings.EqualFold(status, services.StatusConnecting): + return iconMenuDotConnecting + case strings.EqualFold(status, services.StatusNeedsLogin), + strings.EqualFold(status, services.StatusSessionExpired): + return iconMenuDotConnecting + case strings.EqualFold(status, services.StatusLoginFailed), + strings.EqualFold(status, statusError): + return iconMenuDotError + case strings.EqualFold(status, services.StatusDaemonUnavailable): + return iconMenuDotOffline + default: + return iconMenuDotIdle + } +} diff --git a/client/ui/tray_status_enabled_linux.go b/client/ui/tray_status_enabled_linux.go new file mode 100644 index 000000000..ab7f869b8 --- /dev/null +++ b/client/ui/tray_status_enabled_linux.go @@ -0,0 +1,8 @@ +//go:build linux + +package main + +// statusRowEnabled keeps the top status row enabled on Linux: a disabled row +// paints greyed-out, washing out the status dot. The row has no OnClick, so +// enabling only affects drawing. +func statusRowEnabled() bool { return true } diff --git a/client/ui/tray_status_enabled_other.go b/client/ui/tray_status_enabled_other.go new file mode 100644 index 000000000..606a7d702 --- /dev/null +++ b/client/ui/tray_status_enabled_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !linux && !android && !ios && !freebsd && !js + +package main + +// statusRowEnabled is false on macOS: disabling the row dims the label (signalling +// non-clickable) while keeping the bitmap opaque, so the coloured dot stays visible. +func statusRowEnabled() bool { return false } diff --git a/client/ui/tray_status_enabled_windows.go b/client/ui/tray_status_enabled_windows.go new file mode 100644 index 000000000..06750b7c0 --- /dev/null +++ b/client/ui/tray_status_enabled_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package main + +// statusRowEnabled is always true on Windows: the Win32 disabled-state mask +// desaturates the row's HBITMAP, which would grey out the coloured status dot. +func statusRowEnabled() bool { return true } diff --git a/client/ui/tray_theme_linux.go b/client/ui/tray_theme_linux.go new file mode 100644 index 000000000..a3249e57a --- /dev/null +++ b/client/ui/tray_theme_linux.go @@ -0,0 +1,134 @@ +//go:build linux && !(linux && 386) + +package main + +// Wails v3's Linux SNI backend ignores SetDarkModeIcon (it just calls setIcon, +// last write wins) and SNI carries no panel dark/light hint, so we detect the +// desktop colour scheme ourselves and pick the silhouette in iconForState. +// The live watcher is in tray_theme_watcher_linux.go. + +import ( + "bufio" + "os" + "path/filepath" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" +) + +// startTrayTheme seeds t.panelDark and repaints on colour-scheme flips. Must +// run before the first applyIcon so the initial paint uses the right silhouette. +func (t *Tray) startTrayTheme() { + w := startThemeWatcher(func() { t.applyIcon() }) + t.panelDark = w.IsDark +} + +// isKDE reports whether the current desktop is KDE Plasma. XDG_CURRENT_DESKTOP +// is a colon-separated list (e.g. "ubuntu:KDE"), so match per token. +func isKDE() bool { + for _, d := range strings.Split(os.Getenv("XDG_CURRENT_DESKTOP"), ":") { + if strings.EqualFold(strings.TrimSpace(d), "KDE") { + return true + } + } + return false +} + +// kdeglobalsPath returns the user kdeglobals path. We read only this file, not +// the full XDG_CONFIG_DIRS cascade: Plasma writes the active scheme here, and a +// missing Complementary group falls back to the portal. +func kdeglobalsPath() string { + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return filepath.Join(dir, "kdeglobals") + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "kdeglobals") +} + +// kdePanelIsDark reports whether the KDE Plasma panel is dark by the luma of +// its "Complementary" background (the colour Plasma paints the tray with). ok +// is false when this isn't KDE or the colour can't be read, so the caller falls +// through to the portal/GTK path. +func kdePanelIsDark() (dark, ok bool) { + if !isKDE() { + return false, false + } + path := kdeglobalsPath() + if path == "" { + return false, false + } + rgb, ok := readKdeComplementaryBackground(path) + if !ok { + return false, false + } + return isDarkRGB(rgb[0], rgb[1], rgb[2]), true +} + +// readKdeComplementaryBackground parses kdeglobals for +// [Colors:Complementary] BackgroundNormal and returns its R,G,B (0-255). +func readKdeComplementaryBackground(path string) (rgb [3]uint8, ok bool) { + f, err := os.Open(path) + if err != nil { + log.Debugf("tray theme: kdeglobals open failed, using portal: %v", err) + return rgb, false + } + defer func() { _ = f.Close() }() + + const group = "[Colors:Complementary]" + inGroup := false + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "[") { + inGroup = line == group + continue + } + if !inGroup { + continue + } + key, val, found := strings.Cut(line, "=") + if !found || strings.TrimSpace(key) != "BackgroundNormal" { + continue + } + return parseRGB(strings.TrimSpace(val)) + } + return rgb, false +} + +// parseRGB parses KDE's "r,g,b" colour triple into bytes. +func parseRGB(s string) (rgb [3]uint8, ok bool) { + parts := strings.Split(s, ",") + if len(parts) != 3 { + return rgb, false + } + for i, p := range parts { + n, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil || n < 0 || n > 255 { + return rgb, false + } + rgb[i] = uint8(n) + } + return rgb, true +} + +// isDarkRGB reports whether a colour is dark via Rec. 601 luma, split at the +// 128 midpoint. +func isDarkRGB(r, g, b uint8) bool { + luma := (299*int(r) + 587*int(g) + 114*int(b)) / 1000 + return luma < 128 +} + +// gtkThemeIsDark inspects the GTK_THEME env var. Empty (no override) is treated +// as dark to match the default-dark fallback used elsewhere. +func gtkThemeIsDark() bool { + theme := os.Getenv("GTK_THEME") + if theme == "" { + return true + } + // GTK_THEME is "Name[:variant]"; the dark variant is ":dark". + return strings.Contains(strings.ToLower(theme), ":dark") +} diff --git a/client/ui/tray_theme_linux_test.go b/client/ui/tray_theme_linux_test.go new file mode 100644 index 000000000..f14f08d7f --- /dev/null +++ b/client/ui/tray_theme_linux_test.go @@ -0,0 +1,86 @@ +//go:build linux && !(linux && 386) + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadKdeComplementaryBackground(t *testing.T) { + // Mirrors the KDE test VM's kdeglobals: Window light, Complementary dark. + // The tray sits on the panel, which Plasma paints from Complementary, so + // the panel is dark even though the global color-scheme is Light. + content := `[Colors:Window] +BackgroundNormal=239,240,241 + +[Colors:Complementary] +BackgroundAlternate=27,30,32 +BackgroundNormal=42,46,50 + +[General] +ColorSchemeHash=0be804dba87e3512aeb4be3d78ed981f59f0f2f4 +` + path := filepath.Join(t.TempDir(), "kdeglobals") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + rgb, ok := readKdeComplementaryBackground(path) + if !ok { + t.Fatal("expected to find Complementary BackgroundNormal") + } + if rgb != [3]uint8{42, 46, 50} { + t.Fatalf("rgb = %v, want [42 46 50]", rgb) + } + if !isDarkRGB(rgb[0], rgb[1], rgb[2]) { + t.Fatal("panel colour 42,46,50 should be dark") + } + // The Window background (what color-scheme reflects) is light — the bug + // this fix addresses is picking the icon from that instead of the panel. + if isDarkRGB(239, 240, 241) { + t.Fatal("window colour 239,240,241 should be light") + } +} + +func TestReadKdeComplementaryBackgroundMissingGroup(t *testing.T) { + path := filepath.Join(t.TempDir(), "kdeglobals") + if err := os.WriteFile(path, []byte("[Colors:Window]\nBackgroundNormal=1,2,3\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, ok := readKdeComplementaryBackground(path); ok { + t.Fatal("expected not-ok when Complementary group is absent") + } +} + +func TestParseRGB(t *testing.T) { + if _, ok := parseRGB("1,2"); ok { + t.Fatal("two components should fail") + } + if _, ok := parseRGB("300,0,0"); ok { + t.Fatal("out-of-range should fail") + } + if _, ok := parseRGB("a,b,c"); ok { + t.Fatal("non-numeric should fail") + } + rgb, ok := parseRGB(" 10 , 20 , 30 ") + if !ok || rgb != [3]uint8{10, 20, 30} { + t.Fatalf("parseRGB = %v ok=%v, want [10 20 30] true", rgb, ok) + } +} + +func TestIsDarkRGB(t *testing.T) { + if !isDarkRGB(0, 0, 0) { + t.Fatal("black is dark") + } + if isDarkRGB(255, 255, 255) { + t.Fatal("white is light") + } + if !isDarkRGB(42, 46, 50) { + t.Fatal("Breeze panel grey is dark") + } + if isDarkRGB(239, 240, 241) { + t.Fatal("Breeze window grey is light") + } +} diff --git a/client/ui/tray_theme_other.go b/client/ui/tray_theme_other.go new file mode 100644 index 000000000..3f85e1603 --- /dev/null +++ b/client/ui/tray_theme_other.go @@ -0,0 +1,7 @@ +//go:build (!linux || (linux && 386)) && !android && !ios && !freebsd && !js + +package main + +func (t *Tray) startTrayTheme() { + // No-op off Linux: leaves panelDark nil so panelIsDark uses its default. +} diff --git a/client/ui/tray_theme_watcher_linux.go b/client/ui/tray_theme_watcher_linux.go new file mode 100644 index 000000000..b9bafe30b --- /dev/null +++ b/client/ui/tray_theme_watcher_linux.go @@ -0,0 +1,246 @@ +//go:build linux && !(linux && 386) + +package main + +// Sources: the freedesktop Settings portal's SettingChanged signal, and on KDE +// the kdeglobals file (the portal's color-scheme doesn't track the panel's +// Complementary colour — see readDarkMode). The dark/light decision lives in +// tray_theme_linux.go; this file owns the session-bus connection and subscriptions. + +import ( + "path/filepath" + "sync" + + "github.com/fsnotify/fsnotify" + "github.com/godbus/dbus/v5" + log "github.com/sirupsen/logrus" +) + +const ( + portalBusName = "org.freedesktop.portal.Desktop" + portalObjectPath = "/org/freedesktop/portal/desktop" + portalSettings = "org.freedesktop.portal.Settings" + + appearanceNamespace = "org.freedesktop.appearance" + colorSchemeKey = "color-scheme" + + colorSchemeNoPreference = 0 + colorSchemePreferDark = 1 + colorSchemePreferLight = 2 +) + +// themeWatcher owns a private session-bus connection so its signal subscription +// is isolated from the SNI watcher's. +type themeWatcher struct { + conn *dbus.Conn + onChange func() + + mu sync.Mutex + darkMode bool +} + +// startThemeWatcher returns nil if the session bus is unavailable; callers treat +// a nil watcher as "no preference", keeping the default-dark icon. +func startThemeWatcher(onChange func()) *themeWatcher { + conn, err := dbus.SessionBusPrivate() + if err != nil { + log.Debugf("tray theme: session bus unavailable, defaulting to dark icons: %v", err) + return nil + } + if err := conn.Auth(nil); err != nil { + _ = conn.Close() + log.Debugf("tray theme: dbus auth failed: %v", err) + return nil + } + if err := conn.Hello(); err != nil { + _ = conn.Close() + log.Debugf("tray theme: dbus hello failed: %v", err) + return nil + } + + w := &themeWatcher{conn: conn, onChange: onChange} + w.darkMode = w.readDarkMode() + + if err := w.subscribe(); err != nil { + log.Debugf("tray theme: SettingChanged subscription failed, theme is static: %v", err) + // Keep the connection: the seeded darkMode value is still useful. + } + + // The portal's signal doesn't track KDE's panel Complementary colour. + if isKDE() { + w.watchKdeglobals() + } + + log.Infof("tray theme: panel dark mode = %v", w.IsDark()) + return w +} + +// IsDark reports true for a nil watcher, so the icon defaults to the white +// silhouette suiting the common dark Linux panel. +func (w *themeWatcher) IsDark() bool { + if w == nil { + return true + } + w.mu.Lock() + defer w.mu.Unlock() + return w.darkMode +} + +// readDarkMode resolves whether the panel the tray icon sits on is dark. +// +// On KDE the freedesktop color-scheme is the application preference, not the +// panel's: Plasma paints its panel from the Breeze "Complementary" group, which +// stays dark even under a Light global scheme, so we read the panel background +// from kdeglobals first and decide by its luma. Off KDE the color-scheme portal +// is the source; on "no preference" (0) or when unavailable we fall back to +// GTK_THEME (":dark" suffix ⇒ dark), then default to dark. +func (w *themeWatcher) readDarkMode() bool { + if dark, ok := kdePanelIsDark(); ok { + return dark + } + switch w.readColorScheme() { + case colorSchemePreferDark: + return true + case colorSchemePreferLight: + return false + default: + return gtkThemeIsDark() + } +} + +// readColorScheme returns the raw freedesktop color-scheme value, or +// colorSchemeNoPreference when the portal can't be reached. +func (w *themeWatcher) readColorScheme() uint32 { + obj := w.conn.Object(portalBusName, portalObjectPath) + call := obj.Call(portalSettings+".Read", 0, appearanceNamespace, colorSchemeKey) + if call.Err != nil { + log.Debugf("tray theme: portal Read failed, falling back to GTK_THEME: %v", call.Err) + return colorSchemeNoPreference + } + + var v dbus.Variant + if err := call.Store(&v); err != nil { + log.Debugf("tray theme: portal Read decode failed, falling back to GTK_THEME: %v", err) + return colorSchemeNoPreference + } + + return variantToColorScheme(v) +} + +func (w *themeWatcher) subscribe() error { + if err := w.conn.AddMatchSignal( + dbus.WithMatchObjectPath(portalObjectPath), + dbus.WithMatchInterface(portalSettings), + dbus.WithMatchMember("SettingChanged"), + ); err != nil { + return err + } + + sigs := make(chan *dbus.Signal, 8) + w.conn.Signal(sigs) + go w.loop(sigs) + return nil +} + +func (w *themeWatcher) loop(sigs chan *dbus.Signal) { + for sig := range sigs { + if sig.Name != portalSettings+".SettingChanged" { + continue + } + // Signal body: (namespace string, key string, value variant). + if len(sig.Body) < 3 { + continue + } + namespace, _ := sig.Body[0].(string) + key, _ := sig.Body[1].(string) + if namespace != appearanceNamespace || key != colorSchemeKey { + continue + } + if _, ok := sig.Body[2].(dbus.Variant); !ok { + continue + } + + // Re-resolve via readDarkMode, not the signal value: under KDE the panel + // colour comes from kdeglobals, so the signal value would be wrong. + w.update() + } +} + +func (w *themeWatcher) update() { + dark := w.readDarkMode() + w.mu.Lock() + changed := dark != w.darkMode + w.darkMode = dark + w.mu.Unlock() + + if changed && w.onChange != nil { + log.Infof("tray theme: panel dark mode changed to %v", dark) + w.onChange() + } +} + +// watchKdeglobals watches the parent directory, not the file: KDE rewrites +// kdeglobals atomically (write-temp + rename), which would drop an inotify watch +// on the original inode. Filtering by name re-arms implicitly. +func (w *themeWatcher) watchKdeglobals() { + path := kdeglobalsPath() + if path == "" { + return + } + dir, name := filepath.Split(path) + + fw, err := fsnotify.NewWatcher() + if err != nil { + log.Debugf("tray theme: kdeglobals watcher unavailable, theme is static: %v", err) + return + } + if err := fw.Add(filepath.Clean(dir)); err != nil { + log.Debugf("tray theme: watching %s failed, theme is static: %v", dir, err) + _ = fw.Close() + return + } + + go func() { + defer func() { _ = fw.Close() }() + for { + select { + case event, ok := <-fw.Events: + if !ok { + return + } + if filepath.Base(event.Name) != name { + continue + } + if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { + continue + } + w.update() + case err, ok := <-fw.Errors: + if !ok { + return + } + log.Debugf("tray theme: kdeglobals watch error: %v", err) + } + } + }() +} + +// variantToColorScheme unwraps the color-scheme variant; the portal nests it one level. +func variantToColorScheme(v dbus.Variant) uint32 { + inner := v.Value() + if nested, ok := inner.(dbus.Variant); ok { + inner = nested.Value() + } + + switch n := inner.(type) { + case uint32: + return n + case int32: + return uint32(n) + case uint8: + return uint32(n) + default: + log.Debugf("tray theme: unexpected color-scheme type %T, assuming no preference", inner) + return colorSchemeNoPreference + } +} diff --git a/client/ui/tray_update.go b/client/ui/tray_update.go new file mode 100644 index 000000000..27037eccb --- /dev/null +++ b/client/ui/tray_update.go @@ -0,0 +1,198 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + "github.com/netbirdio/netbird/client/ui/services" + "github.com/netbirdio/netbird/client/ui/updater" + "github.com/netbirdio/netbird/version" +) + +// trayUpdater owns the tray UI that reacts to auto-update. Composed inside Tray. +type trayUpdater struct { + app *application.App + window *application.WebviewWindow + update *services.Update + notifier *Notifier + loc *Localizer + onIconChange func() + // onMenuChange drives a full tray relayout: the update row lives in the + // About submenu, which KDE/Plasma caches on first open and never re-fetches + // on a plain SetLabel/SetHidden — only a relayout (fresh submenu ids) repaints. + onMenuChange func() + + mu sync.Mutex + item *application.MenuItem + state updater.State + notifiedVersion string + progressWindowOpen bool +} + +func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater { + u := &trayUpdater{ + app: app, + window: window, + update: update, + notifier: notifier, + loc: loc, + onIconChange: onIconChange, + onMenuChange: onMenuChange, + } + app.Event.On(updater.EventStateChanged, u.onStateEvent) + // Seed from cached state to cover an event that fired before wiring completed. + u.state = update.GetState() + return u +} + +// attach (re)binds the menu item on each Tray.buildMenu run. The caller owns the +// item's OnClick handler. +func (u *trayUpdater) attach(item *application.MenuItem) { + u.mu.Lock() + u.item = item + state := u.state + u.mu.Unlock() + u.refreshMenuItem(state) +} + +// hasUpdate reports whether the tray should paint the "update available" icon. +func (u *trayUpdater) hasUpdate() bool { + u.mu.Lock() + defer u.mu.Unlock() + return u.state.Available +} + +// applyLanguage re-renders the menu item label after a locale switch. +func (u *trayUpdater) applyLanguage() { + u.mu.Lock() + state := u.state + u.mu.Unlock() + u.refreshMenuItem(state) +} + +// handleClick opens the installer download link when not Enforced, otherwise +// shows the progress page and asks the daemon to start the installer. +func (u *trayUpdater) handleClick() { + u.mu.Lock() + state := u.state + u.mu.Unlock() + + if !state.Enforced { + _ = u.app.Browser.OpenURL(version.DownloadUrl()) + return + } + + u.openProgressWindow(state.Version) + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := u.update.Trigger(ctx); err != nil { + log.Errorf("trigger update: %v", err) + } + }() +} + +func (u *trayUpdater) onStateEvent(ev *application.CustomEvent) { + st, ok := ev.Data.(updater.State) + if !ok { + log.Warnf("update state event payload not UpdateState: %T", ev.Data) + return + } + u.applyState(st) +} + +// applyState diffs st against the cached state and drives the resulting side +// effects: icon repaint, menu refresh, new-version notification, progress window. +func (u *trayUpdater) applyState(st updater.State) { + u.mu.Lock() + prev := u.state + u.state = st + + sendNotify := st.Available && st.Version != "" && st.Version != u.notifiedVersion + if sendNotify { + u.notifiedVersion = st.Version + } + + showWindow := st.Installing && !u.progressWindowOpen + if showWindow { + u.progressWindowOpen = true + } else if !st.Installing { + u.progressWindowOpen = false + } + u.mu.Unlock() + + // Full relayout rather than in-place: KDE layout-caches the About submenu, so + // a direct SetLabel/SetHidden wouldn't paint. Fall back if no hook was wired. + if u.onMenuChange != nil { + u.onMenuChange() + } else { + u.refreshMenuItem(st) + } + if prev.Available != st.Available && u.onIconChange != nil { + u.onIconChange() + } + if sendNotify { + u.sendUpdateNotification(st) + } + if showWindow { + u.openProgressWindow(st.Version) + } +} + +func (u *trayUpdater) refreshMenuItem(st updater.State) { + u.mu.Lock() + item := u.item + u.mu.Unlock() + if item == nil { + return + } + + if !st.Available { + item.SetHidden(true) + return + } + if st.Enforced { + item.SetLabel(u.loc.T("tray.menu.installVersion", "version", st.Version)) + } else { + item.SetLabel(u.loc.T("tray.menu.downloadLatest")) + } + item.SetHidden(false) +} + +func (u *trayUpdater) sendUpdateNotification(st updater.State) { + if u.notifier == nil { + return + } + body := u.loc.T("notify.update.body", "version", st.Version) + if st.Enforced { + body += u.loc.T("notify.update.enforcedSuffix") + } + _ = safeSendNotification(u.notifier.SendNotification, "update", notifications.NotificationOptions{ + ID: notifyIDUpdatePrefix + st.Version, + Title: u.loc.T("notify.update.title"), + Body: body, + }) +} + +// openProgressWindow points the main window at the /update progress page and +// brings it forward. +func (u *trayUpdater) openProgressWindow(version string) { + if u.window == nil { + return + } + url := "/#/update" + if version != "" { + url += "?version=" + version + } + u.window.SetURL(url) + u.window.Show() + u.window.Focus() +} diff --git a/client/ui/tray_watcher_linux.go b/client/ui/tray_watcher_linux.go new file mode 100644 index 000000000..38476179a --- /dev/null +++ b/client/ui/tray_watcher_linux.go @@ -0,0 +1,176 @@ +//go:build linux && !(linux && 386) + +package main + +// In-process org.kde.StatusNotifierWatcher for minimal WMs (Fluxbox, OpenBox, +// i3) that ship no watcher. When an XEmbed tray exists (_NET_SYSTEM_TRAY_S0), +// an in-process XEmbed host bridges the SNI icon into it. + +import ( + "sync" + "time" + + "github.com/godbus/dbus/v5" + log "github.com/sirupsen/logrus" +) + +const ( + watcherName = "org.kde.StatusNotifierWatcher" + watcherPath = "/StatusNotifierWatcher" + watcherIface = "org.kde.StatusNotifierWatcher" + + // The UI is often autostarted before the panel on minimal WMs, so a single + // startup probe would miss a tray that appears a second later. + watcherProbeInterval = 500 * time.Millisecond + watcherProbeTimeout = 10 * time.Second +) + +type statusNotifierWatcher struct { + conn *dbus.Conn + items []string + hosts map[string]*xembedHost + hostsMu sync.Mutex +} + +// RegisterStatusNotifierItem is the D-Bus method called by tray clients. +// sender is injected by godbus and is not part of the D-Bus signature. +func (w *statusNotifierWatcher) RegisterStatusNotifierItem(sender dbus.Sender, service string) *dbus.Error { + for _, s := range w.items { + if s == service { + return nil + } + } + w.items = append(w.items, service) + log.Debugf("StatusNotifierWatcher: registered item %q from %s", service, sender) + + go w.tryStartXembedHost(string(sender), dbus.ObjectPath(service)) + return nil +} + +// RegisterStatusNotifierHost is required by the protocol but unused here. +func (w *statusNotifierWatcher) RegisterStatusNotifierHost(service string) *dbus.Error { + log.Debugf("StatusNotifierWatcher: host registered %q", service) + return nil +} + +// tryStartXembedHost is a no-op when no XEmbed tray manager is available. +func (w *statusNotifierWatcher) tryStartXembedHost(busName string, objPath dbus.ObjectPath) { + w.hostsMu.Lock() + defer w.hostsMu.Unlock() + + if _, exists := w.hosts[busName]; exists { + return + } + + // Private session bus so our signal subscriptions don't reach Wails' + // signal handler, which panics on unexpected signals. + sessionConn, err := dbus.SessionBusPrivate() + if err != nil { + log.Debugf("StatusNotifierWatcher: cannot open private session bus for XEmbed host: %v", err) + return + } + if err := sessionConn.Auth(nil); err != nil { + log.Debugf("StatusNotifierWatcher: XEmbed host auth failed: %v", err) + closeBus(sessionConn) + return + } + if err := sessionConn.Hello(); err != nil { + log.Debugf("StatusNotifierWatcher: XEmbed host Hello failed: %v", err) + closeBus(sessionConn) + return + } + + host, err := newXembedHost(sessionConn, busName, objPath) + if err != nil { + log.Debugf("StatusNotifierWatcher: XEmbed host not started: %v", err) + closeBus(sessionConn) + return + } + + w.hosts[busName] = host + go host.run() + log.Infof("StatusNotifierWatcher: XEmbed tray icon created for %s", busName) +} + +// startStatusNotifierWatcher claims org.kde.StatusNotifierWatcher only as a +// bridge to an XEmbed tray on minimal WMs. The watcher is a stub that never +// relays items to a real StatusNotifierHost, so claiming the name on a desktop +// with a real host (e.g. Hyprland + Waybar) would dead-end every other tray +// app's icon. It gates on the actual presence of an XEmbed tray rather than +// GetNameOwner, which can't win a login-order race; without one it stays off +// the bus so the real watcher owns the name. The XEmbed tray may come up after +// the UI, so it re-probes for a grace period rather than deciding once. +// Safe to call unconditionally. +func startStatusNotifierWatcher() { + go func() { + deadline := time.Now().Add(watcherProbeTimeout) + for { + if xembedTrayAvailable() { + claimStatusNotifierWatcher() + return + } + if time.Now().After(deadline) { + log.Debugf("StatusNotifierWatcher: no XEmbed tray appeared within %s, leaving the watcher to the desktop", watcherProbeTimeout) + return + } + time.Sleep(watcherProbeInterval) + } + }() +} + +// claimStatusNotifierWatcher takes ownership of org.kde.StatusNotifierWatcher +// on a private session bus and exports the stub watcher. The GetNameOwner / +// DoNotQueue guards back off if a real watcher already holds the name. +func claimStatusNotifierWatcher() { + conn, err := dbus.SessionBusPrivate() + if err != nil { + log.Debugf("StatusNotifierWatcher: cannot open private session bus: %v", err) + return + } + if err := conn.Auth(nil); err != nil { + log.Debugf("StatusNotifierWatcher: auth failed: %v", err) + closeBus(conn) + return + } + if err := conn.Hello(); err != nil { + log.Debugf("StatusNotifierWatcher: Hello failed: %v", err) + closeBus(conn) + return + } + + var owner string + callErr := conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, watcherName).Store(&owner) + if callErr == nil && owner != "" { + log.Debugf("StatusNotifierWatcher: already owned by %s, skipping", owner) + closeBus(conn) + return + } + + reply, err := conn.RequestName(watcherName, dbus.NameFlagDoNotQueue) + if err != nil || reply != dbus.RequestNameReplyPrimaryOwner { + log.Debugf("StatusNotifierWatcher: could not claim name (reply=%v err=%v)", reply, err) + closeBus(conn) + return + } + + w := &statusNotifierWatcher{ + conn: conn, + hosts: make(map[string]*xembedHost), + } + if err := conn.ExportAll(w, dbus.ObjectPath(watcherPath), watcherIface); err != nil { + log.Errorf("StatusNotifierWatcher: export failed: %v", err) + closeBus(conn) + return + } + + log.Infof("StatusNotifierWatcher: active on session bus (enables tray on minimal WMs)") + // Connection kept open for the process lifetime. +} + +// closeBus closes a private session bus opened on a back-off path, logging a +// warning rather than swallowing the error. +func closeBus(conn *dbus.Conn) { + if err := conn.Close(); err != nil { + log.Warnf("StatusNotifierWatcher: closing session bus failed: %v", err) + } +} diff --git a/client/ui/tray_watcher_other.go b/client/ui/tray_watcher_other.go new file mode 100644 index 000000000..ef8a1bb52 --- /dev/null +++ b/client/ui/tray_watcher_other.go @@ -0,0 +1,9 @@ +//go:build (!linux || (linux && 386)) && !freebsd && !android && !ios && !js + +package main + +// startStatusNotifierWatcher is a no-op stub so main.go can call it across all +// build targets; only minimal Linux WMs need the real watcher (tray_watcher_linux.go). +func startStatusNotifierWatcher() { + // Intentionally empty: only minimal Linux WMs need the real SNI watcher. +} diff --git a/client/ui/uilogpath.go b/client/ui/uilogpath.go new file mode 100644 index 000000000..6fa400e01 --- /dev/null +++ b/client/ui/uilogpath.go @@ -0,0 +1,35 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "os" + "path/filepath" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/configs" + "github.com/netbirdio/netbird/client/ui/guilog" +) + +// uiLogPath returns the GUI log path with native separators, since the daemon +// opens it directly for debug-bundle collection. The file name comes from +// configs.UILogFile so the daemon validates and collects the same name. +func uiLogPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "netbird", configs.UILogFile), nil +} + +// newDebugLog builds the GUI debug log, disabled when userSetLogFile is set +// (manual --log-file override) or the config dir can't be resolved. +func newDebugLog(userSetLogFile bool) *guilog.DebugLog { + path, err := uiLogPath() + if err != nil { + log.Warnf("resolve GUI log path: %v; GUI file logging disabled", err) + return guilog.NewDebugLog("", false) + } + return guilog.NewDebugLog(path, !userSetLogFile) +} diff --git a/client/ui/update.go b/client/ui/update.go deleted file mode 100644 index 25c317bdf..000000000 --- a/client/ui/update.go +++ /dev/null @@ -1,140 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/widget" - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/client/proto" -) - -func (s *serviceClient) showUpdateProgress(ctx context.Context, version string) { - log.Infof("show installer progress window: %s", version) - s.wUpdateProgress = s.app.NewWindow("Automatically updating client") - - statusLabel := widget.NewLabel("Updating...") - infoLabel := widget.NewLabel(fmt.Sprintf("Your client version is older than the auto-update version set in Management.\nUpdating client to: %s.", version)) - content := container.NewVBox(infoLabel, statusLabel) - s.wUpdateProgress.SetContent(content) - s.wUpdateProgress.CenterOnScreen() - s.wUpdateProgress.SetFixedSize(true) - s.wUpdateProgress.SetCloseIntercept(func() { - // this is empty to lock window until result known - }) - s.wUpdateProgress.RequestFocus() - s.wUpdateProgress.Show() - - updateWindowCtx, cancel := context.WithTimeout(ctx, 15*time.Minute) - - // Initialize dot updater - updateText := dotUpdater() - - // Channel to receive the result from RPC call - resultErrCh := make(chan error, 1) - resultOkCh := make(chan struct{}, 1) - - // Start RPC call in background - go func() { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Infof("backend not reachable, upgrade in progress: %v", err) - close(resultOkCh) - return - } - - resp, err := conn.GetInstallerResult(updateWindowCtx, &proto.InstallerResultRequest{}) - if err != nil { - log.Infof("backend stopped responding, upgrade in progress: %v", err) - close(resultOkCh) - return - } - - if !resp.Success { - resultErrCh <- mapInstallError(resp.ErrorMsg) - return - } - - // Success - close(resultOkCh) - }() - - // Update UI with dots and wait for result - go func() { - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - defer cancel() - - // allow closing update window after 10 sec - timerResetCloseInterceptor := time.NewTimer(10 * time.Second) - defer timerResetCloseInterceptor.Stop() - - for { - select { - case <-updateWindowCtx.Done(): - s.showInstallerResult(statusLabel, updateWindowCtx.Err()) - return - case err := <-resultErrCh: - s.showInstallerResult(statusLabel, err) - return - case <-resultOkCh: - log.Info("backend exited, upgrade in progress, closing all UI") - killParentUIProcess() - s.app.Quit() - return - case <-ticker.C: - statusLabel.SetText(updateText()) - case <-timerResetCloseInterceptor.C: - s.wUpdateProgress.SetCloseIntercept(nil) - } - } - }() -} - -func (s *serviceClient) showInstallerResult(statusLabel *widget.Label, err error) { - s.wUpdateProgress.SetCloseIntercept(nil) - switch { - case errors.Is(err, context.DeadlineExceeded): - log.Warn("update watcher timed out") - statusLabel.SetText("Update timed out. Please try again.") - case errors.Is(err, context.Canceled): - log.Info("update watcher canceled") - statusLabel.SetText("Update canceled.") - case err != nil: - log.Errorf("update failed: %v", err) - statusLabel.SetText("Update failed: " + err.Error()) - default: - s.wUpdateProgress.Close() - } -} - -// dotUpdater returns a closure that cycles through dots for a loading animation. -func dotUpdater() func() string { - dotCount := 0 - return func() string { - dotCount = (dotCount + 1) % 4 - return fmt.Sprintf("%s%s", "Updating", strings.Repeat(".", dotCount)) - } -} - -func mapInstallError(msg string) error { - msg = strings.ToLower(strings.TrimSpace(msg)) - - switch { - case strings.Contains(msg, "deadline exceeded"), strings.Contains(msg, "timeout"): - return context.DeadlineExceeded - case strings.Contains(msg, "canceled"), strings.Contains(msg, "cancelled"): - return context.Canceled - case msg == "": - return errors.New("unknown update error") - default: - return errors.New(msg) - } -} diff --git a/client/ui/update_notwindows.go b/client/ui/update_notwindows.go deleted file mode 100644 index 5766f18f7..000000000 --- a/client/ui/update_notwindows.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !windows && !(linux && 386) - -package main - -func killParentUIProcess() { - // No-op on non-Windows platforms -} diff --git a/client/ui/update_windows.go b/client/ui/update_windows.go deleted file mode 100644 index 1b03936f9..000000000 --- a/client/ui/update_windows.go +++ /dev/null @@ -1,44 +0,0 @@ -//go:build windows - -package main - -import ( - log "github.com/sirupsen/logrus" - "golang.org/x/sys/windows" - - nbprocess "github.com/netbirdio/netbird/client/ui/process" -) - -// killParentUIProcess finds and kills the parent systray UI process on Windows. -// This is a workaround in case the MSI installer fails to properly terminate the UI process. -// The installer should handle this via util:CloseApplication with TerminateProcess, but this -// provides an additional safety mechanism to ensure the UI is closed before the upgrade proceeds. -func killParentUIProcess() { - pid, running, err := nbprocess.IsAnotherProcessRunning() - if err != nil { - log.Warnf("failed to check for parent UI process: %v", err) - return - } - - if !running { - log.Debug("no parent UI process found to kill") - return - } - - log.Infof("killing parent UI process (PID: %d)", pid) - - // Open the process with terminate rights - handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid)) - if err != nil { - log.Warnf("failed to open parent process %d: %v", pid, err) - return - } - defer func() { - _ = windows.CloseHandle(handle) - }() - - // Terminate the process with exit code 0 - if err := windows.TerminateProcess(handle, 0); err != nil { - log.Warnf("failed to terminate parent process %d: %v", pid, err) - } -} diff --git a/client/ui/updater/state.go b/client/ui/updater/state.go new file mode 100644 index 000000000..606f8b401 --- /dev/null +++ b/client/ui/updater/state.go @@ -0,0 +1,97 @@ +//go:build !android && !ios && !freebsd && !js + +// Package updater holds the auto-update domain: the typed State, the +// daemon-SystemEvent metadata schema, and the Holder that caches the latest +// state and broadcasts changes. No Wails dependency. +package updater + +import ( + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/proto" +) + +// EventStateChanged carries the full State snapshot as payload. +const EventStateChanged = "netbird:update:state" + +// State is the typed snapshot of the daemon's update situation. Installing is +// driven only by the daemon's progress_window:show event; a UI-side +// Update.Trigger() does not flip it. +type State struct { + Available bool `json:"available"` + Version string `json:"version"` + Enforced bool `json:"enforced"` + Installing bool `json:"installing"` +} + +// Emitter is the broadcast dependency Holder needs; the Wails app.Event +// processor satisfies it. +type Emitter interface { + Emit(name string, data ...any) bool +} + +// Holder caches the latest update State and broadcasts changes. +type Holder struct { + emitter Emitter + + mu sync.Mutex + state State +} + +// NewHolder constructs an empty-state Holder. A nil emitter skips the broadcast. +func NewHolder(emitter Emitter) *Holder { + return &Holder{emitter: emitter} +} + +// Get returns a copy of the cached State. +func (h *Holder) Get() State { + h.mu.Lock() + defer h.mu.Unlock() + return h.state +} + +// OnSystemEvent folds update-related metadata into the cached state, emitting +// EventStateChanged only on an actual change so repeated daemon snapshots +// don't produce redundant pushes. +func (h *Holder) OnSystemEvent(ev *proto.SystemEvent) { + md := ev.GetMetadata() + if len(md) == 0 { + return + } + + h.mu.Lock() + changed := false + if v, ok := md["new_version_available"]; ok { + _, enforced := md["enforced"] + if !h.state.Available || h.state.Version != v || h.state.Enforced != enforced { + h.state.Available = true + h.state.Version = v + h.state.Enforced = enforced + changed = true + } + } + if md["progress_window"] == "show" { + if !h.state.Installing { + h.state.Installing = true + changed = true + } + if v, ok := md["version"]; ok && v != "" && h.state.Version != v { + h.state.Version = v + h.state.Available = true + changed = true + } + } + snap := h.state + h.mu.Unlock() + + if !changed { + return + } + log.Infof("update state: available=%v version=%q enforced=%v installing=%v", + snap.Available, snap.Version, snap.Enforced, snap.Installing) + if h.emitter != nil { + h.emitter.Emit(EventStateChanged, snap) + } +} diff --git a/client/ui/xembed_host_gtk3_linux.go b/client/ui/xembed_host_gtk3_linux.go new file mode 100644 index 000000000..b1f18cc90 --- /dev/null +++ b/client/ui/xembed_host_gtk3_linux.go @@ -0,0 +1,40 @@ +//go:build linux && gtk3 && !(linux && 386) + +package main + +import ( + "errors" + + "github.com/godbus/dbus/v5" +) + +// The legacy GTK3 / WebKit2GTK 4.1 build (-tags gtk3) drops the in-process +// XEmbed StatusNotifierWatcher entirely. The real implementation +// (xembed_host_linux.go + xembed_tray_linux.c) links GTK4 and uses GTK4-only +// popup-menu APIs that have no drop-in GTK3 equivalent, so rather than port the +// C layer we stub the host out on gtk3 builds. The tray still works on every +// desktop that ships its own StatusNotifierWatcher (KDE, GNOME+AppIndicator, +// Cinnamon/xapp, XFCE, …); only the minimal-WM fallback (Fluxbox/OpenBox/i3/ +// dwm/vanilla GNOME) is unavailable on gtk3 packages. See LINUX-TRAY.md. + +// xembedHost is a placeholder so the package compiles on gtk3 builds; the real +// type (with X11/GTK4 state) lives in xembed_host_linux.go. It is never +// instantiated here because xembedTrayAvailable always reports false. +type xembedHost struct{} + +// run satisfies the call in tray_watcher_linux.go; unreachable on gtk3 because +// newXembedHost never returns a non-nil host. +func (*xembedHost) run() {} + +// xembedTrayAvailable always reports false on gtk3 builds, so the watcher probe +// loop in startStatusNotifierWatcher exits immediately and newXembedHost is +// never reached. recenter_linux.go's predicate becomes a harmless no-op too. +func xembedTrayAvailable() bool { + return false +} + +// newXembedHost exists only to satisfy the reference in tray_watcher_linux.go; +// it is unreachable because xembedTrayAvailable returns false on gtk3. +func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) { + return nil, errors.New("xembed host unsupported on gtk3 build") +} diff --git a/client/ui/xembed_host_linux.go b/client/ui/xembed_host_linux.go new file mode 100644 index 000000000..5551cd02a --- /dev/null +++ b/client/ui/xembed_host_linux.go @@ -0,0 +1,443 @@ +//go:build linux && !gtk3 && !(linux && 386) + +package main + +/* +#cgo pkg-config: x11 gtk4 gtk4-x11 cairo cairo-xlib +#cgo LDFLAGS: -lX11 +#include "xembed_tray_linux.h" +#include +#include +#include +*/ +import "C" + +import ( + "errors" + "sync" + "time" + "unsafe" + + "github.com/godbus/dbus/v5" + log "github.com/sirupsen/logrus" +) + +// activeMenuHost holds the popup owner; C callbacks cannot carry Go pointers. +var ( + activeMenuHost *xembedHost + activeMenuHostMu sync.Mutex +) + +// menuItemInfo is a dbusMenuLayout entry flattened for the C popup builder. +type menuItemInfo struct { + id int32 + label string + enabled bool + isCheck bool + checked bool + isSeparator bool + children []menuItemInfo +} + +// dbusMenuLayout mirrors the (ia{sv}av) result of com.canonical.dbusmenu.GetLayout. +// Each Children variant wraps a nested dbusMenuLayout, decoded in flattenMenu. +type dbusMenuLayout struct { + ID int32 + Properties map[string]dbus.Variant + Children []dbus.Variant +} + +// xembedHost manages one XEmbed tray icon for an SNI item. +type xembedHost struct { + conn *dbus.Conn + busName string + objPath dbus.ObjectPath + + dpy *C.Display + trayMgr C.Window + iconWin C.Window + iconSize int + + mu sync.Mutex + iconData []byte + iconW int + iconH int + + stopCh chan struct{} +} + +// newXembedHost creates an XEmbed tray icon for the given SNI item. +// Errors when no XEmbed tray manager is available, so callers can fall back. +func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) { + dpy := C.XOpenDisplay(nil) + if dpy == nil { + return nil, errors.New("cannot open X display") + } + C.xembed_install_error_handlers() + + screen := C.xembed_default_screen(dpy) + trayMgr := C.xembed_find_tray(dpy, screen) + if trayMgr == 0 { + C.XCloseDisplay(dpy) + return nil, errors.New("no XEmbed system tray found") + } + + iconSize := int(C.xembed_get_icon_size(dpy, trayMgr)) + if iconSize <= 0 { + iconSize = 24 // fallback + } + + iconWin := C.xembed_create_icon(dpy, screen, C.int(iconSize), trayMgr) + if iconWin == 0 { + C.XCloseDisplay(dpy) + return nil, errors.New("failed to create icon window") + } + + if C.xembed_dock(dpy, trayMgr, iconWin) != 0 { + C.xembed_destroy_icon(dpy, iconWin) + C.XCloseDisplay(dpy) + return nil, errors.New("failed to dock icon") + } + + h := &xembedHost{ + conn: conn, + busName: busName, + objPath: objPath, + dpy: dpy, + trayMgr: trayMgr, + iconWin: iconWin, + iconSize: iconSize, + stopCh: make(chan struct{}), + } + + h.fetchAndDrawIcon() + return h, nil +} + +func (h *xembedHost) fetchAndDrawIcon() { + obj := h.conn.Object(h.busName, h.objPath) + variant, err := obj.GetProperty("org.kde.StatusNotifierItem.IconPixmap") + if err != nil { + log.Debugf("xembed: failed to get IconPixmap: %v", err) + return + } + + // IconPixmap has D-Bus signature a(iiay). + type px struct { + W int32 + H int32 + Pix []byte + } + + var icons []px + if err := variant.Store(&icons); err != nil { + log.Debugf("xembed: failed to decode IconPixmap: %v", err) + return + } + + if len(icons) == 0 { + log.Debug("xembed: IconPixmap is empty") + return + } + + icon := icons[0] + if icon.W <= 0 || icon.H <= 0 || len(icon.Pix) < int(icon.W*icon.H*4) { + log.Debug("xembed: invalid IconPixmap data") + return + } + + h.mu.Lock() + h.iconData = icon.Pix + h.iconW = int(icon.W) + h.iconH = int(icon.H) + h.mu.Unlock() + + h.drawIcon() +} + +func (h *xembedHost) drawIcon() { + h.mu.Lock() + data := h.iconData + w := h.iconW + ht := h.iconH + h.mu.Unlock() + + if data == nil || w <= 0 || ht <= 0 { + return + } + + cData := C.CBytes(data) + defer C.free(cData) + + C.xembed_draw_icon(h.dpy, h.iconWin, C.int(h.iconSize), + (*C.uchar)(cData), C.int(w), C.int(ht)) +} + +// run is the event loop: polls X11 events and D-Bus NewIcon signals until stopped. +func (h *xembedHost) run() { + matchRule := "type='signal',interface='org.kde.StatusNotifierItem',member='NewIcon',sender='" + h.busName + "'" + if err := h.conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil { + log.Debugf("xembed: failed to add signal match: %v", err) + } + + sigCh := make(chan *dbus.Signal, 16) + h.conn.Signal(sigCh) + defer h.conn.RemoveSignal(sigCh) + + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-h.stopCh: + return + + case sig := <-sigCh: + if sig == nil { + continue + } + if sig.Name == "org.kde.StatusNotifierItem.NewIcon" { + h.fetchAndDrawIcon() + } + + case <-ticker.C: + var outX, outY C.int + result := C.xembed_poll_event(h.dpy, h.iconWin, &outX, &outY) + + switch result { + case 1: // left click + go h.activate(int32(outX), int32(outY)) + case 2: // right click + go h.contextMenu(int32(outX), int32(outY)) + case 3: // expose + h.drawIcon() + case 4: // configure (resize) + newSize := int(outX) + if newSize > 0 && newSize != h.iconSize { + h.iconSize = newSize + h.drawIcon() + } + case -1: // tray died + log.Info("xembed: tray manager destroyed, cleaning up") + return + } + } + } +} + +func (h *xembedHost) activate(x, y int32) { + obj := h.conn.Object(h.busName, h.objPath) + if err := obj.Call("org.kde.StatusNotifierItem.Activate", 0, x, y).Err; err != nil { + log.Debugf("xembed: Activate call failed: %v", err) + } +} + +func (h *xembedHost) contextMenu(x, y int32) { + menuPath := dbus.ObjectPath("/StatusNotifierMenu") + + menuObj := h.conn.Object(h.busName, menuPath) + var revision uint32 + var layout dbusMenuLayout + err := menuObj.Call("com.canonical.dbusmenu.GetLayout", 0, + int32(0), // parentId (root) + int32(-1), // recursionDepth (all) + []string{}, // propertyNames (all) + ).Store(&revision, &layout) + if err != nil { + log.Debugf("xembed: GetLayout failed: %v", err) + return + } + + items := h.flattenMenu(layout) + log.Debugf("xembed: menu has %d items (revision %d)", len(items), revision) + if len(items) == 0 { + return + } + + var allocs []unsafe.Pointer + cItems := buildCItems(items, &allocs) + defer func() { + for _, p := range allocs { + C.free(p) + } + }() + + // C callback reaches us through this global. + activeMenuHostMu.Lock() + activeMenuHost = h + activeMenuHostMu.Unlock() + + C.xembed_show_popup_menu(cItems, C.int(len(items)), + nil, C.int(x), C.int(y)) +} + +func (h *xembedHost) flattenMenu(layout dbusMenuLayout) []menuItemInfo { + var items []menuItemInfo + + for _, childVar := range layout.Children { + var child dbusMenuLayout + if err := dbus.Store([]interface{}{childVar.Value()}, &child); err != nil { + continue + } + if mi, ok := h.menuItemFromLayout(child); ok { + items = append(items, mi) + } + } + + return items +} + +// menuItemFromLayout decodes one dbusmenu child; ok is false for hidden items (drop them). +func (h *xembedHost) menuItemFromLayout(child dbusMenuLayout) (menuItemInfo, bool) { + mi := menuItemInfo{id: child.ID, enabled: true} + + if propString(child.Properties, "type") == "separator" { + mi.isSeparator = true + return mi, true + } + + if vis, ok := propBool(child.Properties, "visible"); ok && !vis { + return menuItemInfo{}, false + } + + mi.label = propString(child.Properties, "label") + if en, ok := propBool(child.Properties, "enabled"); ok { + mi.enabled = en + } + if propString(child.Properties, "toggle-type") == "checkmark" { + mi.isCheck = true + } + if n, ok := propInt32(child.Properties, "toggle-state"); ok && n == 1 { + mi.checked = true + } + + // children are already present from the recursionDepth=-1 GetLayout. + if propString(child.Properties, "children-display") == "submenu" { + mi.children = h.flattenMenu(child) + } + + return mi, true +} + +func (h *xembedHost) sendMenuEvent(id int32) { + menuPath := dbus.ObjectPath("/StatusNotifierMenu") + menuObj := h.conn.Object(h.busName, menuPath) + data := dbus.MakeVariant("") + err := menuObj.Call("com.canonical.dbusmenu.Event", 0, + id, "clicked", data, uint32(0)).Err + if err != nil { + log.Debugf("xembed: menu Event call failed: %v", err) + } +} + +func (h *xembedHost) stop() { + select { + case <-h.stopCh: + return + default: + close(h.stopCh) + } + + C.xembed_destroy_icon(h.dpy, h.iconWin) + C.XCloseDisplay(h.dpy) +} + +// buildCItems builds a C-allocated xembed_menu_item tree. Every malloc is +// appended to *allocs for the caller to free once the C side has deep-copied it. +func buildCItems(items []menuItemInfo, allocs *[]unsafe.Pointer) *C.xembed_menu_item { + if len(items) == 0 { + return nil + } + size := C.size_t(len(items)) * C.size_t(unsafe.Sizeof(C.xembed_menu_item{})) + arr := C.malloc(size) + *allocs = append(*allocs, arr) + C.memset(arr, 0, size) + + slice := (*[1 << 16]C.xembed_menu_item)(arr)[:len(items):len(items)] + for i, mi := range items { + slice[i].id = C.int(mi.id) + slice[i].enabled = boolToInt(mi.enabled) + slice[i].is_check = boolToInt(mi.isCheck) + slice[i].checked = boolToInt(mi.checked) + slice[i].is_separator = boolToInt(mi.isSeparator) + if mi.label != "" { + cstr := C.CString(mi.label) + *allocs = append(*allocs, unsafe.Pointer(cstr)) + slice[i].label = cstr + } + if len(mi.children) > 0 { + slice[i].children = buildCItems(mi.children, allocs) + slice[i].child_count = C.int(len(mi.children)) + } + } + + return (*C.xembed_menu_item)(arr) +} + +// xembedTrayAvailable reports whether an XEmbed tray manager (_NET_SYSTEM_TRAY_S0) +// owns the default screen. Side-effect-free probe. Gates the in-process +// StatusNotifierWatcher: when a real SNI host already owns the tray (e.g. Waybar +// on Wayland) we must not claim org.kde.StatusNotifierWatcher and shadow it. +// Returns false when there is no X display (pure Wayland). +func xembedTrayAvailable() bool { + dpy := C.XOpenDisplay(nil) + if dpy == nil { + return false + } + C.xembed_install_error_handlers() + defer C.XCloseDisplay(dpy) + screen := C.xembed_default_screen(dpy) + return C.xembed_find_tray(dpy, screen) != 0 +} + +// goMenuItemClicked is the C callback fired from the GTK main thread on popup +// activation. The host is looked up via activeMenuHost since C callbacks can't +// carry Go pointers; //export requires this to live in package main. +// +//export goMenuItemClicked +func goMenuItemClicked(id C.int) { + activeMenuHostMu.Lock() + h := activeMenuHost + activeMenuHostMu.Unlock() + + if h != nil { + go h.sendMenuEvent(int32(id)) + } +} + +func boolToInt(b bool) C.int { + if b { + return 1 + } + return 0 +} + +// propString returns the property's string value, or "" if absent or not a string. +func propString(props map[string]dbus.Variant, key string) string { + if v, ok := props[key]; ok { + if s, ok := v.Value().(string); ok { + return s + } + } + return "" +} + +// propBool returns the property's bool value; ok is false if absent or not a bool. +func propBool(props map[string]dbus.Variant, key string) (value, ok bool) { + if v, present := props[key]; present { + if b, isBool := v.Value().(bool); isBool { + return b, true + } + } + return false, false +} + +// propInt32 returns the property's int32 value; ok is false if absent or not an int32. +func propInt32(props map[string]dbus.Variant, key string) (value int32, ok bool) { + if v, present := props[key]; present { + if n, isInt := v.Value().(int32); isInt { + return n, true + } + } + return 0, false +} diff --git a/client/ui/xembed_tray_linux.c b/client/ui/xembed_tray_linux.c new file mode 100644 index 000000000..86da8bf70 --- /dev/null +++ b/client/ui/xembed_tray_linux.c @@ -0,0 +1,710 @@ +//go:build linux && !gtk3 && !(linux && 386) + +#include "xembed_tray_linux.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define SYSTEM_TRAY_REQUEST_DOCK 0 +#define XEMBED_MAPPED (1 << 0) + +/* Xlib's default protocol-error handler calls exit() on any async X error, + killing the whole UI process. Handlers are process-global (not + per-Display), so a single install covers our raw tray Display and GDK's. + Tray work is full of races the X server reports asynchronously — the tray + manager window dying between xembed_find_tray and xembed_dock (BadWindow + on XSendEvent), or x11_move_window touching a popup the WM already + destroyed — and the default handler would take us down for any of them. + Returning 0 here makes the error a logged no-op instead. */ +static int xembed_x_error_handler(Display *dpy, XErrorEvent *ev) { + char buf[256]; + XGetErrorText(dpy, ev->error_code, buf, sizeof(buf)); + fprintf(stderr, + "xembed: X error (ignored): %s (code=%d, request=%d.%d, resource=0x%lx)\n", + buf, ev->error_code, ev->request_code, ev->minor_code, + ev->resourceid); + return 0; +} + +/* The I/O error handler fires when the X connection itself drops (server + gone, socket closed). Xlib treats this as fatal and exits even if we + return, so this can't keep the process alive — it only logs a clearer + line than Xlib's terse default before the unavoidable exit. */ +static int xembed_x_io_error_handler(Display *dpy) { + (void)dpy; + fprintf(stderr, "xembed: X I/O error (connection lost)\n"); + return 0; +} + +/* Install the process-global handlers. Idempotent and cheap, so callers may + invoke it after every XOpenDisplay without tracking prior installs. */ +void xembed_install_error_handlers(void) { + XSetErrorHandler(xembed_x_error_handler); + XSetIOErrorHandler(xembed_x_io_error_handler); +} + +Window xembed_find_tray(Display *dpy, int screen) { + char atom_name[64]; + snprintf(atom_name, sizeof(atom_name), "_NET_SYSTEM_TRAY_S%d", screen); + Atom sel = XInternAtom(dpy, atom_name, False); + return XGetSelectionOwner(dpy, sel); +} + +int xembed_get_icon_size(Display *dpy, Window tray_mgr) { + Atom atom = XInternAtom(dpy, "_NET_SYSTEM_TRAY_ICON_SIZE", False); + Atom actual_type; + int actual_format; + unsigned long nitems, bytes_after; + unsigned char *prop = NULL; + int size = 0; + + if (XGetWindowProperty(dpy, tray_mgr, atom, 0, 1, False, + XA_CARDINAL, &actual_type, &actual_format, + &nitems, &bytes_after, &prop) == Success) { + if (prop && nitems == 1 && actual_format == 32) { + size = (int)(*(unsigned long *)prop); + } + if (prop) + XFree(prop); + } + return size; +} + +Window xembed_create_icon(Display *dpy, int screen, int size, + Window tray_mgr) { + (void)tray_mgr; /* unused; kept in signature for caller symmetry */ + Window root = RootWindow(dpy, screen); + + /* Inherit visual & depth from the parent (tray manager / root) so + ParentRelative background works on every tray. Many minimal + toolbars (Fluxbox slit, OpenBox, etc.) only offer a 24-bit + default visual and do not composite alpha; ParentRelative makes + the X server texture this window's background from the parent, + so transparent pixels in the icon show the toolbar beneath + instead of solid black. ARGB-aware trays still work because the + cairo OVER blend in xembed_draw_icon honours per-pixel alpha + against whatever base the X server painted underneath. */ + XSetWindowAttributes attrs; + memset(&attrs, 0, sizeof(attrs)); + attrs.event_mask = ButtonPressMask | StructureNotifyMask | ExposureMask; + attrs.background_pixmap = ParentRelative; + unsigned long mask = CWEventMask | CWBackPixmap; + + Window win = XCreateWindow( + dpy, root, + 0, 0, size, size, + 0, /* border width */ + CopyFromParent, /* depth */ + InputOutput, + CopyFromParent, /* visual */ + mask, + &attrs + ); + + /* Set _XEMBED_INFO: version=0, flags=XEMBED_MAPPED */ + Atom xembed_info = XInternAtom(dpy, "_XEMBED_INFO", False); + unsigned long info[2] = { 0, XEMBED_MAPPED }; + XChangeProperty(dpy, win, xembed_info, xembed_info, + 32, PropModeReplace, (unsigned char *)info, 2); + + return win; +} + +int xembed_dock(Display *dpy, Window tray_mgr, Window icon_win) { + Atom opcode = XInternAtom(dpy, "_NET_SYSTEM_TRAY_OPCODE", False); + + XClientMessageEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.type = ClientMessage; + ev.window = tray_mgr; + ev.message_type = opcode; + ev.format = 32; + ev.data.l[0] = CurrentTime; + ev.data.l[1] = SYSTEM_TRAY_REQUEST_DOCK; + ev.data.l[2] = (long)icon_win; + + XSendEvent(dpy, tray_mgr, False, NoEventMask, (XEvent *)&ev); + XFlush(dpy); + return 0; +} + +void xembed_draw_icon(Display *dpy, Window icon_win, int win_size, + const unsigned char *data, int img_w, int img_h) { + if (!data || img_w <= 0 || img_h <= 0 || win_size <= 0) + return; + + /* Query the window's actual visual and depth so cairo composites + through the matching ARGB pipeline. */ + XWindowAttributes wa; + if (!XGetWindowAttributes(dpy, icon_win, &wa)) + return; + + /* Build a CAIRO_FORMAT_ARGB32 source surface from the SNI IconPixmap + bytes. SNI ships the pixels as [A,R,G,B,...] in network byte + order; cairo's ARGB32 stores native uint32 with B in the lowest + byte on little-endian hosts. Repack into native order with + pre-multiplied alpha so cairo can composite without tonemapping. */ + int stride = cairo_format_stride_for_width(CAIRO_FORMAT_ARGB32, img_w); + unsigned char *buf = (unsigned char *)calloc(stride * img_h, 1); + if (!buf) + return; + + for (int y = 0; y < img_h; y++) { + unsigned int *row = (unsigned int *)(buf + y * stride); + for (int x = 0; x < img_w; x++) { + int idx = (y * img_w + x) * 4; + unsigned int a = data[idx + 0]; + unsigned int r = data[idx + 1]; + unsigned int g = data[idx + 2]; + unsigned int b = data[idx + 3]; + + if (a == 0) { + row[x] = 0; + } else if (a == 255) { + row[x] = (a << 24) | (r << 16) | (g << 8) | b; + } else { + unsigned int pr = r * a / 255; + unsigned int pg = g * a / 255; + unsigned int pb = b * a / 255; + row[x] = (a << 24) | (pr << 16) | (pg << 8) | pb; + } + } + } + + cairo_surface_t *src = cairo_image_surface_create_for_data( + buf, CAIRO_FORMAT_ARGB32, img_w, img_h, stride); + if (cairo_surface_status(src) != CAIRO_STATUS_SUCCESS) { + cairo_surface_destroy(src); + free(buf); + return; + } + + /* Wrap the X11 window in a cairo XLib surface using its real visual. */ + cairo_surface_t *dst = cairo_xlib_surface_create( + dpy, icon_win, wa.visual, win_size, win_size); + if (cairo_surface_status(dst) != CAIRO_STATUS_SUCCESS) { + cairo_surface_destroy(dst); + cairo_surface_destroy(src); + free(buf); + return; + } + + /* Repaint the ParentRelative background first — without this the + window keeps the previously-drawn icon underneath when an icon + update arrives, and cairo's OVER blend would composite the new + icon on top of the stale one. XClearWindow forces the X server + to retexture from the parent (tray toolbar), giving us a clean + opaque base. */ + XClearWindow(dpy, icon_win); + + cairo_t *cr = cairo_create(dst); + + /* Scale the source onto the window with alpha compositing (default + OPERATOR_OVER). Transparent pixels keep the toolbar's pixels + visible underneath. */ + double sx = (double)win_size / img_w; + double sy = (double)win_size / img_h; + cairo_scale(cr, sx, sy); + cairo_set_source_surface(cr, src, 0, 0); + cairo_paint(cr); + + cairo_destroy(cr); + cairo_surface_destroy(dst); + cairo_surface_destroy(src); + free(buf); + XFlush(dpy); +} + +void xembed_destroy_icon(Display *dpy, Window icon_win) { + if (icon_win) + XDestroyWindow(dpy, icon_win); + XFlush(dpy); +} + +int xembed_poll_event(Display *dpy, Window icon_win, + int *out_x, int *out_y) { + *out_x = 0; + *out_y = 0; + + while (XPending(dpy) > 0) { + XEvent ev; + XNextEvent(dpy, &ev); + + switch (ev.type) { + case ButtonPress: + if (ev.xbutton.window == icon_win) { + *out_x = ev.xbutton.x_root; + *out_y = ev.xbutton.y_root; + if (ev.xbutton.button == Button1) + return 1; + if (ev.xbutton.button == Button3) + return 2; + } + break; + + case Expose: + if (ev.xexpose.window == icon_win && ev.xexpose.count == 0) + return 3; + break; + + case DestroyNotify: + if (ev.xdestroywindow.window == icon_win) + return -1; + break; + + case ConfigureNotify: + if (ev.xconfigure.window == icon_win) { + *out_x = ev.xconfigure.width; + *out_y = ev.xconfigure.height; + return 4; + } + break; + + case ReparentNotify: + /* Tray manager reparented us — this is expected after docking. */ + break; + + default: + break; + } + } + + return 0; +} + +/* --- GTK4 popup window menu support --- */ + +/* Implemented in Go via //export */ +extern void goMenuItemClicked(int id); + +/* The top-level popup window, reused across invocations. Submenu + popups are tracked in a separate list so they all close when the + top-level closes. */ +static GtkWidget *popup_win = NULL; +static GList *submenu_popups = NULL; /* list of GtkWidget* */ + +typedef struct { + xembed_menu_item *items; + int count; + int x, y; +} popup_data; + +/* Deep-free a heap-owned xembed_menu_item array (label + children). */ +static void free_items(xembed_menu_item *items, int count) { + if (!items) return; + for (int i = 0; i < count; i++) { + free((void *)items[i].label); + free_items(items[i].children, items[i].child_count); + } + free(items); +} + +static void free_popup_data(popup_data *pd) { + if (!pd) return; + free_items(pd->items, pd->count); + free(pd); +} + + +/* Close every popup window — top-level plus any open submenus. + Called when the user clicks an actionable item or focus leaves the + menu tree. */ +static void close_all_popups(void) { + for (GList *l = submenu_popups; l; l = l->next) { + gtk_window_destroy(GTK_WINDOW(l->data)); + } + g_list_free(submenu_popups); + submenu_popups = NULL; + + if (popup_win) { + gtk_widget_set_visible(popup_win, FALSE); + } +} + +static void on_button_clicked(GtkButton *btn, gpointer user_data) { + (void)btn; + int id = GPOINTER_TO_INT(user_data); + close_all_popups(); + goMenuItemClicked(id); +} + +static void on_check_toggled(GtkCheckButton *btn, gpointer user_data) { + (void)btn; + int id = GPOINTER_TO_INT(user_data); + close_all_popups(); + goMenuItemClicked(id); +} + +/* The popup is a regular WM-managed window (not override-redirect), + so the WM hands keyboard focus to it on map. When focus moves + elsewhere — the user clicked somewhere else, switched apps, etc. — + the focus controller's "leave" signal fires and we tear down the + menu tree. Submenus open from inside the top-level popup, so we + defer the actual close to an idle callback: that gives the new + submenu a chance to take focus first, and we only close if none of + our windows still has it. */ +static gboolean any_popup_has_focus(void) { + if (popup_win && gtk_window_is_active(GTK_WINDOW(popup_win))) + return TRUE; + for (GList *l = submenu_popups; l; l = l->next) { + if (gtk_window_is_active(GTK_WINDOW(l->data))) + return TRUE; + } + return FALSE; +} + +static gboolean focus_out_recheck(gpointer user_data) { + (void)user_data; + if (!any_popup_has_focus()) + close_all_popups(); + return G_SOURCE_REMOVE; +} + +static void on_popup_focus_leave(GtkEventControllerFocus *ctrl, + gpointer user_data) { + (void)ctrl; (void)user_data; + g_idle_add(focus_out_recheck, NULL); +} + +/* Attach a focus controller that fires close_all_popups on focus loss. */ +static void attach_outside_click_close(GtkWidget *win) { + GtkEventController *focus = gtk_event_controller_focus_new(); + g_signal_connect(focus, "leave", + G_CALLBACK(on_popup_focus_leave), NULL); + gtk_widget_add_controller(win, focus); +} + +/* Move a GtkWindow at the X11 level. GTK4 removed gtk_window_move(); the + GdkSurface is mapped to a real X11 Window we can reposition with + XMoveWindow. Must be called after the window has been realized (i.e. + after gtk_widget_set_visible TRUE). + + The popup is **not** override-redirect — the WM keeps managing it so + focus tracking still works (focus-out fires when the user clicks + elsewhere). We tag the window with a stack of EWMH hints that make + sane WMs (fluxbox, openbox, i3, kwin, mutter) render it like a + floating menu: above the tray panel, skipped from taskbar/pager, + no decorations. */ +static void x11_move_window(GtkWidget *win, int x, int y) { + GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(win)); + if (!surface || !GDK_IS_X11_SURFACE(surface)) + return; + Window xid = gdk_x11_surface_get_xid(surface); + GdkDisplay *display = gdk_surface_get_display(surface); + Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display)); + + /* These calls poke a window the WM may have already destroyed (a popup + torn down between scheduling and this idle callback). On GDK's Display + use GDK's own error trap rather than our global handler — push/pop is + the spec-correct way to make untrapped BadWindow/BadMatch from these + raw Xlib calls non-fatal, independent of whichever process-global + handler happens to be installed. */ + gdk_x11_display_error_trap_push(display); + + /* _NET_WM_WINDOW_TYPE_POPUP_MENU: makes fluxbox / openbox / etc + render the window above panels and skip decorations. Must be + set before the window is mapped to be honoured by some WMs; + on already-mapped windows it works for most modern WMs but a + few need an unmap/map cycle to re-read the property. */ + Atom wm_type = XInternAtom(xdpy, "_NET_WM_WINDOW_TYPE", False); + Atom wm_type_popup = XInternAtom(xdpy, "_NET_WM_WINDOW_TYPE_POPUP_MENU", False); + XChangeProperty(xdpy, xid, wm_type, XA_ATOM, 32, + PropModeReplace, (unsigned char *)&wm_type_popup, 1); + + /* _NET_WM_STATE_ABOVE + SKIP_TASKBAR + SKIP_PAGER. Bundled into + one property write. */ + Atom wm_state = XInternAtom(xdpy, "_NET_WM_STATE", False); + Atom state_above = XInternAtom(xdpy, "_NET_WM_STATE_ABOVE", False); + Atom state_skip_tb = XInternAtom(xdpy, "_NET_WM_STATE_SKIP_TASKBAR", False); + Atom state_skip_pg = XInternAtom(xdpy, "_NET_WM_STATE_SKIP_PAGER", False); + Atom states[3] = { state_above, state_skip_tb, state_skip_pg }; + XChangeProperty(xdpy, xid, wm_state, XA_ATOM, 32, + PropModeReplace, (unsigned char *)states, 3); + + XMoveWindow(xdpy, xid, x, y); + XRaiseWindow(xdpy, xid); + + /* POPUP_MENU windows aren't given keyboard focus by most WMs (the + spec says they're "menus", which traditionally use a grab rather + than focus). Without focus GtkEventControllerFocus's leave signal + never fires, so we'd have no way to notice the user clicking + elsewhere. Ask the WM to activate us via _NET_ACTIVE_WINDOW + (source=2 means "pager / pseudo-user request" which most WMs + honour without timestamp checks). This is safer than calling + XSetInputFocus directly — that races the X server with the + not-yet-fully-mapped window and trips BadMatch. */ + Atom net_active = XInternAtom(xdpy, "_NET_ACTIVE_WINDOW", False); + XClientMessageEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.type = ClientMessage; + ev.window = xid; + ev.message_type = net_active; + ev.format = 32; + ev.data.l[0] = 2; /* source: pager */ + ev.data.l[1] = CurrentTime; + XSendEvent(xdpy, DefaultRootWindow(xdpy), False, + SubstructureRedirectMask | SubstructureNotifyMask, + (XEvent *)&ev); + + XFlush(xdpy); + gdk_x11_display_error_trap_pop_ignored(display); +} + +/* Forward declaration — submenu buttons need to schedule a child popup. */ +static GtkWidget *build_menu_box(xembed_menu_item *items, int count); + +typedef struct { + xembed_menu_item *items; + int count; + GtkWidget *anchor; /* the submenu button — used to position the popup */ +} submenu_open_data; + +static void on_submenu_button_clicked(GtkButton *btn, gpointer user_data) { + submenu_open_data *sd = (submenu_open_data *)user_data; + + GtkWidget *win = gtk_window_new(); + gtk_window_set_decorated(GTK_WINDOW(win), FALSE); + gtk_window_set_resizable(GTK_WINDOW(win), FALSE); + + attach_outside_click_close(win); + + GtkWidget *vbox = build_menu_box(sd->items, sd->count); + gtk_window_set_child(GTK_WINDOW(win), vbox); + + /* Need the anchor button's position in root coordinates. GTK4 + removed gtk_widget_translate_coordinates(); compute via the + button's bounds within its native widget plus the native + surface's screen origin via X11. */ + graphene_rect_t bounds; + if (!gtk_widget_compute_bounds(GTK_WIDGET(btn), + GTK_WIDGET(gtk_widget_get_native(GTK_WIDGET(btn))), + &bounds)) { + bounds.origin.x = 0; + bounds.origin.y = 0; + bounds.size.width = 0; + bounds.size.height = 0; + } + GdkSurface *anchor_surface = + gtk_native_get_surface(gtk_widget_get_native(GTK_WIDGET(btn))); + int ox = 0, oy = 0; + if (anchor_surface && GDK_IS_X11_SURFACE(anchor_surface)) { + Window axid = gdk_x11_surface_get_xid(anchor_surface); + GdkDisplay *display = gdk_surface_get_display(anchor_surface); + Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display)); + Window child; + /* Trap BadWindow in case the anchor's surface is torn down between + the click and this handler running. */ + gdk_x11_display_error_trap_push(display); + XTranslateCoordinates(xdpy, axid, DefaultRootWindow(xdpy), + 0, 0, &ox, &oy, &child); + gdk_x11_display_error_trap_pop_ignored(display); + } + int ax = ox + (int)bounds.origin.x; + int ay = oy + (int)bounds.origin.y; + + gtk_widget_set_visible(win, TRUE); + + int sw, sh; + gtk_window_get_default_size(GTK_WINDOW(win), &sw, &sh); + if (sw <= 0 || sh <= 0) { + /* default_size returns -1,-1 if never explicitly set; fall back + to the measured preferred size. */ + GtkRequisition req; + gtk_widget_get_preferred_size(win, NULL, &req); + sw = req.width; + sh = req.height; + } + + /* The parent popup grows upward from the tray, so submenu items + sit closer to the bottom of the screen than to the top. Align + the submenu's BOTTOM to the anchor button's bottom: the popup + grows upward, level with the row that opened it. */ + int final_x = ax + (int)bounds.size.width; + int final_y = ay + (int)bounds.size.height - sh; + + /* Horizontal flip against the monitor under the anchor button. */ + GdkDisplay *display = gtk_widget_get_display(win); + GListModel *monitors = gdk_display_get_monitors(display); + guint n = g_list_model_get_n_items(monitors); + for (guint i = 0; i < n; i++) { + GdkMonitor *m = (GdkMonitor *)g_list_model_get_item(monitors, i); + GdkRectangle geom; + gdk_monitor_get_geometry(m, &geom); + if (ax >= geom.x && ax < geom.x + geom.width && + ay >= geom.y && ay < geom.y + geom.height) { + if (final_x + sw > geom.x + geom.width) + final_x = ax - sw; /* flip to the left */ + g_object_unref(m); + break; + } + g_object_unref(m); + } + + x11_move_window(win, final_x, final_y); + gtk_window_present(GTK_WINDOW(win)); + + submenu_popups = g_list_prepend(submenu_popups, win); +} + +/* Build a vbox of GtkWidgets for the supplied items. Used for both the + top-level popup and each submenu popup. The submenu_open_data attached + to submenu buttons is freed when the button is destroyed. */ +static void on_button_destroy_free_data(GtkWidget *widget, gpointer user_data) { + (void)widget; + free(user_data); +} + +static GtkWidget *build_menu_box(xembed_menu_item *items, int count) { + GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + + for (int i = 0; i < count; i++) { + xembed_menu_item *mi = &items[i]; + + if (mi->is_separator) { + GtkWidget *sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL); + gtk_widget_set_margin_top(sep, 2); + gtk_widget_set_margin_bottom(sep, 2); + gtk_box_append(GTK_BOX(vbox), sep); + continue; + } + + if (mi->is_check) { + GtkWidget *chk = gtk_check_button_new_with_label( + mi->label ? mi->label : ""); + gtk_check_button_set_active(GTK_CHECK_BUTTON(chk), mi->checked); + gtk_widget_set_sensitive(chk, mi->enabled); + g_signal_connect(chk, "toggled", + G_CALLBACK(on_check_toggled), + GINT_TO_POINTER(mi->id)); + gtk_box_append(GTK_BOX(vbox), chk); + continue; + } + + /* Plain button (leaf) or submenu opener. Show "Label ▸" for + submenu folders so users see they're nested. */ + const char *label_text = mi->label ? mi->label : ""; + char *display_label = NULL; + if (mi->child_count > 0 && mi->children) { + /* Compose "label ▸" (BLACK RIGHT-POINTING SMALL TRIANGLE). */ + size_t n = strlen(label_text) + 8; /* ascii + " ▸" + NUL */ + display_label = (char *)malloc(n); + snprintf(display_label, n, "%s \xE2\x96\xB8", label_text); + label_text = display_label; + } + + GtkWidget *btn = gtk_button_new_with_label(label_text); + gtk_widget_set_sensitive(btn, mi->enabled); + gtk_button_set_has_frame(GTK_BUTTON(btn), FALSE); + GtkWidget *lbl = gtk_button_get_child(GTK_BUTTON(btn)); + if (GTK_IS_LABEL(lbl)) + gtk_label_set_xalign(GTK_LABEL(lbl), 0.0); + + free(display_label); + + if (mi->child_count > 0 && mi->children) { + submenu_open_data *sd = + (submenu_open_data *)calloc(1, sizeof(submenu_open_data)); + sd->items = mi->children; + sd->count = mi->child_count; + sd->anchor = btn; + g_signal_connect(btn, "clicked", + G_CALLBACK(on_submenu_button_clicked), sd); + g_signal_connect(btn, "destroy", + G_CALLBACK(on_button_destroy_free_data), sd); + } else { + g_signal_connect(btn, "clicked", + G_CALLBACK(on_button_clicked), + GINT_TO_POINTER(mi->id)); + } + gtk_box_append(GTK_BOX(vbox), btn); + } + + return vbox; +} + +static gboolean popup_menu_idle(gpointer user_data) { + popup_data *pd = (popup_data *)user_data; + + /* Destroy old top-level (and orphan submenus) before rebuilding. */ + close_all_popups(); + if (popup_win) { + gtk_window_destroy(GTK_WINDOW(popup_win)); + popup_win = NULL; + } + + popup_win = gtk_window_new(); + gtk_window_set_decorated(GTK_WINDOW(popup_win), FALSE); + gtk_window_set_resizable(GTK_WINDOW(popup_win), FALSE); + + attach_outside_click_close(popup_win); + + GtkWidget *vbox = build_menu_box(pd->items, pd->count); + gtk_window_set_child(GTK_WINDOW(popup_win), vbox); + + gtk_widget_set_visible(popup_win, TRUE); + + /* Position the window above the click point (menu grows upward + from tray). Use measured preferred size — default_size is -1 + until set. */ + GtkRequisition req; + gtk_widget_get_preferred_size(popup_win, NULL, &req); + int win_w = req.width; + int win_h = req.height; + + int final_x = pd->x - win_w / 2; + int final_y = pd->y - win_h; + if (final_x < 0) final_x = 0; + if (final_y < 0) final_y = pd->y; /* fallback: below click */ + x11_move_window(popup_win, final_x, final_y); + + gtk_window_present(GTK_WINDOW(popup_win)); + + /* The vbox+children retain pointers into pd->items (via submenu + click handlers). free_popup_data() walks the array recursively + to release labels and children buffers — but we need to keep + the items alive while the popup is open. Defer the free until + the popup window is destroyed. */ + g_object_set_data_full(G_OBJECT(popup_win), "popup_data", pd, + (GDestroyNotify)free_popup_data); + return G_SOURCE_REMOVE; +} + +/* Recursively deep-copy a Go-supplied items array into freshly-allocated + C memory. Each label is strdup'd, each children array is calloc'd. */ +static xembed_menu_item *copy_items(xembed_menu_item *src, int count) { + if (count <= 0 || !src) return NULL; + xembed_menu_item *dst = + (xembed_menu_item *)calloc(count, sizeof(xembed_menu_item)); + for (int i = 0; i < count; i++) { + dst[i] = src[i]; + if (src[i].label) + dst[i].label = strdup(src[i].label); + if (src[i].child_count > 0 && src[i].children) { + dst[i].children = copy_items(src[i].children, src[i].child_count); + dst[i].child_count = src[i].child_count; + } else { + dst[i].children = NULL; + dst[i].child_count = 0; + } + } + return dst; +} + +void xembed_show_popup_menu(xembed_menu_item *items, int count, + xembed_menu_click_cb cb, int x, int y) { + (void)cb; + popup_data *pd = (popup_data *)calloc(1, sizeof(popup_data)); + pd->items = copy_items(items, count); + pd->count = count; + pd->x = x; + pd->y = y; + + g_idle_add(popup_menu_idle, pd); +} diff --git a/client/ui/xembed_tray_linux.h b/client/ui/xembed_tray_linux.h new file mode 100644 index 000000000..18a77c4c0 --- /dev/null +++ b/client/ui/xembed_tray_linux.h @@ -0,0 +1,81 @@ +#ifndef XEMBED_TRAY_H +#define XEMBED_TRAY_H + +#include + +// xembed_default_screen wraps the DefaultScreen macro for CGo. +static inline int xembed_default_screen(Display *dpy) { + return DefaultScreen(dpy); +} + +// xembed_install_error_handlers replaces Xlib's default protocol- and +// I/O-error handlers (which call exit()) with logging handlers, so an async +// X error from a tray race doesn't kill the UI process. Process-global and +// idempotent; safe to call after every XOpenDisplay. +void xembed_install_error_handlers(void); + +// xembed_find_tray returns the selection owner window for +// _NET_SYSTEM_TRAY_S{screen}, or 0 if no XEmbed tray manager exists. +Window xembed_find_tray(Display *dpy, int screen); + +// xembed_get_icon_size queries _NET_SYSTEM_TRAY_ICON_SIZE from the tray +// manager window. Returns the size in pixels, or 0 if not set. +int xembed_get_icon_size(Display *dpy, Window tray_mgr); + +// xembed_create_icon creates a tray icon window of the given size, +// sets _XEMBED_INFO, and returns the window ID. +// tray_mgr is the tray manager window; its _NET_SYSTEM_TRAY_VISUAL +// property is queried to obtain a 32-bit ARGB visual for transparency. +Window xembed_create_icon(Display *dpy, int screen, int size, Window tray_mgr); + +// xembed_dock sends _NET_SYSTEM_TRAY_OPCODE SYSTEM_TRAY_REQUEST_DOCK +// to the tray manager to embed our icon window. +int xembed_dock(Display *dpy, Window tray_mgr, Window icon_win); + +// xembed_draw_icon draws ARGB pixel data onto the icon window. +// data is in [A,R,G,B] byte order per pixel (SNI IconPixmap format). +// img_w, img_h are the source image dimensions. +// win_size is the target window dimension (square). +void xembed_draw_icon(Display *dpy, Window icon_win, int win_size, + const unsigned char *data, int img_w, int img_h); + +// xembed_destroy_icon destroys the icon window. +void xembed_destroy_icon(Display *dpy, Window icon_win); + +// xembed_poll_event processes pending X11 events. Returns: +// 0 = no actionable event +// 1 = left button press (out_x, out_y filled) +// 2 = right button press (out_x, out_y filled) +// 3 = expose (needs redraw) +// 4 = configure (resize; out_x=width, out_y=height) +// -1 = DestroyNotify on icon window (tray died) +int xembed_poll_event(Display *dpy, Window icon_win, + int *out_x, int *out_y); + +// Callback type for menu item clicks. Called with the item's dbusmenu ID. +typedef void (*xembed_menu_click_cb)(int id); + +// xembed_popup_menu builds and shows a GTK3 popup menu. +// items is an array of menu item descriptors, count is the number of items. +// cb is called (from the GTK main thread) when an item is clicked. +// x, y are root coordinates for positioning the popup. +// This must be called from the GTK main thread (use g_idle_add). + +typedef struct xembed_menu_item { + int id; // dbusmenu item ID + const char *label; // display label (NULL for separator) + int enabled; // whether the item is clickable + int is_check; // whether this is a checkbox item + int checked; // checkbox state (0 or 1) + int is_separator;// 1 if this is a separator + // children + child_count populate when this item is a submenu folder + // (dbusmenu's children-display=="submenu"). NULL/0 means leaf item. + struct xembed_menu_item *children; + int child_count; +} xembed_menu_item; + +// Schedule a GTK popup menu on the main thread. +void xembed_show_popup_menu(xembed_menu_item *items, int count, + xembed_menu_click_cb cb, int x, int y); + +#endif diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go index 4683f4033..260a528f0 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -56,8 +56,7 @@ func startClient(ctx context.Context, nbClient *netbird.Client) error { // parseClientOptions extracts NetBird options from JavaScript object func parseClientOptions(jsOptions js.Value) (netbird.Options, error) { options := netbird.Options{ - DeviceName: "dashboard-client", - LogLevel: defaultLogLevel, + LogLevel: defaultLogLevel, } if jwtToken := jsOptions.Get("jwtToken"); !jwtToken.IsNull() && !jwtToken.IsUndefined() { @@ -87,13 +86,41 @@ func parseClientOptions(jsOptions js.Value) (netbird.Options, error) { options.DeviceName = deviceName.String() } - if disableIPv6 := jsOptions.Get("disableIPv6"); !disableIPv6.IsNull() && !disableIPv6.IsUndefined() { - options.DisableIPv6 = disableIPv6.Bool() + disableIPv6, err := boolOption(jsOptions, "disableIPv6") + if err != nil { + return options, err + } + if disableIPv6 != nil { + options.DisableIPv6 = *disableIPv6 } + // The caller decides whether this client uses lazy connections; left unset it + // defers to the management feature flag. A short-lived, interactive caller + // turns it off so its sessions reach the few peers their grant covers eagerly, + // instead of the first request waiting for the connection to be established. + lazyConnectionEnabled, err := boolOption(jsOptions, "lazyConnectionEnabled") + if err != nil { + return options, err + } + options.LazyConnectionEnabled = lazyConnectionEnabled + return options, nil } +// boolOption reads a boolean option, returning nil when the caller left it out. +// js.Value.Bool panics on any other type, so a wrong type is reported instead. +func boolOption(jsOptions js.Value, name string) (*bool, error) { + v := jsOptions.Get(name) + if v.IsNull() || v.IsUndefined() { + return nil, nil + } + if v.Type() != js.TypeBoolean { + return nil, fmt.Errorf("option %s must be a boolean, got %s", name, v.Type()) + } + b := v.Bool() + return &b, nil +} + // createStartMethod creates the start method for the client func createStartMethod(client *netbird.Client) js.Func { return js.FuncOf(func(this js.Value, args []js.Value) any { diff --git a/client/wasm/cmd/main_test.go b/client/wasm/cmd/main_test.go new file mode 100644 index 000000000..3ec5a8f6a --- /dev/null +++ b/client/wasm/cmd/main_test.go @@ -0,0 +1,64 @@ +//go:build js + +package main + +import ( + "syscall/js" + "testing" +) + +// TestParseClientOptionsBooleans covers the boolean options against the value +// kinds a JS caller can pass: js.Value.Bool panics on anything but a boolean, +// so a wrong type has to be rejected before it reaches the client. +func TestParseClientOptionsBooleans(t *testing.T) { + t.Run("unset leaves the lazy override empty", func(t *testing.T) { + options, err := parseClientOptions(js.Global().Get("Object").New()) + if err != nil { + t.Fatalf("parse options: %v", err) + } + if options.LazyConnectionEnabled != nil { + t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled) + } + if options.DisableIPv6 { + t.Error("disableIPv6 should default to false") + } + }) + + t.Run("null defers to the management flag", func(t *testing.T) { + jsOptions := js.Global().Get("Object").New() + jsOptions.Set("lazyConnectionEnabled", js.Null()) + options, err := parseClientOptions(jsOptions) + if err != nil { + t.Fatalf("parse options: %v", err) + } + if options.LazyConnectionEnabled != nil { + t.Errorf("lazy override should stay unset, got %v", *options.LazyConnectionEnabled) + } + }) + + t.Run("booleans are carried through", func(t *testing.T) { + jsOptions := js.Global().Get("Object").New() + jsOptions.Set("lazyConnectionEnabled", false) + jsOptions.Set("disableIPv6", true) + options, err := parseClientOptions(jsOptions) + if err != nil { + t.Fatalf("parse options: %v", err) + } + if options.LazyConnectionEnabled == nil || *options.LazyConnectionEnabled { + t.Errorf("lazy override should be false, got %v", options.LazyConnectionEnabled) + } + if !options.DisableIPv6 { + t.Error("disableIPv6 should be true") + } + }) + + t.Run("a non-boolean is rejected", func(t *testing.T) { + for _, value := range []any{"true", 1, js.Global().Get("Object").New()} { + jsOptions := js.Global().Get("Object").New() + jsOptions.Set("lazyConnectionEnabled", value) + if _, err := parseClientOptions(jsOptions); err == nil { + t.Errorf("value %v should be rejected", value) + } + } + }) +} diff --git a/client/wasm/internal/rdp/rdcleanpath.go b/client/wasm/internal/rdp/rdcleanpath.go index ee420dca4..3d8be8950 100644 --- a/client/wasm/internal/rdp/rdcleanpath.go +++ b/client/wasm/internal/rdp/rdcleanpath.go @@ -23,7 +23,7 @@ const ( RDCleanPathProxyHost = "rdcleanpath.proxy.local" RDCleanPathProxyScheme = "ws" - rdpDialTimeout = 15 * time.Second + rdpDialTimeout = 30 * time.Second GeneralErrorCode = 1 WSAETimedOut = 10060 diff --git a/client/wasm/internal/ssh/client.go b/client/wasm/internal/ssh/client.go index 9cfe65266..28ae95ec0 100644 --- a/client/wasm/internal/ssh/client.go +++ b/client/wasm/internal/ssh/client.go @@ -80,13 +80,12 @@ func (c *Client) Connect(host string, port int, username, jwtToken string, ipVer return fmt.Errorf("dial %s: %w", addr, err) } - sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config) + sshClient, err := nbssh.Handshake(ctx, conn, addr, config) if err != nil { - closeWithLog(conn, "connection after handshake error") - return fmt.Errorf("SSH handshake: %w", err) + return err } - c.sshClient = ssh.NewClient(sshConn, chans, reqs) + c.sshClient = sshClient logrus.Infof("SSH: Connected to %s", addr) return nil @@ -119,57 +118,26 @@ func (c *Client) getAuthMethods(jwtToken string) ([]ssh.AuthMethod, error) { return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil } -// StartSession starts an SSH session with PTY +// StartSession starts an SSH session with PTY. It holds the client lock for +// the whole startup so Close cannot tear the client down mid-setup and the +// new session cannot be installed into an already closed client. func (c *Client) StartSession(cols, rows int) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.sshClient == nil { return fmt.Errorf("SSH client not connected") } - session, err := c.sshClient.NewSession() + pty, err := nbssh.StartPTYSession(c.sshClient, cols, rows) if err != nil { - return fmt.Errorf("create session: %w", err) + return err } - c.mu.Lock() - defer c.mu.Unlock() - c.session = session - - modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - ssh.VINTR: 3, - ssh.VQUIT: 28, - ssh.VERASE: 127, - } - - if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil { - closeWithLog(session, "session after PTY error") - return fmt.Errorf("PTY request: %w", err) - } - - c.stdin, err = session.StdinPipe() - if err != nil { - closeWithLog(session, "session after stdin error") - return fmt.Errorf("get stdin: %w", err) - } - - c.stdout, err = session.StdoutPipe() - if err != nil { - closeWithLog(session, "session after stdout error") - return fmt.Errorf("get stdout: %w", err) - } - - c.stderr, err = session.StderrPipe() - if err != nil { - closeWithLog(session, "session after stderr error") - return fmt.Errorf("get stderr: %w", err) - } - - if err := session.Shell(); err != nil { - closeWithLog(session, "session after shell error") - return fmt.Errorf("start shell: %w", err) - } + c.session = pty.Session + c.stdin = pty.Stdin + c.stdout = pty.Stdout + c.stderr = pty.Stderr logrus.Info("SSH: Session started with PTY") return nil diff --git a/combined/Dockerfile.multistage b/combined/Dockerfile.multistage index ef3d68c6e..79746819d 100644 --- a/combined/Dockerfile.multistage +++ b/combined/Dockerfile.multistage @@ -5,12 +5,16 @@ WORKDIR /app RUN apt-get update && apt-get install -y gcc libc6-dev git && rm -rf /var/lib/apt/lists/* COPY go.mod go.sum ./ -RUN go mod download +RUN --mount=type=cache,target=/go/pkg/mod go mod download COPY . . -# Build with version info from git (matching goreleaser ldflags) -RUN CGO_ENABLED=1 GOOS=linux go build \ +# Build with version info from git (matching goreleaser ldflags). +# BuildKit cache mounts persist the module + build caches across image builds, +# so a source change recompiles incrementally instead of from scratch. +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=1 GOOS=linux go build \ -ldflags="-s -w \ -X github.com/netbirdio/netbird/version.version=$(git describe --tags --always --dirty 2>/dev/null || echo 'dev') \ -X main.commit=$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown') \ diff --git a/combined/cmd/admin.go b/combined/cmd/admin.go new file mode 100644 index 000000000..66fac4ac9 --- /dev/null +++ b/combined/cmd/admin.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/dexidp/dex/storage" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/formatter/hook" + admincmd "github.com/netbirdio/netbird/management/cmd/admin" + tokencmd "github.com/netbirdio/netbird/management/cmd/token" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/activity" + activitystore "github.com/netbirdio/netbird/management/server/activity/store" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/util" +) + +// newAdminCommands creates the admin command tree with combined-specific resource openers. +func newAdminCommands() *cobra.Command { + return admincmd.NewCommands(admincmd.Openers{ + Resources: withAdminResources, + Store: withAdminStoreOnly, + IDP: withAdminIDPOnly, + }) +} + +func newLegacyTokenCommand() *cobra.Command { + cmd := tokencmd.NewCommands(tokencmd.StoreOpener(withAdminStoreOnly)) + cmd.Deprecated = "use 'admin token' instead" + return cmd +} + +// withAdminResources loads the combined YAML config, initializes stores, and calls fn. +func withAdminResources(cmd *cobra.Command, fn func(ctx context.Context, resources admincmd.Resources) error) error { + return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error { + mgmtConfig, err := adminManagementConfig(cfg) + if err != nil { + return err + } + + managementStore, err := openAdminStore(ctx, cfg) + if err != nil { + return err + } + defer admincmd.CloseStore(ctx, managementStore) + + idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(mgmtConfig) + if err != nil { + return err + } + defer admincmd.CloseIDPStorage(idpStorage) + + eventStore, esErr := openAdminEventStore(ctx, cfg, mgmtConfig) + if esErr != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: audit events will not be recorded: %v\n", esErr) + } + if eventStore != nil { + defer func() { + if err := eventStore.Close(ctx); err != nil { + log.Debugf("close activity event store: %v", err) + } + }() + } + + return fn(ctx, admincmd.Resources{Store: managementStore, IDPStorage: idpStorage, IDPStorageFile: idpStorageFile, EventStore: eventStore}) + }) +} + +// withAdminStoreOnly opens only the management store for admin subcommands that do not +// need embedded IdP storage. +func withAdminStoreOnly(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { + return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error { + managementStore, err := openAdminStore(ctx, cfg) + if err != nil { + return err + } + defer admincmd.CloseStore(ctx, managementStore) + + return fn(ctx, managementStore) + }) +} + +func withAdminIDPOnly(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error { + return withAdminConfig(cmd, func(ctx context.Context, cfg *CombinedConfig) error { + mgmtConfig, err := adminManagementConfig(cfg) + if err != nil { + return err + } + idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(mgmtConfig) + if err != nil { + return err + } + defer admincmd.CloseIDPStorage(idpStorage) + + return fn(ctx, idpStorage, idpStorageFile) + }) +} + +func withAdminConfig(cmd *cobra.Command, fn func(ctx context.Context, cfg *CombinedConfig) error) error { + if err := util.InitLog("error", "console"); err != nil { + return fmt.Errorf("init log: %w", err) + } + + ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck + + cfg, err := LoadConfig(configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + cfg.ApplyAdminDefaults() + applyServerStoreEnv(cfg.Server.Store) + + return fn(ctx, cfg) +} + +func adminManagementConfig(cfg *CombinedConfig) (*nbconfig.Config, error) { + mgmtConfig, err := cfg.ToManagementConfig() + if err != nil { + return nil, fmt.Errorf("create management config: %w", err) + } + return mgmtConfig, nil +} + +func openAdminStore(ctx context.Context, cfg *CombinedConfig) (store.Store, error) { + managementStore, err := store.NewStore(ctx, types.Engine(cfg.Management.Store.Engine), cfg.Management.DataDir, nil, true) + if err != nil { + return nil, fmt.Errorf("create store: %w", err) + } + return managementStore, nil +} + +func openAdminEventStore(ctx context.Context, cfg *CombinedConfig, config *nbconfig.Config) (activity.Store, error) { + if config.DataStoreEncryptionKey == "" { + return nil, fmt.Errorf("data store encryption key is not configured") + } + if err := applyActivityStoreEnv(cfg.Server.ActivityStore); err != nil { + return nil, fmt.Errorf("configure activity event store: %w", err) + } + eventStore, err := activitystore.NewSqlStore(ctx, config.Datadir, config.DataStoreEncryptionKey) + if err != nil { + return nil, fmt.Errorf("open activity event store: %w", err) + } + if eventStore == nil { + return nil, fmt.Errorf("open activity event store: returned nil store") + } + return eventStore, nil +} diff --git a/combined/cmd/admin_config_test.go b/combined/cmd/admin_config_test.go new file mode 100644 index 000000000..ff7045d38 --- /dev/null +++ b/combined/cmd/admin_config_test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" +) + +func TestApplyAdminDefaultsCopiesServerStoreWithoutExposedAddress(t *testing.T) { + cfg := DefaultConfig() + cfg.Server.ExposedAddress = "" + cfg.Server.DataDir = "/srv/netbird" + cfg.Server.Store = StoreConfig{ + Engine: "postgres", + DSN: "postgres://user:pass@example.com/netbird", + } + + cfg.ApplyAdminDefaults() + + require.Equal(t, "/srv/netbird", cfg.Management.DataDir) + require.Equal(t, "postgres", cfg.Management.Store.Engine) + require.Equal(t, cfg.Server.Store.DSN, cfg.Management.Store.DSN) +} + +func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T) { + eventStore, err := openAdminEventStore(context.Background(), &CombinedConfig{}, &nbconfig.Config{}) + require.Error(t, err) + require.Contains(t, err.Error(), "encryption key") + require.Nil(t, eventStore) +} + +func TestApplyServerStoreEnv(t *testing.T) { + t.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", "") + t.Setenv("NB_STORE_ENGINE_MYSQL_DSN", "") + t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", "") + + applyServerStoreEnv(StoreConfig{Engine: "postgres", DSN: "postgres-dsn", File: "store.db"}) + require.Equal(t, "postgres-dsn", os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN")) + require.Equal(t, "store.db", os.Getenv("NB_STORE_ENGINE_SQLITE_FILE")) + + applyServerStoreEnv(StoreConfig{Engine: "mysql", DSN: "mysql-dsn"}) + require.Equal(t, "mysql-dsn", os.Getenv("NB_STORE_ENGINE_MYSQL_DSN")) +} diff --git a/combined/cmd/config.go b/combined/cmd/config.go index fe350e52a..890c86876 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -6,8 +6,7 @@ import ( "net" "net/netip" "os" - "path" - "path/filepath" + filePath "path/filepath" "strings" "time" @@ -74,6 +73,16 @@ type ServerConfig struct { ActivityStore StoreConfig `yaml:"activityStore"` AuthStore StoreConfig `yaml:"authStore"` ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"` + + SupportedSyncMessageVersions *int `yaml:"supportedSyncMessageVersions,omitempty"` + PerAccountSupportedSyncMessageVersions map[string]int `yaml:"perAccountSupportedSyncMessageVersions,omitempty"` + + AgentNetwork AgentNetworkConfig `yaml:"agentNetwork"` +} + +// AgentNetworkConfig contains agent-network (LLM gateway) configuration. +type AgentNetworkConfig struct { + PricingDefaultsFile string `yaml:"pricingDefaultsFile"` } // TLSConfig contains TLS/HTTPS settings @@ -145,6 +154,7 @@ type AuthConfig struct { CLIRedirectURIs []string `yaml:"cliRedirectURIs"` Owner *AuthOwnerConfig `yaml:"owner,omitempty"` DashboardPostLogoutRedirectURIs []string `yaml:"dashboardPostLogoutRedirectURIs"` + GrantTypes []string `yaml:"grantTypes"` } // AuthStorageConfig contains auth storage settings @@ -299,6 +309,19 @@ func (c *CombinedConfig) ApplySimplifiedDefaults() { c.autoConfigureClientSettings(exposedProto, exposedHost, exposedHostPort, hasExternalStuns, hasExternalRelay, hasExternalSignal) } +// ApplyAdminDefaults applies the management settings needed by admin commands even +// when the full server config is invalid and ApplySimplifiedDefaults cannot run. +func (c *CombinedConfig) ApplyAdminDefaults() { + if c.Management.DataDir == "" || c.Management.DataDir == "/var/lib/netbird/" { + c.Management.DataDir = c.Server.DataDir + } + if c.Management.Store.Engine == "" || c.Management.Store.Engine == "sqlite" { + if c.Server.Store.Engine != "" || c.Server.Store.File != "" || c.Server.Store.DSN != "" { + c.Management.Store = c.Server.Store + } + } +} + // applyRelayDefaults configures the relay service if no external relay is configured. func (c *CombinedConfig) applyRelayDefaults(exposedProto, exposedHostPort string, hasExternalRelay, hasExternalStuns bool) { if hasExternalRelay { @@ -576,11 +599,11 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb return nil, fmt.Errorf("authStore.dsn is required when authStore.engine is postgres") } } else { - authStorageFile = path.Join(mgmt.DataDir, "idp.db") + authStorageFile = filePath.Join(mgmt.DataDir, "idp.db") if c.Server.AuthStore.File != "" { authStorageFile = c.Server.AuthStore.File - if !filepath.IsAbs(authStorageFile) { - authStorageFile = filepath.Join(mgmt.DataDir, authStorageFile) + if !filePath.IsAbs(authStorageFile) { + authStorageFile = filePath.Join(mgmt.DataDir, authStorageFile) } } } @@ -604,6 +627,7 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb DashboardRedirectURIs: mgmt.Auth.DashboardRedirectURIs, CLIRedirectURIs: mgmt.Auth.CLIRedirectURIs, DashboardPostLogoutRedirectURIs: mgmt.Auth.DashboardPostLogoutRedirectURIs, + GrantTypes: mgmt.Auth.GrantTypes, } if mgmt.Auth.Owner != nil && mgmt.Auth.Owner.Email != "" { @@ -694,16 +718,21 @@ func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) { httpConfig.AuthCallbackURL = callbackURL + types.ProxyCallbackEndpointFull return &nbconfig.Config{ - Stuns: stuns, - Relay: relayConfig, - Signal: signalConfig, - Datadir: mgmt.DataDir, - DataStoreEncryptionKey: mgmt.Store.EncryptionKey, - HttpConfig: httpConfig, - StoreConfig: storeConfig, - ReverseProxy: reverseProxy, - DisableDefaultPolicy: mgmt.DisableDefaultPolicy, - EmbeddedIdP: embeddedIdP, + Stuns: stuns, + Relay: relayConfig, + Signal: signalConfig, + Datadir: mgmt.DataDir, + DataStoreEncryptionKey: mgmt.Store.EncryptionKey, + HttpConfig: httpConfig, + StoreConfig: storeConfig, + ReverseProxy: reverseProxy, + DisableDefaultPolicy: mgmt.DisableDefaultPolicy, + EmbeddedIdP: embeddedIdP, + HighestSupportedSyncMessageVersion: c.Server.SupportedSyncMessageVersions, + PerAccountHighestSupportedSyncMessageVersion: c.Server.PerAccountSupportedSyncMessageVersions, + AgentNetwork: nbconfig.AgentNetwork{ + PricingDefaultsFile: c.Server.AgentNetwork.PricingDefaultsFile, + }, }, nil } @@ -727,7 +756,7 @@ func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config, mgmtPort cfg.EmbeddedIdP.Storage.Type = "sqlite3" } if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" { - cfg.EmbeddedIdP.Storage.Config.File = path.Join(cfg.Datadir, "idp.db") + cfg.EmbeddedIdP.Storage.Config.File = filePath.Join(cfg.Datadir, "idp.db") } issuer := cfg.EmbeddedIdP.Issuer diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 31e0580fb..7eac84ce5 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "strconv" "strings" "sync" @@ -24,6 +25,7 @@ import ( "google.golang.org/grpc" "github.com/netbirdio/netbird/encryption" + agentnetworkpricing "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" mgmtServer "github.com/netbirdio/netbird/management/internals/server" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/telemetry" @@ -31,6 +33,7 @@ import ( relayServer "github.com/netbirdio/netbird/relay/server" "github.com/netbirdio/netbird/relay/server/listener" "github.com/netbirdio/netbird/relay/server/listener/ws" + syncgrpc "github.com/netbirdio/netbird/shared/management/grpc" sharedMetrics "github.com/netbirdio/netbird/shared/metrics" "github.com/netbirdio/netbird/shared/relay/auth" "github.com/netbirdio/netbird/shared/signal/proto" @@ -64,7 +67,8 @@ func init() { rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to YAML configuration file (required)") _ = rootCmd.MarkPersistentFlagRequired("config") - rootCmd.AddCommand(newTokenCommands()) + rootCmd.AddCommand(newAdminCommands()) + rootCmd.AddCommand(newLegacyTokenCommand()) } func RootCmd() *cobra.Command { @@ -122,6 +126,37 @@ func execute(cmd *cobra.Command, _ []string) error { } // initializeConfig loads and validates the configuration, then initializes logging. +func applyServerStoreEnv(storeConfig StoreConfig) { + if dsn := storeConfig.DSN; dsn != "" { + switch strings.ToLower(storeConfig.Engine) { + case "postgres": + os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) + case "mysql": + os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn) + } + } + if file := storeConfig.File; file != "" { + os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file) + } +} + +func applyActivityStoreEnv(storeConfig StoreConfig) error { + if engine := storeConfig.Engine; engine != "" { + engineLower := strings.ToLower(engine) + if engineLower == "postgres" && storeConfig.DSN == "" { + return fmt.Errorf("activityStore.dsn is required when activityStore.engine is postgres") + } + os.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", engineLower) + if dsn := storeConfig.DSN; dsn != "" { + os.Setenv("NB_ACTIVITY_EVENT_POSTGRES_DSN", dsn) + } + } + if file := storeConfig.File; file != "" { + os.Setenv("NB_ACTIVITY_EVENT_SQLITE_FILE", file) + } + return nil +} + func initializeConfig() error { var err error config, err = LoadConfig(configPath) @@ -137,30 +172,10 @@ func initializeConfig() error { return fmt.Errorf("failed to initialize log: %w", err) } - if dsn := config.Server.Store.DSN; dsn != "" { - switch strings.ToLower(config.Server.Store.Engine) { - case "postgres": - os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) - case "mysql": - os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn) - } - } - if file := config.Server.Store.File; file != "" { - os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file) - } + applyServerStoreEnv(config.Server.Store) - if engine := config.Server.ActivityStore.Engine; engine != "" { - engineLower := strings.ToLower(engine) - if engineLower == "postgres" && config.Server.ActivityStore.DSN == "" { - return fmt.Errorf("activityStore.dsn is required when activityStore.engine is postgres") - } - os.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", engineLower) - if dsn := config.Server.ActivityStore.DSN; dsn != "" { - os.Setenv("NB_ACTIVITY_EVENT_POSTGRES_DSN", dsn) - } - } - if file := config.Server.ActivityStore.File; file != "" { - os.Setenv("NB_ACTIVITY_EVENT_SQLITE_FILE", file) + if err := applyActivityStoreEnv(config.Server.ActivityStore); err != nil { + return err } log.Infof("Starting combined NetBird server") @@ -226,7 +241,7 @@ func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool } hashedSecret := sha256.Sum256([]byte(cfg.Relay.AuthSecret)) - authenticator := auth.NewTimedHMACValidator(hashedSecret[:], 24*time.Hour) + authenticator := auth.NewTimedHMACValidator(hashedSecret[:]) relayCfg := relayServer.Config{ Meter: s.metricsServer.Meter, @@ -275,6 +290,11 @@ func (s *serverInstances) createManagementServer(ctx context.Context, cfg *Combi return fmt.Errorf("failed to ensure encryption key: %w", err) } + if err := loadAgentNetworkPricing(ctx, mgmtConfig); err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to load agent-network pricing defaults: %w", err) + } + LogConfigInfo(mgmtConfig) s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig) @@ -505,6 +525,16 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (m } mgmtPort, _ := strconv.Atoi(portStr) + if err := syncgrpc.ValidateSyncMessageVersion(mgmtConfig.HighestSupportedSyncMessageVersion); err != nil { + return nil, err + } + + for accountId, version := range mgmtConfig.PerAccountHighestSupportedSyncMessageVersion { + if err := syncgrpc.ValidateSyncMessageVersion(&version); err != nil { + return nil, fmt.Errorf("unrecognized sync message version in perAccountSupportedSyncMessageVersions for account %s %w", accountId, err) + } + } + mgmtSrv := newServer( &mgmtServer.Config{ NbConfig: mgmtConfig, @@ -599,6 +629,32 @@ func handleRelayWebSocket(w http.ResponseWriter, r *http.Request, acceptFn func( acceptFn(conn) } +// loadAgentNetworkPricing loads the management-side LLM pricing defaults +// file for the combined server and starts its periodic reloader. An +// explicitly configured PricingDefaultsFile is required to load (a typo +// must fail startup rather than silently bill with built-ins the operator +// believes they replaced); a relative path is resolved against the data +// directory so a bare filename like "pricing.yaml" lands in the datadir +// alongside the store. With no path configured, / +// is probed and may be absent (compiled-in defaults serve). +func loadAgentNetworkPricing(ctx context.Context, mgmtConfig *nbconfig.Config) error { + pricingPath := mgmtConfig.AgentNetwork.PricingDefaultsFile + required := pricingPath != "" + if !required { + pricingPath = agentnetworkpricing.DefaultFileName + } + if !filepath.IsAbs(pricingPath) { + pricingPath = filepath.Join(mgmtConfig.Datadir, pricingPath) + } + + log.Infof("loading agent-network pricing defaults from %s (required: %v)", pricingPath, required) + if err := agentnetworkpricing.LoadFile(pricingPath, required); err != nil { + return err + } + agentnetworkpricing.StartReloader(ctx, agentnetworkpricing.ReloadInterval) + return nil +} + // logConfig prints all configuration parameters for debugging func logConfig(cfg *CombinedConfig) { log.Info("=== Configuration ===") @@ -675,6 +731,25 @@ func logManagementConfig(cfg *CombinedConfig) { log.Infof(" Relay addresses: %v", cfg.Management.Relays.Addresses) log.Infof(" Relay credentials TTL: %s", cfg.Management.Relays.CredentialsTTL) } + + logAgentNetworkConfig(cfg) +} + +func logAgentNetworkConfig(cfg *CombinedConfig) { + log.Info(" Agent Network:") + pricingPath := cfg.Server.AgentNetwork.PricingDefaultsFile + configured := pricingPath != "" + if !configured { + pricingPath = agentnetworkpricing.DefaultFileName + } + if !filepath.IsAbs(pricingPath) { + pricingPath = filepath.Join(cfg.Management.DataDir, pricingPath) + } + if configured { + log.Infof(" Pricing defaults file: %s", pricingPath) + } else { + log.Infof(" Pricing defaults file: %s (default, optional)", pricingPath) + } } // logEnvVars logs all NB_ environment variables that are currently set diff --git a/combined/cmd/token.go b/combined/cmd/token.go deleted file mode 100644 index 550480062..000000000 --- a/combined/cmd/token.go +++ /dev/null @@ -1,63 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - "strings" - - log "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - - "github.com/netbirdio/netbird/formatter/hook" - tokencmd "github.com/netbirdio/netbird/management/cmd/token" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/types" - "github.com/netbirdio/netbird/util" -) - -// newTokenCommands creates the token command tree with combined-specific store opener. -func newTokenCommands() *cobra.Command { - return tokencmd.NewCommands(withTokenStore) -} - -// withTokenStore loads the combined YAML config, initializes the store, and calls fn. -func withTokenStore(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { - if err := util.InitLog("error", "console"); err != nil { - return fmt.Errorf("init log: %w", err) - } - - ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck - - cfg, err := LoadConfig(configPath) - if err != nil { - return fmt.Errorf("load config: %w", err) - } - - if dsn := cfg.Server.Store.DSN; dsn != "" { - switch strings.ToLower(cfg.Server.Store.Engine) { - case "postgres": - os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) - case "mysql": - os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn) - } - } - if file := cfg.Server.Store.File; file != "" { - os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file) - } - - datadir := cfg.Management.DataDir - engine := types.Engine(cfg.Management.Store.Engine) - - s, err := store.NewStore(ctx, engine, datadir, nil, true) - if err != nil { - return fmt.Errorf("create store: %w", err) - } - defer func() { - if err := s.Close(ctx); err != nil { - log.Debugf("close store: %v", err) - } - }() - - return fn(ctx, s) -} diff --git a/combined/config.yaml.example b/combined/config.yaml.example index 66bc71703..085e4344f 100644 --- a/combined/config.yaml.example +++ b/combined/config.yaml.example @@ -134,3 +134,16 @@ server: # trustedPeers: [] # CIDRs of trusted peer networks (e.g. ["100.64.0.0/10"]) # accessLogRetentionDays: 7 # Days to retain HTTP access logs. 0 (or unset) defaults to 7. Negative values disable cleanup (logs kept indefinitely). # accessLogCleanupIntervalHours: 24 # How often (in hours) to run the access-log cleanup job. 0 (or unset) is treated as "not set" and defaults to 24 hours; cleanup remains enabled. To disable cleanup, set accessLogRetentionDays to a negative value. + + # Agent network (LLM gateway) settings (optional) + # agentNetwork: + # # Path to the YAML file holding the default LLM pricing table. A relative + # # path is resolved against dataDir, so a bare filename like "pricing.yaml" + # # lands in the data directory. When empty, {dataDir}/defaults_llm_pricing.yaml + # # is probed; if no file is present the compiled-in defaults are used. + # # Schema: surface ("openai"/"anthropic"/"bedrock") -> model -> rates in USD + # # per 1k tokens (input_per_1k, output_per_1k, and the optional + # # cached_input_per_1k / cache_read_per_1k / cache_creation_per_1k). The file + # # is re-read periodically (mtime poll). An explicitly configured path that + # # fails to load fails startup; runtime reload errors keep the previous table. + # pricingDefaultsFile: "pricing.yaml" diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 000000000..efdbceb8d --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,11 @@ +skip_untranslated_strings: true +skip_untranslated_files: true +import_eq_suggestions: true + +files: + - source: /client/ui/i18n/locales/en/common.json + translation: /client/ui/i18n/locales/%two_letters_code%/common.json + type: chrome + languages_mapping: + two_letters_code: + zh-CN: zh-CN diff --git a/dns/nameserver.go b/dns/nameserver.go index 81c616c50..84e83e2b4 100644 --- a/dns/nameserver.go +++ b/dns/nameserver.go @@ -53,6 +53,7 @@ type NameServerGroup struct { ID string `gorm:"primaryKey"` // AccountID is a reference to Account that this object belongs AccountID string `gorm:"index"` + PublicID string `json:"-"` // Name group name Name string // Description group description diff --git a/docs/agent-networks/00-overview.md b/docs/agent-networks/00-overview.md new file mode 100644 index 000000000..0d76e44a0 --- /dev/null +++ b/docs/agent-networks/00-overview.md @@ -0,0 +1,109 @@ +# Agent Networks — overview + +Single-entry point. Feature scope, the module map, and the cross-cutting +topics worth keeping in mind, with links into every per-module guide. + +## TL;DR + +Agent Networks introduces an **LLM-aware reverse-proxy middleware system** +plus **account-level controls** (budget rules, log collection toggles, +PII redaction). The management server synthesises a per-peer middleware +chain that the proxy executes on every LLM request; the chain enforces +quotas, injects identity, redacts PII, parses tokens/cost, and emits +access-log entries. The dashboard exposes the surface as a single **AI +Observability** page with four tabs. + +- **Backend** lives in this repo, primarily under + `management/server/agentnetwork`, `proxy/internal/middleware`, and + `proxy/internal/llm`, with wire contracts in `shared/management`. +- **Dashboard** lives in the dashboard repo under + `src/modules/agent-network/` and `src/app/(dashboard)/agent-network/`. + +## Reading order + +| # | Doc | Why | +|---|-----|-----| +| 1 | [01-end-to-end-flows.md](01-end-to-end-flows.md) | Get the three big diagrams in your head first. | +| 2 | [modules/10-shared-api.md](modules/10-shared-api.md) | Wire contracts — every other module either produces or consumes these. | +| 3 | [modules/21-management-agentnetwork.md](modules/21-management-agentnetwork.md) | The largest module; everything the proxy executes originates here. | +| 4 | [modules/30-proxy-middleware-framework.md](modules/30-proxy-middleware-framework.md) | The generic plugin system on the proxy side. | +| 5 | [modules/31-proxy-middleware-builtin.md](modules/31-proxy-middleware-builtin.md) | The 8 LLM middlewares that ride on the framework. | +| 6 | Everything else in any order. | | + +## Module map + +11 modules. Each is described in detail in its own file under +[`modules/`](modules/). + +| # | Module | Risk | BC impact | +|---|--------|------|-----------| +| 10 | [shared/api](modules/10-shared-api.md) — proto + OpenAPI | Low | Additive only | +| 20 | [management/store](modules/20-management-store.md) — SQL persistence | Medium | Auto-migrate (additive) | +| 21 | [management/agentnetwork](modules/21-management-agentnetwork.md) — domain layer + synthesizer | **High** | Additive | +| 22 | [management/handlers + wiring](modules/22-management-handlers-wiring.md) — HTTP API + gRPC delivery | Medium | Additive | +| 30 | [proxy/middleware-framework](modules/30-proxy-middleware-framework.md) — generic plugin system | High | Additive | +| 31 | [proxy/middleware-builtin](modules/31-proxy-middleware-builtin.md) — 8 LLM middlewares | High | Additive | +| 32 | [proxy/llm-parsers](modules/32-proxy-llm-parsers.md) — SDK adapters + pricing | Medium | Additive | +| 33 | [proxy/runtime](modules/33-proxy-runtime.md) — translate + serve + access-log | High | Additive (touches hot path) | +| 40 | [dashboard](modules/40-dashboard.md) — UI for everything above | Medium | Sidebar reshape | +| 50 | [path-routed-providers](modules/50-path-routed-providers.md) — Vertex AI + Bedrock | Medium | Additive (new catalog entries) | + +The largest and highest-risk module is `management/agentnetwork`: it is +the single writer of the middleware chain the proxy executes. + +## Cross-cutting topics + +These are the items most likely to bite production. Each is fully +documented in the linked module guide. + +1. **Capture-pointer semantics** (`*bool` for `capture_prompt` and + `capture_completion`): nil = legacy emit, false = suppress, true = + emit. nil-vs-false must be handled at every JSON hop. See + [21-management-agentnetwork.md](modules/21-management-agentnetwork.md) + and [31-proxy-middleware-builtin.md](modules/31-proxy-middleware-builtin.md). +2. **`ProxyMapping.Private` preservation** on per-proxy live updates. + Failure mode: `auth` skips `ValidateTunnelPeer` → + `CapturedData.UserGroups` empty → `llm_router` denies. See + [33-proxy-runtime.md](modules/33-proxy-runtime.md). +3. **respInput carrying `UserEmail`/`UserGroups`/`UserGroupNames` onto + the response leg** in `reverseproxy.go`. Load-bearing wire that lets + `llm_limit_record` ship non-empty `group_ids` on `RecordLLMUsage`. See + [33-proxy-runtime.md](modules/33-proxy-runtime.md). +4. **Min-wins all-must-pass budget rule semantics**. Every matching + rule's remaining quota must be > 0 for the request to proceed; one + exhausted rule blocks the whole call. Documented in + [21-management-agentnetwork.md](modules/21-management-agentnetwork.md) + and the `llm_limit_check` middleware in + [31-proxy-middleware-builtin.md](modules/31-proxy-middleware-builtin.md). +5. **body-tap memory bounds**: per-direction 1 MiB cap, shared 256 MiB + budget, `LimitReader(r.Body, limit+1)` for truncation detection with + `replayReadCloser` fallback so upstream still sees the full body. + `cloneInputFor` deep-copies the body up to 16 times per chain — a + perf hot-spot. See + [30-proxy-middleware-framework.md](modules/30-proxy-middleware-framework.md). +6. **UpstreamRewrite.AuthHeader bypasses the header denylist** + deliberately. The runtime consumer only unpacks it via the + trusted upstream-build path. See + [30-proxy-middleware-framework.md](modules/30-proxy-middleware-framework.md). +7. **`disable_access_log` default-false semantics**: the synth target + sets it true, all other targets leave it false. See + [10-shared-api.md](modules/10-shared-api.md). +8. **String-typed `decision` / `deny_code`** on + `CheckLLMPolicyLimitsResponse` — would benefit from enum pinning + before external consumers integrate. See + [10-shared-api.md](modules/10-shared-api.md). + +## Explicit non-goals + +- **Reaper / GC pass over stale synth services** — designed but cut from + scope. +- **URL-sync for tab state on AI Observability** — read path is wired + (`?tab=`) but write path isn't. Future work. +- **CI golden-file regen-and-diff for `types.gen.go` / + `proxy_service.pb.go`** — would catch codegen drift; not yet in place. + +## Where to read the code + +Per-module file scopes are listed in each module guide. Behaviour is +covered by Go tests co-located with each package (and an end-to-end +chain integration test under `proxy/internal/proxy`). diff --git a/docs/agent-networks/01-end-to-end-flows.md b/docs/agent-networks/01-end-to-end-flows.md new file mode 100644 index 000000000..0de6b4c33 --- /dev/null +++ b/docs/agent-networks/01-end-to-end-flows.md @@ -0,0 +1,232 @@ +# End-to-end flows + +Three cross-module mermaid diagrams. Each per-module guide repeats the +slice that's relevant to its own scope — these are the canonical +top-down views. + +- [Flow A — Config → runtime (synth + deliver)](#flow-a--config--runtime-synth--deliver) +- [Flow B — Request lifecycle through the LLM chain](#flow-b--request-lifecycle-through-the-llm-chain) +- [Flow C — Budget rule feedback loop](#flow-c--budget-rule-feedback-loop) + +--- + +## Flow A — Config → runtime (synth + deliver) + +How an operator's change to a Provider, Policy, Guardrail, Budget Rule, +or Settings record ends up as live middleware on a peer's proxy. + +```mermaid +sequenceDiagram + autonumber + actor Op as Operator + participant UI as Dashboard + participant HTTP as management/handlers + participant Mgr as agentnetwork.Manager + participant Store as management/store (SQL) + participant Ctl as network_map.Controller + participant Synth as agentnetwork.SynthesizeServices + participant Grpc as management gRPC + participant Proxy as netbird-proxy + participant Xlate as middleware_translate + participant Chain as middleware.Chain + + Op->>UI: edit provider/policy/budget/settings + UI->>HTTP: REST PUT/POST /api/agent-network/* + HTTP->>Mgr: SaveProvider / SavePolicy / SaveBudgetRule / SaveSettings + Mgr->>Store: persist (gorm) + Mgr-->>Ctl: account change event (Network-Map dirty) + loop per connected peer + Ctl->>Synth: SynthesizeServices(ctx, store, accountID) + Synth->>Store: load providers, policies, guardrails, budget rules, settings + Synth-->>Synth: build per-peer Service list + Note over Synth: each Service has a middleware
    chain with capture_prompt /
    capture_completion / redact_pii
    baked from account settings + Synth-->>Ctl: []rpservice.Service + Ctl->>Grpc: NetworkMap push (services + middleware configs) + end + Grpc-->>Proxy: NetworkMap stream + Proxy->>Xlate: translate proto MiddlewareConfig → runtime Spec + Xlate->>Chain: register / replace per-service chain + Note over Chain: chain replacement is live
    (no proxy restart, in-flight
    requests unaffected) +``` + +**Notes on the diagram** + +- The `network_map.Controller` synthesises on every push, not on a + timer. A single config change costs O(connected peers × policies × + providers) per push. See [`modules/22-management-handlers-wiring.md`](modules/22-management-handlers-wiring.md). +- `SynthesizeServices` is the single source of truth for the wire + format the proxy executes. Anything the proxy does that the + synthesiser didn't request is a bug. See + [`modules/21-management-agentnetwork.md`](modules/21-management-agentnetwork.md). +- The translate step (step 13) is the only place that knows the + middleware-ID strings on the proxy side. It must reject unknown IDs; + silently dropping middlewares would create a security gap (e.g. + missing `llm_limit_check` ⇒ unbounded spend). See + [`modules/33-proxy-runtime.md`](modules/33-proxy-runtime.md). + +--- + +## Flow B — Request lifecycle through the LLM chain + +What happens when an agent on the client peer sends a chat-completion / +messages request through the synthesised reverse-proxy. + +```mermaid +sequenceDiagram + autonumber + actor Agent as Agent (local) + participant Px as netbird-proxy + participant Auth as auth middleware + participant Map as service-mapping + participant Req as llm_request_parser + participant Rt as llm_router + participant Chk as llm_limit_check + participant Inj as llm_identity_inject + participant Grd as llm_guardrail + participant Up as upstream LLM + participant Resp as llm_response_parser + participant Cost as cost_meter + participant Rec as llm_limit_record + participant Log as access-log + participant MgmtGrpc as management gRPC + + Agent->>Px: POST /v1/chat/completions (OpenAI / Anthropic) + Px->>Auth: identify peer (user, groups) + Auth->>Map: resolve service from Host + path + Map-->>Req: dispatch chain in slot order + + Req->>Req: parse body → provider, model, prompt, token estimate + Note over Req: capture_prompt gates raw_prompt
    capture (nil = legacy emit,
    false = drop, true = emit) + Req->>Rt: pass metadata + Rt->>Chk: route to upstream candidate + + Chk->>MgmtGrpc: CheckLLMPolicyLimits(provider, model, est_tokens, groups, user) + MgmtGrpc-->>Chk: decision = allow / deny + deny_code + alt decision == deny + Chk-->>Log: emit access-log with deny_code
    (if EnableLogCollection) + Chk-->>Agent: 429 (or 403 per deny_code) + else decision == allow + Chk->>Inj: continue + Inj->>Inj: inject NetBird identity headers per provider config + Inj->>Grd: continue + Grd->>Grd: enforce per-provider allowlist (fail-closed backstop) + Grd->>Up: forward (over WireGuard) + Up-->>Resp: response (JSON or SSE stream) + Resp->>Resp: parse usage tokens, completion + Note over Resp: capture_completion gates raw
    completion capture + Resp->>Cost: tokens + Cost->>Cost: lookup rates from config-delivered
    pricing table + compute cost + Cost->>Rec: tokens + cost + Rec->>MgmtGrpc: RecordLLMUsage(provider, model, prompt_t, completion_t, cost, groups, user) + Rec-->>Log: emit access-log entry
    (if EnableLogCollection) + Log-->>Agent: 200 + body (streamed if SSE) + end +``` + +**Notes on the diagram** + +- The chain runs in synth-defined order. Re-ordering middlewares + changes invariants — `llm_limit_check` must precede `llm_router` so + a denied request never hits upstream, and `llm_limit_record` must + pair with `llm_limit_check` so a successful check is always recorded + (or the rate-limit semantics break). See + [`modules/31-proxy-middleware-builtin.md`](modules/31-proxy-middleware-builtin.md). +- `llm_guardrail` is also where PII redaction happens + (`redact_pii = settings.RedactPii`). Phones, emails, credit cards, + PII names — see `redact.go` for the full set. See + [`modules/31-proxy-middleware-builtin.md`](modules/31-proxy-middleware-builtin.md). +- The model allowlist is enforced in TWO places. `CheckLLMPolicyLimits` + is authoritative: it resolves the policy that governs this + (provider, caller-groups) and denies (`deny_code = llm_policy.model_blocked`) + when no applicable policy permits the model — so an allowlist scoped to + one group/provider never leaks to another, and an un-guardrailed policy + is genuinely unrestricted. `llm_guardrail` is a per-provider fail-closed + backstop: it only carries an allowlist for a provider every authorising + policy restricts, and blocks unknown/undetermined models even when + management is unreachable. Because that backstop allowlist is the UNION + of every restricting policy's models, per-group narrowing lives only in + the authoritative check: during a `CheckLLMPolicyLimits` outage + `llm_limit_check` fails open, so a caller can reach any model in the + provider's union — a group scoped to model A could reach model B if + another group restricts the same provider to B. This is the documented + fail-open trade-off; a future flag may switch it to fail-closed. +- SSE streaming requires special handling on the response side; the + parser must handle partial chunks without buffering the whole + stream. See [`modules/32-proxy-llm-parsers.md`](modules/32-proxy-llm-parsers.md). +- Access-log emission is gated on `settings.EnableLogCollection`. With + it OFF, neither the deny nor the allow leg writes an entry — the + chain still runs (budget rules are still enforced) but no audit trail + is kept. See + [`modules/33-proxy-runtime.md`](modules/33-proxy-runtime.md). + +--- + +## Flow C — Budget rule feedback loop + +How an account's budget rules tighten ceilings on every request and how +consumption flows back into the dashboard. + +```mermaid +flowchart LR + subgraph Operator + DashBud[Dashboard Budget Settings tab] + end + subgraph Mgmt[Management] + Save[POST/PUT /api/agent-network/budget-rules] + Store[(SQL store)] + Synth[SynthesizeServices] + Check[CheckLLMPolicyLimits RPC] + Rec[RecordLLMUsage RPC] + Cons[/api/agent-network/consumption] + end + subgraph Proxy[Proxy] + Chk[llm_limit_check] + RecMw[llm_limit_record] + end + subgraph DashView[Dashboard Budget Dashboard tab] + Panel[AgentConsumptionPanel] + end + + DashBud -->|create / update rules| Save + Save --> Store + Store --> Synth + Synth -->|push synth-services to peer| Proxy + + Chk -->|per request| Check + Check -->|aggregate matching rules
    min-wins all-must-pass| Store + Check -->|allow / deny| Chk + + RecMw -->|post-response| Rec + Rec -->|tokens + cost + groups + user| Store + + Store -->|read counters| Cons + Cons --> Panel +``` + +**Notes on the diagram** + +- **min-wins all-must-pass** is the core semantic. A budget rule binds + to (group set, user set) with a (window, ceiling). At check time, + every rule that matches the caller is evaluated; if ANY rule has + zero remaining quota the request is denied. This is the most + surprising semantic for operators — see the invariants section of + [`modules/21-management-agentnetwork.md`](modules/21-management-agentnetwork.md). +- The proxy never makes its own budget decisions. It always asks + management via `CheckLLMPolicyLimits` and reports back via + `RecordLLMUsage`. This keeps account-wide accounting in one place + and avoids per-proxy drift. +- `RecordLLMUsage` must carry `group_ids` and `user_id` so the + decrement hits the right rule(s). The wire that carries those + fields onto the response leg is `respInput` in `reverseproxy.go`. See + [`modules/33-proxy-runtime.md`](modules/33-proxy-runtime.md). +- The dashboard's Budget Dashboard tab polls + `/api/agent-network/consumption` — not gRPC, not WebSocket. Poll + interval lives in `AgentConsumptionPanel.tsx`. See + [`modules/40-dashboard.md`](modules/40-dashboard.md). + +--- + +## Cross-references + +- Per-module guides: [`modules/`](modules/) +- Overview + module map: [`00-overview.md`](00-overview.md) diff --git a/docs/agent-networks/README.md b/docs/agent-networks/README.md new file mode 100644 index 000000000..a7d2d2ab5 --- /dev/null +++ b/docs/agent-networks/README.md @@ -0,0 +1,66 @@ +# Agent Networks — architecture documentation + +A self-contained set of documents describing the agent-networks feature: +an LLM-aware reverse-proxy middleware system plus account-level controls +(budget rules, log collection toggles, PII redaction). The management +server synthesises a per-peer middleware chain that the proxy executes on +every LLM request. + +## What to read first + +1. **[00-overview.md](00-overview.md)** — the single entry point. Feature + scope, the module map, and the cross-cutting topics worth keeping in + mind, with links to every per-module guide. +2. **[01-end-to-end-flows.md](01-end-to-end-flows.md)** — three + high-level mermaid diagrams: config-to-runtime synth/delivery, + per-request lifecycle through the LLM chain, and the budget-rule + feedback loop. +3. **Per-module guides** under `modules/` — one file per package. Each + describes the module boundary, the file-level layout, its own flow + diagrams, the public contracts, the invariants it relies on, and the + areas worth the closest attention. + +## Directory layout + +``` +docs/agent-networks/ +├── README.md # you are here +├── 00-overview.md # feature summary + module map +├── 01-end-to-end-flows.md # cross-module mermaid diagrams +└── modules/ + ├── 10-shared-api.md # proto + OpenAPI wire contracts + ├── 20-management-store.md # SQL persistence layer + ├── 21-management-agentnetwork.md # domain layer + synthesizer (largest) + ├── 22-management-handlers-wiring.md # HTTP API + gRPC delivery + ├── 30-proxy-middleware-framework.md # generic plugin system + ├── 31-proxy-middleware-builtin.md # 8 LLM-aware middlewares + ├── 32-proxy-llm-parsers.md # OpenAI/Anthropic/Bedrock SDKs + pricing + ├── 33-proxy-runtime.md # translate + serve + access-log + ├── 40-dashboard.md # UI for everything above (lives in the dashboard repo) + └── 50-path-routed-providers.md # Vertex AI + Bedrock (path-routed, keyfile:: creds, /bedrock prefix) +``` + +The `40-dashboard.md` module documents code that lives in the **dashboard +repo**, not in this repo. The guide is co-located here so backend readers +see the full picture in one place. + +## How the per-module guides are structured + +Every `modules/*.md` follows the same template so the docs are easy to +scan: + +- **Module boundary** — what this package owns; where it sits in the stack. +- **Files** — path / role. +- **Architecture & flow** — one or more mermaid diagrams. +- **Public contracts** — function signatures, gRPC messages, JSON shapes. +- **Invariants** — semantic guarantees the module relies on or enforces. +- **Things to scrutinize** — split by correctness / security / + concurrency / backward-compat / performance / observability. +- **Test coverage** — the test files that lock down behaviour in this + module. +- **Known limitations / non-goals** — what is intentionally out of scope. +- **Cross-references** — upstream/downstream module links + the + end-to-end flow + the overview. + +See [00-overview.md](00-overview.md) for the module map and the +cross-cutting topics. diff --git a/docs/agent-networks/modules/10-shared-api.md b/docs/agent-networks/modules/10-shared-api.md new file mode 100644 index 000000000..532927b90 --- /dev/null +++ b/docs/agent-networks/modules/10-shared-api.md @@ -0,0 +1,105 @@ +# shared/api — wire contracts (proto + OpenAPI) + +> **Risk level:** Medium — wire-format surface that every other module pins against; backward-compat hinges on field-number discipline more than on logic correctness. +> **Backward-compat impact:** Additive only (new proto fields use unallocated numbers, new RPCs default to `Unimplemented`, new OpenAPI schemas/paths are append-only; no existing field/RPC/schema removed or renumbered). + +## Module boundary +This module owns the cross-process contract surface between management, proxy, and dashboard. Two artefacts: `shared/management/proto/proxy_service.proto` (management↔proxy gRPC) and `shared/management/http/api/openapi.yml` (dashboard/CLI↔management REST). Both have generated companions checked in (`proxy_service.pb.go`, `proxy_service_grpc.pb.go`, `types.gen.go`) which must travel in lockstep with their sources. `shared/management/status/error.go` is in scope only for the four new typed `NotFound` constructors that the new HTTP handlers return. + +Everything downstream — `management/agentnetwork`, `management/server/http/handlers/*`, `proxy/internal/*`, the dashboard SDK — consumes these types verbatim. The concern here is wire stability and codegen reproducibility, not behaviour: behaviour is covered in the management and proxy module guides. + +`management.proto` and `signalexchange.proto` are unchanged. `status/error.go` only receives four additive constructors (lines 208-227); no existing error types are reshaped. + +## Files +| Path | Role | +| ---- | ---- | +| `shared/management/proto/proxy_service.proto` | Source of truth: 2 new RPCs, 1 new message group (`MiddlewareConfig` + slot enum), additive fields on `PathTargetOptions`, `AccessLog`, `RecordLLMUsageRequest` | +| `shared/management/proto/proxy_service.pb.go` | Generated (protoc-gen-go) | +| `shared/management/proto/proxy_service_grpc.pb.go` | Generated; adds `CheckLLMPolicyLimits` + `RecordLLMUsage` client/server stubs and `UnimplementedProxyServiceServer` defaults | +| `shared/management/http/api/openapi.yml` | 15 new `AgentNetwork*` schemas, 9 new path groups under `/api/agent-network/*` | +| `shared/management/http/api/types.gen.go` | Generated (oapi-codegen; see codegen note below) | +| `shared/management/status/error.go` | Four `NotFound` constructors for the new resource kinds (lines 208-227) | + +## Architecture & flow +```mermaid +sequenceDiagram + participant Dash as Dashboard / CLI + participant Mgmt as management (HTTP+gRPC) + participant Px as proxy + + Note over Dash,Mgmt: REST (OpenAPI / types.gen.go) + Dash->>Mgmt: PUT /api/agent-network/providers (AgentNetworkProviderRequest) + Dash->>Mgmt: PUT /api/agent-network/settings (AgentNetworkSettingsRequest) + Dash->>Mgmt: GET /api/agent-network/consumption -> [AgentNetworkConsumption] + + Note over Mgmt,Px: gRPC ProxyService (proxy_service.proto) + Mgmt-->>Px: SyncMappingsResponse{ ProxyMapping.path[*].options.middlewares,
    agent_network, disable_access_log, capture_* } + Px->>Mgmt: CheckLLMPolicyLimits(account, user, groups, provider, model) + Mgmt-->>Px: decision=allow|deny + selected_policy_id + attribution_group_id + window_seconds + Px->>Mgmt: RecordLLMUsage(account, user, group_id, group_ids, window_seconds, tokens, cost) + Px->>Mgmt: SendAccessLog(AccessLog{ agent_network=true }) +``` + +The proto changes split into three independent slices: (1) **mapping enrichment** — `PathTargetOptions` grows fields 8-13 so management can ship middleware configs, capture limits, and the agent-network / log-suppression flags down to the proxy without a second RPC; (2) **two new request/response RPCs** (`CheckLLMPolicyLimits`, `RecordLLMUsage`) for per-LLM-request budget arbitration; (3) **observability tag** — `AccessLog.agent_network` so management can route logs to the right surface. + +The OpenAPI side is a thin CRUD surface — every resource (`Provider`, `Policy`, `Guardrail`, `BudgetRule`, `Settings`) follows the same `GET-list / POST / GET / PUT / DELETE` pattern, plus a read-only `/consumption` listing and a catalog endpoint. The `*Request` variants drop server-controlled fields (id, timestamps). `AgentNetworkBudgetRule` deliberately reuses `AgentNetworkPolicyLimits` to keep wire-shape parity with policies. + +## Public contracts added +- gRPC RPCs (`proxy_service.proto:52-57`): `CheckLLMPolicyLimits(CheckLLMPolicyLimitsRequest) → CheckLLMPolicyLimitsResponse`, `RecordLLMUsage(RecordLLMUsageRequest) → RecordLLMUsageResponse`. Both unary; default `UnimplementedProxyServiceServer` returns `codes.Unimplemented` (`proxy_service_grpc.pb.go:283-289`). +- New messages (`proxy_service.proto:145-175,448-502`): `MiddlewareConfig`, `MiddlewareSlot` enum, `CheckLLMPolicyLimitsRequest`/`Response`, `RecordLLMUsageRequest`/`Response`. +- New `PathTargetOptions` fields 8-13 (`proxy_service.proto:124-140`): `capture_max_request_bytes`, `capture_max_response_bytes`, `capture_content_types`, `middlewares`, `agent_network`, `disable_access_log`. All default-false / zero; pre-existing fields 1-7 byte-for-byte unchanged. +- `AccessLog.agent_network = 18` (`proxy_service.proto:258-261`). +- `RecordLLMUsageRequest.group_ids = 8` (`proxy_service.proto:496-498`) — so the record path can fan out to every applicable budget rule's window without a re-lookup. +- 15 new OpenAPI component schemas (`openapi.yml:5072-5829`): `AgentNetworkProvider[Request|Model]`, `AgentNetworkCatalog{Model,Provider,IdentityInjection,HeaderPairInjection,JSONMetadataInjection,ExtraHeader}`, `AgentNetworkPolicy[Request|TokenLimit|BudgetLimit|Limits]`, `AgentNetworkGuardrail[Checks|Request]`, `AgentNetworkConsumption`, `AgentNetworkSettings[Request]`, `AgentNetworkBudgetRule[Request]`. +- 9 new path groups (`openapi.yml:12797-13460`): `/api/agent-network/{consumption,settings,budget-rules,budget-rules/{ruleId},catalog/providers,providers,providers/{providerId},policies,policies/{policyId},guardrails,guardrails/{guardrailId}}`. +- Four typed NotFound errors (`shared/management/status/error.go:208-227`). + +## Invariants +- **Field-number monotonicity.** Every new proto field uses a previously-unallocated number in its message: `PathTargetOptions` 8-13 (was 1-7), `AccessLog` 18 (was 1-17), `RecordLLMUsageRequest` 8. `SendStatusUpdateRequest.inbound_listener = 50` (pre-existing) reserves 50+ for observability extensions, so 8 on `RecordLLMUsageRequest` doesn't conflict. +- **Old proxies stay compatible.** Old management never sends `disable_access_log`/`middlewares`/`agent_network` (zero value → existing behaviour); old proxies that don't decode these fields just drop them silently (proto3 unknown-field semantics) — log emission stays on. No pre-existing field number changed: the proto change is insertions only. +- **Old management stays compatible.** The two new RPCs are registered on the same `management.ProxyService` descriptor; old proxies hitting them get `codes.Unimplemented` from the unimplemented embed (`proxy_service_grpc.pb.go:283-289`), which is the same fallback pattern `SyncMappings` already documents (`proxy_service.proto:20-21`). +- **OpenAPI shapes are append-only.** New schemas are placed at the end of `components.schemas` (line 5072+); new paths at the end of `paths` (line 12797+). No existing schema's `required` list, enum, or property type was changed. +- **`*Request` vs response asymmetry.** Read shapes (`AgentNetworkProvider`, `AgentNetworkPolicy`, `AgentNetworkGuardrail`, `AgentNetworkSettings`, `AgentNetworkBudgetRule`) require `created_at`/`updated_at`; the matching `*Request` shapes do not — server fills them. `AgentNetworkProviderRequest.api_key` is write-only (`openapi.yml:5158-5161` "never returned in responses"); reviewers should confirm the response schema (5072-5138) actually omits `api_key`. + +## Things to scrutinize +### Correctness +- `RecordLLMUsageRequest` carries both `group_id` (singular, the attribution group — field 3) and `group_ids` (plural, full membership — field 8). `b22d5a181` adds field 8 to drive account-budget fan-out; double-check that consumers can't accidentally key counters on the wrong one. Field comments at `proxy_service.proto:489-491` and `496-498` distinguish them but it's the kind of subtle thing a follow-up commit might collapse. +- `PathTargetOptions.disable_access_log` is the only field whose default-false meaning **changes semantics** on the proxy side: false → log (status quo), true → suppress. Synthesizer sets `DisableAccessLog = !settings.EnableLogCollection`, so a missing/default settings row yields `EnableLogCollection=false → DisableAccessLog=true → suppressed`. Worth confirming downstream (`agentnetwork.synthesizer`) that operator-defined private services never inherit this flag — the proto field default protects them, but only if synth code is explicit. +- `CheckLLMPolicyLimitsResponse.decision` is a free-form `string` (`proxy_service.proto:471`) rather than an enum. Only documented values are "allow" / "deny". An enum would prevent typo drift; consider before this RPC ships to external consumers. +- `deny_code` (`proxy_service.proto:478-481`) is documented as "a stable label" but is also a free string. Pin the allowed set somewhere observable to the proxy. + +### Security +- `AgentNetworkProvider.api_key` MUST be write-only. Schema split (request has it at line 5158; response omits it) looks correct, but a regression here leaks the upstream provider credential to every dashboard reader. Check that the handler explicitly zeros it on the response path. +- `extra_values` / `identity_header_*` headers on `AgentNetworkProvider` get stamped onto upstream requests. Description at `openapi.yml:5099` says "values not declared by the catalog are ignored at synth time" — a contract this module documents but the synthesizer must enforce. Confirm the synth module honours it. +- Cluster + subdomain on `AgentNetworkSettings` are documented immutable (`openapi.yml:5686-5694`) and the `AgentNetworkSettingsRequest` (lines 5733-5752) doesn't accept them. Verify the `PUT /api/agent-network/settings` handler can't be tricked by extra JSON keys (oapi-codegen's `additionalProperties: false` is not declared here; spec defaults to permissive). + +### Backward compatibility +- The proto change is field-number additive: every previously numbered field keeps the same name + type, and the change is insertions only (no deletions in `proxy_service.proto`), so this holds at the source-text level. +- `proxy_service_grpc.pb.go` adds two RPC handlers and registers them in `ProxyService_ServiceDesc.Methods` (lines 543-552). The existing entries are unchanged and order-preserving — gRPC method dispatch is name-keyed, so order doesn't matter, but reviewing the diff (no method renamed/dropped) is still worth a glance. +- OpenAPI 3.0 doesn't have a built-in deprecation flow for paths; if any client tooling iterates `paths.*`, the additive routes shouldn't break it, but generated SDKs (especially the dashboard's) need a regen to gain access to `AgentNetwork*`. + +### Codegen pinning +- `generate.sh` (`shared/management/http/api/generate.sh:14`) installs `oapi-codegen@latest` rather than a pinned version. **This is a reproducibility gap** — re-running the script later may produce a different `types.gen.go`. Either pin the version in `generate.sh` (e.g. `@v2.7.0`) or document the pin in a `tools.go`. +- proto codegen has the protoc / protoc-gen-go version stamped in the generated file header (`proxy_service.pb.go:3-4`). +- Regenerate locally and confirm zero diff against the committed `types.gen.go` / `proxy_service.pb.go`. + +## Test coverage +| Test file | Locks down | +| --------- | ---------- | +| None in this scope | The proto and OpenAPI sources are tested transitively by the handler tests (`shared/management/http/handlers/agentnetwork/...`) and by the synthesizer/manager tests (`management/server/agentnetwork/...`). No round-trip serialisation test exists in the `proto/` or `api/` packages themselves. | +| `shared/management/proto/*_test.go` | (absent) | +| `shared/management/http/api/*_test.go` | (absent) | + +Acceptable for codegen artefacts, but a single golden-file test that re-runs `oapi-codegen` and `protoc` in CI and diffs against the checked-in files would close the reproducibility gap noted above. + +## Known limitations / explicit non-goals +- **No deprecation surface.** Old fields/RPCs are kept silently; there is no `[deprecated = true]` annotation on anything. Acceptable here because nothing is being removed. +- **No proto-side validation.** Numeric ranges (e.g. `window_seconds >= 60`, `cost_usd >= 0`, capture-byte clamps) are enforced in the OpenAPI schema via `minimum:` and inside Go code by the proxy/management, but `proto3` itself can't express them; downstream is expected to validate every message. +- **`MiddlewareConfig.config_json` is `bytes`** (`proxy_service.proto:163`) — opaque to the proto layer. Schema validity is the middleware factory's problem. This is a deliberate tradeoff (per the comment at 161-162) but worth flagging: a corrupted/malicious config_json can only fail at proxy apply time, not at the wire-decode step. +- **No catalog endpoint schema for the catalog itself** — the catalog data ships as a `GET /api/agent-network/catalog/providers` returning `[AgentNetworkCatalogProvider]` (`openapi.yml:13024`), but the catalog source-of-truth lives in `management/server/agentnetwork/catalog`, not here. +- The reaper / GC design was cut from scope; no reaper-related types appear here. + +## Cross-references +- Downstream: [management/store](20-management-store.md), [management/agentnetwork](21-management-agentnetwork.md), [management/handlers + wiring](22-management-handlers-wiring.md), [proxy/runtime](33-proxy-runtime.md) +- End-to-end flow: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/20-management-store.md b/docs/agent-networks/modules/20-management-store.md new file mode 100644 index 000000000..1acc12611 --- /dev/null +++ b/docs/agent-networks/modules/20-management-store.md @@ -0,0 +1,112 @@ +# management/store — persistence for agent-network entities + +> **Risk level:** Medium — six brand-new tables behind AutoMigrate, one upsert-counter table that runs on the request hot path, and one column carrying an encrypted secret. +> **Backward-compat impact:** Additive (six new tables created by AutoMigrate; the `Store` interface gains 23 methods, but no existing column/index is touched). + +## Module boundary + +This module is the persistence layer for the Agent Network feature. Everything the management server stores about LLM proxying — providers, policies, guardrails, the per-account settings row, a usage-counter table written on every proxied LLM request, and the account-budget rules — flows through the methods added to `store.Store`. The module owns six tables, six entity types from `management/server/agentnetwork/types`, and a single hot-path upsert (`IncrementAgentNetworkConsumption`) consumed by the proxy fleet. + +Out of scope here: the catalog of provider definitions (compiled-in, no DB), the synthesizer/manager built on top of these CRUDs (covered in [21-management-agentnetwork.md](21-management-agentnetwork.md)), and the HTTP handlers that translate API requests into Save/Delete calls. + +## Files + +| Path | Role | +| ---- | ---- | +| `management/server/store/sql_store_agentnetwork.go` | gorm implementations of all 23 store methods | +| `management/server/store/sql_store_agentnetwork_budgetrule_test.go` | round-trip + account-scoping coverage against a real sqlite store | +| `management/server/store/sql_store.go` | one import, six entities appended to the `AutoMigrate` slice (sql_store.go:40, sql_store.go:141-142) | +| `management/server/store/store.go` | 23 methods added to the `Store` interface (store.go:328-354) | +| `management/server/store/store_mock_agentnetwork.go` | mockgen output for the new interface surface | + +## Tables added / migrations + +All six tables are created by `db.AutoMigrate` invoked from `NewSqlStore` at sql_store.go:133-143. There is no hand-rolled SQL migration script — the schema is whatever GORM derives from the struct tags. + +- `agent_network_providers` — `Provider.TableName()` at provider.go:76. PK `id`, index on `account_id`, named index `idx_agent_network_provider` on `provider_id`. Carries an at-rest-encrypted `api_key` and ed25519 `session_private_key` (provider.go:35,56). `extra_values` and `models` are JSON blobs (`serializer:json`). +- `agent_network_policies` — `Policy.TableName()` at policy.go:70. PK `id`, index on `account_id`. JSON columns: `source_groups`, `destination_provider_ids`, `guardrail_ids`, `limits`. +- `agent_network_guardrails` — `Guardrail.TableName()` at guardrail.go:41. PK `id`, index on `account_id`. JSON `checks`. +- `agent_network_settings` — `Settings.TableName()` at settings.go:33. PK `account_id` (one row per account), named index `idx_agent_network_settings_cluster_subdomain` on `subdomain` only — the index name implies a composite, but only one column is tagged. +- `agent_network_consumption` — `Consumption.TableName()` at consumption.go:46. Composite PK across `(account_id, dim_kind, dim_id, window_seconds, window_start_utc)` — the same tuple the upsert keys on. +- `agent_network_budget_rules` — `AccountBudgetRule.TableName()` at budgetrule.go:35. PK `id`, index on `account_id`. JSON `target_groups`, `target_users`, `limits`. + +## CRUD surface added + +Provider, Policy, Guardrail, BudgetRule follow the same pattern: `GetByID`, `GetAccount` (list), `Save` (upsert), `Delete`, with account-scoping enforced by the existing `accountAndIDQueryCondition` / `accountIDCondition` constants (sql_store.go:59-62). Provider additionally exposes `GetAllAgentNetworkProviders` (cross-account, used by the synthesizer). Settings exposes `Get`/`GetByCluster`/`Save` (no delete — one row per account, created on first save). Consumption exposes the upsert `Increment`, a point `Get`, and a cross-window `List`. + +## Architecture & flow + +```mermaid +flowchart LR + handlers["HTTP handlers
    (management/server/agentnetwork)"] -->|Save/Delete| iface["Store interface
    store.go:328-354"] + manager["agentnetwork.Manager"] -->|Get*| iface + synth["synthesizer
    (global)"] -->|GetAllAgentNetworkProviders| iface + proxy["proxy fleet
    (hot path)"] -->|IncrementAgentNetworkConsumption| iface + iface --> sql["SqlStore methods
    sql_store_agentnetwork.go"] + iface -.gomock.-> mock["MockStore
    store_mock_agentnetwork.go"] + sql --> gorm["gorm.DB"] + gorm --> tables[("6 tables
    agent_network_*")] + sql --> enc["crypt.FieldEncrypt
    (provider only)"] +``` + +Reads decrypt provider secrets in-place; writes do `provider.Copy().EncryptSensitiveData(...)` before `db.Save` so the caller's in-memory object keeps the plaintext `api_key` (sql_store_agentnetwork.go:88-102). Every list/get takes a `LockingStrength` and applies `clause.Locking{Strength: ...}` when non-`None` — matching the rest of the store. The upsert path uses `clause.OnConflict` with `gorm.Expr` server-side increments so concurrent proxy nodes converge without read-modify-write races (sql_store_agentnetwork.go:321-335). + +## Invariants enforced at the store layer + +- **Account scoping.** Every entity-by-ID method keys on `account_id = ? and id = ?`; no cross-tenant leak path through the API is reachable as long as callers always pass the auth'd `accountID` (sql_store_agentnetwork.go:70,141,201,429). +- **NotFound mapping.** `gorm.ErrRecordNotFound` is translated to typed `status.NewAgentNetwork*NotFoundError`; `Delete*` returns NotFound when `RowsAffected == 0` (sql_store_agentnetwork.go:111-113,171-173,231-233,461-463). +- **Provider secret encryption at rest.** `SaveAgentNetworkProvider` always encrypts before persist; `Get*` always decrypts after read. The plaintext `api_key` never reaches the DB through this layer (sql_store_agentnetwork.go:31,54,80,90). +- **Consumption monotonicity.** The upsert only ever issues `col = col + ?` for the three counter columns — no decrement path exists (sql_store_agentnetwork.go:330-332). +- **Window alignment is the caller's responsibility.** The store stamps `WindowStartUTC` as-passed; alignment to epoch happens in `types.WindowStart` at consumption.go:51-58. +- **Settings has no Delete.** Intentional — one row per account, created on first save; the row sticks around for the account lifetime. + +## Things to scrutinize + +### Correctness +- `SaveAgentNetworkProvider` saves the copy (sql_store_agentnetwork.go:95). The caller's in-memory pointer therefore keeps plaintext `api_key` and any `CreatedAt`/`UpdatedAt` gorm autofills land on the copy, not the original. Callers that need synced timestamps must re-fetch. +- `IncrementAgentNetworkConsumption`'s `Create` provides initial counter values (`TokensInput: tokensIn`, etc.) in the row, and on conflict the assignments add the same deltas to the existing values. The insert-vs-update arithmetic is consistent. Cross-check that no engine in use (sqlite, postgres, mysql) silently rejects the `OnConflict` clause — GORM emits engine-specific SQL but `ON DUPLICATE KEY UPDATE` (mysql) vs `ON CONFLICT (...)` (sqlite/postgres) need their unique constraint to match the composite PK on `agent_network_consumption`; it does, by construction. +- `IncrementAgentNetworkConsumption` writes `updated_at: time.Now().UTC()` literally inside the assignments map (sql_store_agentnetwork.go:333) — fine, but it's a Go-side timestamp captured at call time, not a DB-side `now()`. Acceptable for an audit field. +- `GetAgentNetworkConsumption` returns a zero-valued non-nil row on `ErrRecordNotFound` (sql_store_agentnetwork.go:364-371). Document or rename — a typed sentinel error would be more orthodox; callers must know not to error-check. + +### Concurrency / transactions +- Hot-path `IncrementAgentNetworkConsumption` runs outside any explicit transaction; concurrency safety relies entirely on the DB serialising the `ON CONFLICT` upsert against the composite PK. This is correct for postgres and mysql; for sqlite it serialises behind the single writer. +- `SaveAgentNetworkSettings` is a blind upsert with no version/etag — concurrent writes from two operators last-write-wins on the collection-toggle flags (settings.go:23-25). Acceptable for admin-curated state but worth flagging. +- `Save*Provider` uses `db.Save` on a struct with a PK already set — GORM emits UPDATE or INSERT based on row existence. No upsert clause is attached, so a race between two creates with the same generated `xid` (vanishingly unlikely) would surface as a PK violation. + +### Migration safety +- All six tables ride `AutoMigrate` (sql_store.go:141-142). AutoMigrate is additive: new columns get added, but it never drops columns nor narrows types. Three `bool` columns on `agent_network_settings` (`EnableLogCollection`, `EnablePromptCollection`, `RedactPii`) default to false at the GORM/DDL layer for existing rows; the test at sql_store_agentnetwork_budgetrule_test.go:83-112 locks that down on a fresh sqlite. Verify postgres/mysql produce the same default. +- The named index `idx_agent_network_settings_cluster_subdomain` on settings.go:15 is declared on only `subdomain`. Either the cluster column also needs `gorm:"index:idx_agent_network_settings_cluster_subdomain"` to make it composite, or the name is misleading. +- The named index `idx_agent_network_provider` on `Provider.ProviderID` (provider.go:30) is *not* unique and not scoped to account — two providers in the same account with the same `provider_id` are permitted at the DB layer; uniqueness, if any, must live above the store. + +### Backward compatibility +- Net additive. No removed methods, no renamed columns, no schema change to existing tables. Existing deployments running a prior binary continue to work; the first boot of the new binary creates the six tables. +- The `Store` interface grows by 23 methods (store.go:330-354); any non-mock external implementer of `store.Store` will fail to compile. The repo only has `SqlStore` + `MockStore`, both updated. + +### Performance (indexes, N+1) +- All by-account list queries hit the `idx_account_id` per-table index. No N+1: list methods return the full slice in one query. +- `GetAgentNetworkSettingsByCluster` (sql_store_agentnetwork.go:263-277) does a tablescan on `cluster` — no index. Tolerable for the bootstrap label generator (one-shot at provisioning) but worth noting if the call moves onto a hot path. +- `ListAgentNetworkConsumption` returns every row ever recorded for the account (sql_store_agentnetwork.go:382-400) — unbounded growth, no `LIMIT`, no time filter. With one row per (dim, window) per request burst, this table grows fastest of the six; a retention job + a paginated list method are obvious follow-ups. + +## Test coverage + +| Test file | Locks down | +| --------- | ---------- | +| `sql_store_agentnetwork_budgetrule_test.go::TestAgentNetworkBudgetRule_RealStore_RoundTrip` | full save → reload of `AccountBudgetRule` including the JSON-serialised `PolicyLimits`, target slices, double-delete returns NotFound (lines 18-59) | +| `sql_store_agentnetwork_budgetrule_test.go::TestAgentNetworkBudgetRule_RealStore_ScopedByAccount` | cross-account isolation for budget rules (lines 63-78) | +| `sql_store_agentnetwork_budgetrule_test.go::TestAgentNetworkSettings_RealStore_CollectionTogglesRoundTrip` | collection toggles default off, survive save/reload at the set values (lines 83-112) | + +Gap: there is no store-level test for providers (encryption round-trip), policies, guardrails, or `IncrementAgentNetworkConsumption` (concurrent upsert, window-key uniqueness). The consumption upsert is the most performance-sensitive method in this module and the only one without a real-sqlite test. + +## Known limitations / explicit non-goals + +- No retention / GC for `agent_network_consumption`. +- No `Delete` for `Settings` (one row per account, cleared with the account). +- No DB-engine-specific tuning — the same struct tags drive sqlite, mysql, postgres. +- Provider `extra_values` and `models` are JSON blobs; querying inside them is not supported by design. +- `GetAgentNetworkConsumption` "not-found = zero row" contract is convenient but unconventional. + +## Cross-references + +- Upstream: [shared/api](10-shared-api.md), [management/agentnetwork](21-management-agentnetwork.md) +- End-to-end flow: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/21-management-agentnetwork.md b/docs/agent-networks/modules/21-management-agentnetwork.md new file mode 100644 index 000000000..f91c369f7 --- /dev/null +++ b/docs/agent-networks/modules/21-management-agentnetwork.md @@ -0,0 +1,321 @@ +# management/agentnetwork — domain layer + synth pipeline + +> **Risk level:** High — central business logic + budget enforcement + the source of every middleware-chain change the proxy executes. +> **Backward-compat impact:** Additive within the agent-network surface; one **behavioural difference for opted-out accounts** in parser capture (the capture flag is stamped explicitly false instead of being absent — see capture-pointer semantics below). Non-agent-network proxy services are untouched (the synth chain only ships on `agent-net-svc-*` targets). + +## Module boundary + +`management/server/agentnetwork` owns every agent-network entity (providers, policies, guardrails, account budget rules, per-account settings, consumption rows) and **translates them into the in-memory `*rpservice.Service` that the reverse-proxy controller turns into `proto.ProxyMapping`s and pushes to clusters**. It is the *only* writer of the agent-network middleware chain. + +Inside the package: `manager.go` is the CRUD + permissions-gated facade; `synthesizer.go` walks settings + providers + policies + guardrails and emits the per-account service plus every middleware's JSON config; `policyselect.go` runs per-request attribution (min-wins account ceiling, then "drain bigger pool first"); `reconcile.go` diffs successive synth outputs and emits precise Create/Update/Delete proxy-mapping updates plus a peer-map refresh. `labelgen/` mints DNS-safe subdomain labels; `catalog/` is the static provider catalogue; `types/` carries gorm entity structs. The `_realstack_test.go` files in the parent `management/server/` directory exercise the manager + network-map controller end-to-end with no mocks. + +## Files + +| Path | Role | +| ---- | ---- | +| `agentnetwork/manager.go` | Manager interface + CRUD + permission gates + bootstrap-settings + reconcile trigger | +| `agentnetwork/synthesizer.go` | Settings/policy → wire-format synthesis; sole writer of the proxy middleware chain | +| `agentnetwork/synthesizer_pricing.go` | `buildCostMeterConfigJSON` — default table + per-provider prices → `cost_meter` config | +| `agentnetwork/pricing/defaults.go` | Default pricing table derived from the catalog + supplementals; `DefaultTable`, `LookupDefault`, wire `Entry` | +| `agentnetwork/pricing/override.go` | `LoadFile`/`StartReloader` for `AgentNetwork.PricingDefaultsFile` (mtime poll, merge over compiled-in base) | +| `agentnetwork/pricing/{exampleyaml,gen}.go` | Generates `defaults_llm_pricing.example.yaml` from the compiled-in table (golden-tested) | +| `agentnetwork/policyselect.go` | Per-request policy attribution + account-budget ceiling (min-wins) | +| `agentnetwork/reconcile.go` | Per-account synth diff vs in-memory cache → Create/Update/Delete | +| `agentnetwork/catalog/catalog.go` | Static provider catalogue (auth headers, identity-injection shapes) | +| `agentnetwork/labelgen/{labelgen,words}.go` | DNS-safe subdomain picker + curated wordlist | +| `agentnetwork/types/provider.go` | Provider entity + APIKey + Models + ExtraValues + SessionKeys | +| `agentnetwork/types/policy.go` | Policy entity + `PolicyLimits` (token + budget) | +| `agentnetwork/types/guardrail.go` | Guardrail entity (`ModelAllowlist`, `PromptCapture`) | +| `agentnetwork/types/budgetrule.go` | `AccountBudgetRule` (reuses `PolicyLimits`) | +| `agentnetwork/types/settings.go` | Per-account `Settings` (Cluster, Subdomain, 3 toggles) | +| `agentnetwork/types/consumption.go` | `Consumption` row + `WindowStart` aligner | +| `agentnetwork/{synthesizer,policyselect,reconcile,wire_shape}_*test.go` | See test coverage table | +| `agentnetwork/types/consumption_test.go` | `WindowStart` alignment proofs | +| `agentnetwork/labelgen/labelgen_test.go` | Deterministic picks + exhaustion + fallback | +| `management/server/agentnetwork_realstack_test.go` | No-mock provider CRUD → network-map fan-out | +| `management/server/agentnetwork_budgetrule_realstack_test.go` | No-mock budget-rule CRUD + settings preserve-immutable | + +## Architecture & flow + +### Synthesis (settings/policy → wire format) + +```mermaid +flowchart TD + A[Mutation: provider/policy/guardrail/settings] --> B[managerImpl.reconcile accountID] + B --> C{proxyController nil?} + C -- yes --> D[accountManager.UpdateAccountPeers only] + C -- no --> E[SynthesizeServices] + E --> F[loadSettings — NotFound returns ok=false, no synth] + F --> G[filterEnabledProviders sorted by CreatedAt] + G --> H[filterEnabledPolicies] + H --> I[backfillProviderSessionKeys if missing] + I --> J[indexProviderGroups: providerID -> sorted source groups] + J --> K[buildRouterConfigJSON drops orphan providers] + J --> L[buildIdentityInjectConfigJSON per catalog entry] + J --> K2[buildCostMeterConfigJSON: default table + per-provider prices] + K2 --> P + H --> M[mergeGuardrails: union allowlist, OR redact] + M --> N[applyAccountCollectionControls account toggle = SOLE capture control] + N --> O[marshalGuardrailConfig] + K --> P[buildMiddlewareChain 8 middleware entries] + L --> P + O --> P + P --> Q[buildAccountService: AccessGroups=union source groups, noop.invalid target] + Q --> R[reconcile.diffMappings vs cache] + R --> S[SendServiceUpdateToCluster CREATE/MODIFY/REMOVE] + R --> T[accountManager.UpdateAccountPeers — fans synth ACLs into network map] +``` + +### LLM pricing (management is the sole authority) + +**The proxy carries no price list.** Management synthesizes the entire pricing +table and ships it inside `cost_meter`'s `ConfigJSON`, so a price change reaches +the proxies as an ordinary mapping push — the chain rebuild installs a fresh +table and there is nothing to reload on the proxy side. + +```mermaid +flowchart TD + A[catalog.All — PricingSurfaces x Models] --> B[buildDefaultTable + supplementalDefaults] + B --> C{AgentNetwork.PricingDefaultsFile} + C -- absent --> D[compiled-in table serves] + C -- loaded --> E[LoadFile: merge file entries WHOLE over compiled base] + E --> F[mergedTable atomic.Pointer] + D --> G[DefaultTable] + F --> G + G --> H[buildCostMeterConfigJSON — pricing.defaults] + I[types.Provider.Models operator prices] --> J[normalizePricingModelID
    bedrock ARN/region/version, vertex @version] + J --> K[materializeEntry: default entry as base,
    operator input/output verbatim,
    cache pointers only when non-nil] + K --> L[pricing.providers keyed by provider record ID] + H --> M[cost_meter ConfigJSON] + L --> M + G --> N[GET /catalog — applyDefaultPricing prefills dashboard rows] + O[StartReloader: mtime poll every ReloadInterval 1m] --> E +``` + +**Two tiers, resolved per request on the proxy** (`synthesizer_pricing.go:22-35`): + +- `pricing.defaults` — surface (`openai`/`anthropic`/`bedrock`) → normalized model + id → rates. The **full** default table ships to every account: it is small + (~10 KB) and it is what keeps gateway-style providers (which enumerate no + models, so they claim every model) priced. +- `pricing.providers` — provider **record** id → normalized model id → rates, + matched against the `llm.resolved_provider_id` the router stamps. Entries are + **fully materialized here**, at synth time: `materializeEntry` starts from the + default entry for that model so cache rates the operator didn't state are + inherited, overlays operator `input`/`output` verbatim (**including an explicit + 0**, which prices a self-hosted or internal endpoint as free rather than + silently reverting to list price), and overlays cache-rate **pointers only when + non-nil** — `nil` means "inherit the default", an explicit `0` means "no + discount, bill this bucket at the input rate". The proxy therefore does two map + lookups and no merging. + +Same orphan rule as the router: a provider no enabled policy authorises is +unreachable, so its prices aren't shipped. Model ids are normalized with the +**same** functions the request parser uses (`NormalizeBedrockModel` / +`NormalizeVertexModel`), which is what makes the per-record lookup key compare +equal to the `llm.model` the proxy meters. Post-normalization duplicates resolve +first-occurrence-wins, matching the routing dedup order. + +**`AgentNetwork.PricingDefaultsFile`** (`config.go:190-207`) lets an operator +replace default rates without a rebuild. Schema is `surface → model → rates` +(`input_per_1k`, `output_per_1k`, and optional `cached_input_per_1k` / +`cache_read_per_1k` / `cache_creation_per_1k`). Semantics: + +- A **relative** path resolves against ``, so a bare filename lands + alongside the store. Empty config probes `/defaults_llm_pricing.yaml`. +- An **explicitly configured** path is *required to load*: a typo or malformed + file fails startup, because the operator believes those rates are live. The + conventional probe is optional — an absent file just serves compiled-in + defaults, and the path stays watched in case it appears later. +- File entries **replace** the compiled-in entry for the same (surface, model) + **whole** — they are not field-merged, so an entry must repeat the cache rates + it wants to keep. Everything the file doesn't mention keeps built-in rates. +- Unknown YAML fields are rejected (`KnownFields(true)`) and every rate must be + finite and non-negative — the same constraints the HTTP API enforces on + operator per-provider prices. +- Reload is an mtime poll (`ReloadInterval`, 1 min) and is **lenient at runtime**: + a parse error keeps the previous table, a deleted file reverts to compiled-in + defaults. A mid-edit save can never take pricing down. + +The live table feeds **both** consumers, which is what keeps them consistent: the +synthesizer (what proxies actually bill with) and `GET /api/agent-network/catalog` +via `applyDefaultPricing` (what the dashboard's model-row prices prefill with). +`defaults_llm_pricing.example.yaml` is generated from the compiled-in table +(`go generate ./management/internals/modules/agentnetwork/pricing`) and +golden-tested, so operators start from a file matching the built-in rates exactly. + +### Budget rule resolution (min-wins, group+user bound) + +```mermaid +flowchart TD + A[SelectPolicyForRequest in] --> B[checkAccountBudget — runs FIRST, independent of policies] + B --> C[GetAccountAgentNetworkBudgetRules] + C --> D{for each enabled rule} + D --> E{budgetRuleApplies?} + E -- no --> D + E -- yes --> F[attrGroup = lowestIntersect TargetGroups, in.GroupIDs] + F --> G{Token cap enabled?} + G -- yes --> H[evalTokenCap user dim + group dim] + H --> I{exhausted?} + I -- yes --> J[DENY: llm_account.token_cap_exceeded - STOP] + I -- no --> K{Budget cap enabled?} + G -- no --> K + K -- yes --> L[evalBudgetCap user dim + group dim] + L --> M{exhausted?} + M -- yes --> N[DENY: llm_account.budget_cap_exceeded - STOP] + M -- no --> D + K -- no --> D + D --> O[All rules passed -> fall through to per-policy selection] +``` + +Key invariant: **rules are checked sequentially and ANY exhausted rule denies (all-must-pass / min-wins).** Untargeted rules (`len(TargetGroups)==0 && len(TargetUsers)==0`) apply to every caller (`policyselect.go:393`). + +### Policy selection (per-peer, per-request) + +```mermaid +flowchart TD + A[Account-budget gate passed] --> B[GetAccountAgentNetworkPolicies] + B --> C[filterApplicablePolicies enabled + provider match + group intersect] + C --> D{candidates empty?} + D -- yes --> E[Allow, empty SelectedPolicyID] + D -- no --> F[scoreCandidates -> scoreOne per policy] + F --> G[scoreOne: attrGroup + window] + G --> H{any cap exhausted?} + H -- yes --> I[Drop policy; record last deny code] + H -- no --> K[Keep as live candidate] + F --> L{live candidates exist?} + L -- no --> M[Deny with last exhaustion code] + L -- yes --> N[Sort: uncapped wins -> larger group token -> group budget -> user token -> user budget -> oldest CreatedAt] + N --> O[winner = scored 0] + O --> P[Allow + SelectedPolicyID + AttributionGroupID + WindowSeconds] +``` + +End-to-end: a mutation calls `managerImpl.reconcile(ctx, accountID)` (`manager.go:205,239,...`). Reconcile defers an `accountManager.UpdateAccountPeers` so the network-map controller re-runs and `injectAllProxyPolicies` picks up the new access groups; with a `proxyController` wired, it re-synthesizes the service, diffs against `reconcileCache[accountID]` (guarded by `reconcileMu`), and emits proto mappings to the cluster derived from the mapping's domain (`reconcile.go:120`). Synthesis is stateless and idempotent. Sole persistent side effect: `backfillProviderSessionKeys` (`synthesizer.go:249`) mints ed25519 keys on legacy provider rows and writes them back. + +At request time the path is independent: the proxy calls `SelectPolicyForRequest` (`policyselect.go:56`); account-budget ceiling first, then per-policy scoring. Token + budget caps share `evalTokenCap` / `evalBudgetCap` — same primitive for account rules and policy limits, `label` differentiates the deny reason. After a served request, `RecordAccountBudgetUsage` (`policyselect.go:415`) fans deltas to every applicable rule's distinct `(dim_kind, dim_id, window)` tuple, deduplicating to prevent double-count when two rules share target+window. + +## Public contracts + +- **Manager interface** (`manager.go:48-80`): CRUD for `Providers/Policies/Guardrails/BudgetRules`; `GetSettings/UpdateSettings` (cluster + subdomain immutable, only the three toggles mutate); `ListConsumption/RecordConsumption(account, kind, dimID, windowSec, in, out, USD)`; `RecordAccountBudgetUsage(account, user, groups, in, out, USD)`; `SelectPolicyForRequest(ctx, PolicySelectionInput) → *PolicySelectionResult{Allow, SelectedPolicyID, AttributionGroupID, WindowSeconds, DenyCode, DenyReason}`. +- **`PolicySelectionInput`** (`manager.go:85-90`): `{AccountID, UserID, GroupIDs, ProviderID}` — populated by the proxy from CapturedData + `llm_router` resolution. +- **Synthesized middleware chain** (`synthesizer.go:576-657`), order load-bearing — response slot runs reverse-of-slice: + + | Slot | Idx | ID | ConfigJSON shape | CanMutate | + | --- | --- | --- | --- | --- | + | on_request | 0 | `llm_request_parser` | `{"capture_prompt": , "redact_pii"?: true}` | – | + | on_request | 1 | `llm_router` | `{"providers":[{id, models[], upstream_*, auth_header_*, allowed_group_ids[]}]}` | **true** | + | on_request | 2 | `llm_limit_check` | `{}` | – | + | on_request | 3 | `llm_identity_inject` | `{"providers":[{provider_id, header_pair?, json_metadata?, extra_headers?}]}` | **true** | + | on_request | 4 | `llm_guardrail` | `{"provider_allowlists"?: {providerID: []model}, "prompt_capture":{enabled,redact_pii}}` | – | + | on_response | 5 | `llm_limit_record` | `{}` (runs LAST at runtime) | – | + | on_response | 6 | `cost_meter` | `{"pricing":{"defaults":{surface:{model:rates}},"providers"?:{providerRecordID:{model:rates}}}}` — rates are `{input_per_1k, output_per_1k, cached_input_per_1k?, cache_read_per_1k?, cache_creation_per_1k?}` | – | + | on_response | 7 | `llm_response_parser` | `{"capture_completion": , "redact_pii"?: true}` | – | +- **Synthesized service shape** (`synthesizer.go:739`): `Mode=HTTP`, `Private=true`, `Domain=.`, `AccessGroups=unionSourceGroups(enabledPolicies)`, one `TargetTypeCluster` target with `Host=noop.invalid:443` (router rewrites per request), `Options.{DirectUpstream,AgentNetwork}=true`, `DisableAccessLog=!settings.EnableLogCollection`, `CaptureMax{Req,Resp}Bytes=1<<20`, `CaptureContentTypes=["application/json","text/event-stream"]`. + +## Invariants + +- **Min-wins / all-must-pass for account budget rules** (`checkAccountBudget`, `policyselect.go:353`): every applicable enabled rule is checked; first exhausted cap denies. Untargeted rules bind every caller. +- **Account toggle is the SOLE control for capture enablement.** `applyAccountCollectionControls` (`synthesizer.go:701`) sets `merged.PromptCapture.Enabled = settings.EnablePromptCollection` *unconditionally*. +- **Capture-pointer semantics on parser configs** — see "Things to scrutinize" below. +- **`EnableLogCollection` ↔ `DisableAccessLog` is the only access-log toggle** (`synthesizer.go:770`). Default off ⇒ access log suppressed. +- **`RedactPii` flows verbatim to BOTH parsers** (`synthesizer.go:584-585`) and is OR'd into the merged guardrail (`synthesizer.go:706`). +- **Cluster and Subdomain are immutable on Settings.** `UpdateSettings` reloads existing row and overlays only the three toggles (`manager.go:558-561`). +- **Orphan providers (no enabled policy authorises them) NEVER reach the router** (`synthesizer.go:351-357`); skipped from `identity_inject` for symmetry. +- **Provider creation refuses empty `api_key`** (`manager.go:175`); **deletion refuses while any policy still references it** (`manager.go:265-273`). +- **Session keypair stability across provider edits** (`manager.go:226-228`) — server-managed, copied through every `UpdateProvider`, never API-surfaced. +- **Management is the sole pricing authority.** The proxy has no embedded price list, so an account whose `cost_meter` config carries no `pricing` block bills **nothing** (`cost.skipped=unknown_model`, $0) rather than falling back to stale built-ins. The top-level `pricing` wrapper is also the feature-detection signal in both directions: an old proxy ignores it as an unknown field, and a new proxy reads its absence as "old management". +- **Per-provider prices are materialized at synth time, not merged on the proxy** (`synthesizer_pricing.go:114-131`). A per-record entry is always complete, so the proxy's lookup is per-record-then-defaults with no field-level fallback between tiers. +- **An explicit operator price of `0` prices the model as free** — it must not be treated as "unset" and reverted to list price (`synthesizer_pricing.go:49-54`). Only *cache*-rate fields distinguish unset from zero, via `*float64`. +- **Pricing model ids are normalized with the same functions the request parser uses** (`normalizePricingModelID`). If the two ever diverge, per-record prices silently stop matching and every request falls through to surface defaults. +- **The default table's coverage is structural, not curated.** It is derived from the catalog via each provider's `PricingSurfaces`; `TestDefaultTable_CoversEveryCatalogModel` fails on an unpriced catalog model and `TestDefaultTable_NoConflictingContributions` fails if two providers contribute the same (surface, model) at different rates. +- **A pricing-defaults file failure is fatal only at startup, and only for an explicitly configured path.** Runtime reload failures keep the previous table; a deleted file reverts to compiled-in defaults (`pricing/override.go:62-81, 113-148`). + +## Things to scrutinize + +### Correctness + +- **Capture-pointer semantics — `*bool` vs `bool`.** Three states, owned by separate sides: + - **Wire JSON this module emits:** `buildParserConfigJSON` (`synthesizer.go:678-693`) *always* stamps the capture field. Agent-network targets ship `"capture_prompt": false` or `"capture_prompt": true` — never absent. Same for `"capture_completion"`. The happy-path test pins `{"capture_prompt":false}` (`synthesizer_test.go:174`). + - **Proxy-side parser config (consumer):** parsers decode into `*bool`. Matrix: + - `nil` (field absent) → **legacy default = emit**. Preserved for non-agent-network callers and pre-existing tests (the backward-compat hook). + - `false` (field present, value false) → **suppress emission entirely**. The behaviour for opted-out agent-network accounts. Without this, `enable_log_collection=true` + `enable_prompt_collection=false` would leak raw user input AND raw model output to the access log. + - `true` → emit normally. + - **Why the synth always stamps a value:** an agent-network mapping omitting the field would hit legacy "always emit" and re-introduce the leak. The `json.Marshal` error fallback at `synthesizer.go:687` degrades to `{}` — comment-claimed unreachable, but if ever fired re-introduces the leak. Consider fail-closed (return literal `{"capture_prompt":false}`) instead. +- **`scoreCandidates` non-cumulative deny code.** Only the *last* exhausted policy's deny code survives (`policyselect.go:188-190`). Iteration order is store's natural order. Auth signal is `len(scored)==0`, so this is informational only — verify no UI depends on "first exhausted policy" semantics. +- **`effectiveWindowSeconds` token-wins tiebreak.** When both halves are enabled with different windows, token's window wins (`policyselect.go:482`). Verify `RecordLLMUsage` increments against the winning window only. +- **`RecordAccountBudgetUsage` dedup.** Two rules with the same `(kind, dim_id, window)` would double-count without the `tuples` map (`policyselect.go:434-449`). Key includes all three dimensions — correct. +- **Fail-closed on bad provider:** unknown catalog id (`synthesizer.go:794-796`) or empty API key (`synthesizer.go:801-803`) drops the **entire** account's synth, not just the bad provider. Confirm matches operator UX. + +### Security + +- **Redact OR-merge:** merged `RedactPii` = account OR guardrail (`synthesizer.go:706`). **Parser-side flag is `settings.RedactPii` only, NOT the OR** — a guardrail-only opt-in does not propagate to parsers. Correct because the account toggle gates capture, but worth noting on the proxy side. +- **Group resolution must not leak across accounts.** Every store call carries `accountID` (`policyselect.go:73, 286, 298, 322, 334, 354`); `lowestIntersect` uses caller's claimed groups only (`policyselect.go:494`). Risk surface is upstream (handler populates `in.GroupIDs`). +- **`UpdateSettings` preserves immutable Cluster + Subdomain** (`manager.go:558`). A client can't rebind the cluster. +- **Provider session keypair backfill writes through `SaveAgentNetworkProvider`** (`synthesizer.go:256`) from a read-shaped call. Idempotent → worst case is a wasted write under concurrent reconcile + snapshot. + +### Concurrency + +- **`reconcileMu`** guards `reconcileCache`. Lock window is narrow — compute diff inside, send outside (`reconcile.go:56-68`). +- **`labelRngMu`** guards `labelRng` because `math/rand.Source` is unsafe for concurrent use (`manager.go:638-640`). +- **Real-store tests** use `store.NewTestStoreFromSQL` with `t.TempDir()` per test — no shared state, no `t.Parallel()`. +- **`RecordAccountBudgetUsage` dedup `tuples` map is per-call;** concurrent calls fan out fully — correct (each request's tokens book once per applicable rule). +- **Deferred `UpdateAccountPeers` runs inline after the proxy push** (`reconcile.go:28-35`); a slow call stretches CRUD response time. + +### Backward compatibility + +- **Capture-pointer semantics (restated):** non-agent-network callers see no field → legacy nil-default emit, identical to pre-PR. Agent-network targets always carry an explicit `capture_*` value. +- **`TestSynthesizeServices_HappyPath` was updated:** request-parser config moved from `{}` to `{"capture_prompt":false}` (`synthesizer_test.go:174`). External snapshot tests against synth output need updating. +- **`MergedGuardrails` retains zeroed `TokenLimits`/`Budget`/`Retention`** even though `Policy.Limits` carries the real values now; `llm_limit_check` is the authoritative enforcement. Comment at `synthesizer.go:940-948` calls this out. +- **`cost_meter`'s `pricing` block is version-skew-safe in both directions.** A proxy predating config-delivered pricing ignores the field as unknown JSON (it previously priced from its own embedded table, so it keeps billing — at its own rates, which is the skew to be aware of during a rolling upgrade). A current proxy paired with old management sees no `pricing` block, logs one warning at chain-build time, and records `cost.skipped=unknown_model` — token counting and cap enforcement are unaffected, only the USD annotation goes to $0. + +### Performance + +- **`SynthesizeServices` runs on every controller tick / mutation reconcile.** Cost: 4 store reads + optional per-provider keypair backfill. Sort + index + merge are O(N log N) / O(P × G); dominant cost is JSON marshalling. No nested loops escape these dimensions. +- **The full default pricing table is marshalled into every account's `cost_meter` config on every synth** (~10 KB serialized). This is a deliberate trade: it keeps gateway-style providers priced for every catalog model, and it is the largest single contributor to the synth JSON. `DefaultTable()` itself is a pointer load (or a `sync.Once`-built map) — the cost is the marshal, not the build. +- **`reconcile.diffMappings` is O(N + M)** with N=M=1 per account today — effectively constant. +- **`SynthesizeServicesForCluster`** (`synthesizer.go:71`) walks every account on a cluster; per-account failures are **swallowed** (`synthesizer.go:91-93`) so a single misconfigured account doesn't drop the cluster. Runs per proxy reconnect. + +### Observability + +- **Activity codes:** `AgentNetwork{Provider,Policy,Guardrail,BudgetRule}{Created,Updated,Deleted}`; `AgentNetworkSettingsUpdated` with `log_collection/prompt_collection/redact_pii` payload (`manager.go:567-571`). **No activity code for `SelectPolicyForRequest` denies** — surfaced via proxy access log only (likely intentional given volume). +- **Deny codes** namespaced: `llm_policy.{token,budget}_cap_exceeded`, `llm_account.{token,budget}_cap_exceeded` (`policyselect.go:18-26`). +- **Reconcile failures are logged at warn and swallowed** (`reconcile.go:42-44`). Persistent synth failures (e.g. unknown catalog id) silently keep the proxy out of sync — consider a manager-level synth-health surface if this becomes a support burden. +- **Pricing-file lifecycle logs at info** (load, reload, revert-to-built-ins) and **at warn** for a runtime reload failure; the mtime check itself is `Debugf`. There is no metric on reload failures, so an operator who breaks the file mid-flight keeps billing at the previous table with only a log line to show it (`pricing/override.go:113-148`). + +## Test coverage + +| Test file | Locks down | +| --------- | ---------- | +| `synthesizer_test.go` | Mock-store: `HappyPath` (8-mw chain ordering, `{"capture_prompt":false}` baseline); `No{Settings,Providers}`; `Disabled{Provider,Policy}_NoService`; `RouterConfigOrdering`; `PolicyCheckConfig_UnionsSourceGroups`; `OrphanProvider_HasEmptyAllowedGroups`; identity-inject for LiteLLM / Bifrost (overrides + partial disable) / Cloudflare / Portkey / Vercel / OpenRouter / generic non-customizable; `GuardrailMerge_AllowlistUnion_LimitsRestrictive`; `BackfillsMissingSessionKeys`; `HTTPUpstream_KeepsExplicitPort`; `UpstreamURLPath_FlowsToRouter`; `UnknownProviderID_FailsClosed`; `EmptyAPIKey_FailsClosed`. | +| `synthesizer_realstore_test.go` | Real-sqlite: `SurvivesStatusToggle` reproduces the disable/re-enable 403 regression; `Reconcile_RealStore_PushesPrivateAfterStatusToggle` extends through reconcile push. | +| `synthesizer_guardrail_realstore_test.go` | `PromptCaptureAccountIsSoleControl`; `PromptCaptureFlowsWhenAccountOptsIn`; `AccountRedactWithoutGuardrailRedact`; `NoGuardrail_CaptureOff`. | +| `synthesizer_log_collection_realstore_test.go` | `LogCollection{Off_SuppressesAccessLog,On_PermitsAccessLog}` — verifies `DisableAccessLog` propagation through `ToProtoMapping`. | +| `synthesizer_parser_redact_realstore_test.go` | **Capture-pointer regression suite:** `ParserConfigsCarryRedactPii`; `ParserConfigsSuppressCaptureWhenLogCollectionOnly` (log=on/prompt=off ⇒ both capture flags false); `ParserConfigsOmitRedactPiiWhenOff`. | +| `synthesizer_pricing_test.go` | `BuildCostMeterConfig_{BedrockModelNormalization,CacheRateNilVsZero,OrphanAndGatewayProviders}` — the per-record tier's three load-bearing rules: keys normalized like the parser's, `nil` cache pointer inherits vs explicit `0` bills at input rate, and orphan / gateway (empty `Models`) providers ship no per-record entry. | +| `pricing/defaults_test.go` | `DefaultTable_{CoversEveryCatalogModel,NoConflictingContributions,AllRatesFiniteNonNegative,PinnedRates}`; `LookupDefault_SurfaceOrder`. Catalog-derived coverage + rate sanity are structural, not curated. | +| `pricing/override_test.go` | `LoadFile_{MergesOverCompiledDefaults,MissingPath,RejectsInvalid}`; `Reload_LifeCycle` (mtime detect, parse error keeps previous, delete reverts to built-ins); `ExampleYAML_InSyncWithBuiltins` golden. | +| `policyselect_test.go` | Mock-store: `NoApplicablePolicies`; `AllowWithLowestGroupAttribution`; `LargerPoolWinsAcrossUsageLevels`; `StaysOnLargerPoolAfterPartialDrain`; `FallsThroughToSmallerPoolWhenLargerExhausted`; `TiebreakBy{LargerGroupPool,CreatedAt}`; `DeniesWhenAllExhausted`; `UncappedPolicyAlwaysWinsAgainstCapped`; `DisabledPolicyIgnored`; `StoreErrorPropagates`; `RejectsEmptyAccount`; `SharesGroupCounterAcrossPolicies`; `AntiFallThroughOnLowestGroup`; `BudgetOnlyExhaustionDenies`; `BudgetTighterThanTokenWins`. | +| `policyselect_realstore_test.go` | Real-sqlite regression guard: `NoApplicablePolicies`; `AllowAndLowestGroupAttribution`; `LargerPoolWins_FallsThroughWhenExhausted`; `BudgetCapDenies`; `GroupCounterSharedAcrossPolicies`; `DisabledPolicyIgnored`. | +| `policyselect_account_realstore_test.go` | Account budget rules: `AccountCeilingBindsEvenWithUncappedPolicy` (min-wins); `AccountGroupCeiling`; `AccountTargetUsersBindsOnlyThatUser`; `AccountRuleRecordsToOwnWindow`. | +| `reconcile_test.go` | `FirstSynth_EmitsCreate`; `NoChange_EmitsNothingExtra` (re-push as Modified — verify desired); `PolicyRemoved_EmitsDelete`; `NilProxyController_NoOp`; `EmptyAccountID_NoOp`; `ClusterFromMapping`. | +| `wire_shape_test.go` | `TestSynthesizedService_WireShape` — proto-shape lockdown via `ToProtoMapping`. Catches "service not matching" (mapping reaches proxy but no SNI/HTTP route). Asserts ID, Domain, Mode, AuthToken, `Private`, `Auth.Oidc=false`, one path `/` + `https://noop.invalid/`, 8 middlewares with correct slot enums, router config `auth_header_value="Bearer sk-test-key"`. | +| `labelgen/labelgen_test.go` | `PickUnique_{DeterministicWithSeededRng,AvoidsTakenWordsWhenMostAreReserved,FallsBackWhenAllReserved}`; `UniqueWords_DropsDuplicates`. | +| `types/consumption_test.go` | `WindowStart_{AlignedToUnixEpoch,WithinWindowConverges,AcrossWindowsDiverges,DifferentWindowsHaveDifferentBuckets,SubMinuteAndMinuteAlignment,ZeroWindowReturnsInputUTC}`. Bucket alignment so multi-node reads converge. | +| `agentnetwork_realstack_test.go` | `ProviderCRUD_FansOutToProxyAndClientPeers` — no-mock end-to-end through real account manager + network-map + agentnetwork: provider create propagates the updated map to both proxy peer and client peer with the synth DNS surface. | +| `agentnetwork_budgetrule_realstack_test.go` | `BudgetRuleCRUD_RealManager`; `UpdateSettings_PreservesImmutableAndTogglesCollection`. | + +## Known limitations / explicit non-goals + +- **`MergedGuardrails.TokenLimits/Budget/Retention` emit at zero** (`synthesizer.go:940-948`); real enforcement is `Policy.Limits` via `llm_limit_check`. Future cleanup implied. +- **Session keys picked from first enabled provider by created_at** (`pickServiceSessionKeys`, `synthesizer.go:270`). Existing session cookies survive provider edits only while the first-by-CreatedAt provider stays in place. Document for operators. +- **Reconcile failures silently swallowed** (`reconcile.go:42-44`). Persistent failures keep the proxy out of sync until the next reconcile. +- **`scoreCandidates` exposes only the LAST exhaustion's deny code** when multiple policies are exhausted. +- **`bootstrapSettingsIfNeeded` failure is non-fatal to provider create** (`manager.go:200`): provider lands, synth is no-op until the next provider create retries the bootstrap. +- **Budget rules do not trigger a reconcile** (`manager.go:476-477`). Request-time evaluation only; new rules take effect on the next request without a proxy push. + +## Cross-references + +- **Upstream:** [shared/api](10-shared-api.md), [management/store](20-management-store.md), reverseproxy `service`/`proxy`/`sessionkey` packages, `management/server/permissions` + `activity`. +- **Downstream:** [management/handlers (HTTP wiring)](22-management-handlers-wiring.md), [proxy/middleware-builtin](31-proxy-middleware-builtin.md), network-map controller (`injectAllProxyPolicies` fan-out). +- **End-to-end flow:** [../01-end-to-end-flows.md](../01-end-to-end-flows.md) — "Provider create → reconcile → proxy push → peer map refresh" and "request → policy select → record" diagrams. +- **Top-level:** [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/22-management-handlers-wiring.md b/docs/agent-networks/modules/22-management-handlers-wiring.md new file mode 100644 index 000000000..9b8a47445 --- /dev/null +++ b/docs/agent-networks/modules/22-management-handlers-wiring.md @@ -0,0 +1,203 @@ +# management/handlers + wiring — HTTP API + gRPC delivery + +> **Risk level:** Medium — the surface is mostly additive, but two changes are load-bearing: `injectAllProxyPolicies` runs on every per-peer compute, and `shallowCloneMapping` must round-trip `Private` (a missed field silently breaks every MODIFIED). +> **Backward-compat impact:** Additive on the wire (new routes, new RPCs, new proto fields, new gorm column on `AccessLogEntry`). One management-internal break: `nbhttp.NewAPIHandler` gains a trailing `agentNetworkManager` parameter; `nil` is tolerated and silently skips route registration. + +## Module boundary + +This module is the seam between the public Agent Network HTTP API and the proxy fleet that serves agent traffic. North side: a `/api/agent-network/*` surface (providers, policies, guardrails, budget rules, settings, consumption) on the existing gorilla router, delegating to `agentnetwork.Manager`. Handlers are thin — they translate `api.*` ↔ `types.*`, validate shape, forward. RBAC and event emission stay inside the manager (`manager.go:680-682`). + +South side: `ProxyServiceServer` (`proxy.go`) learns to (a) ship synth services to a proxy on initial snapshot, (b) resolve agent-network domains in `getServiceByDomain` for OIDC/session/tunnel-peer flows, (c) gate LLM requests via `CheckLLMPolicyLimits` + `RecordLLMUsage`, (d) preserve `Private` through `shallowCloneMapping` so per-proxy live updates don't silently flip services public. The network_map controller prepends synth services to `account.Services` on every per-peer compute; `accesslogentry.go` gains an indexed `AgentNetwork` column so the dashboard can filter cheaply. + +## Files + +| Path | Role | +| ---- | ---- | +| `handlers/agentnetwork/providers_handler.go` | Catalog + provider CRUD + central `AddEndpoints` | +| `handlers/agentnetwork/policies_handler.go` | Policy CRUD + shared `validatePolicy*` | +| `handlers/agentnetwork/guardrails_handler.go` | Guardrail CRUD | +| `handlers/agentnetwork/budget_handler.go` | Account-level budget rule CRUD | +| `handlers/agentnetwork/settings_handler.go` | GET (200+`null` if unbootstrapped) + PUT toggles | +| `handlers/agentnetwork/consumption_handler.go` | Read-only consumption rows | +| `handlers/agentnetwork/handlers_test.go` | Real-store fixture; wire round-trip + validation | +| `handlers/agentnetwork/budget_handler_test.go` | Budget-rule + settings toggles | +| `server/http/handler.go` | New `agentNetworkManager` arg; conditional `AddEndpoints` | +| `server/permissions/modules/module.go` | New `AgentNetwork` module key | +| `internals/server/boot.go` | Wires synthesiser adapter + limits service into proxy server | +| `internals/server/modules.go` | `AgentNetworkManager()` lazy-create node | +| `internals/controllers/network_map/controller/controller.go` | `injectAllProxyPolicies` replaces 4 `InjectProxyPolicies` calls | +| `internals/controllers/network_map/controller/repository.go` | `SynthesizeAgentNetworkServices` repo method | +| `internals/modules/reverseproxy/service/service.go` | `MiddlewareConfig`, capture limits, `AgentNetwork`, `DisableAccessLog` + proto | +| `internals/modules/reverseproxy/accesslogs/accesslogentry.go` | Indexed `AgentNetwork bool` from proto | +| `internals/shared/grpc/proxy.go` | Synth wiring, 2 RPCs, domain fallback, `Private` in clone | +| `internals/shared/grpc/proxy_clone_test.go` | Locks every `ProxyMapping` field minus `AuthToken` | +| `server/activity/codes.go` | 13 new activity codes (125-137) | + +## HTTP routes added + +All routes inherit the platform's auth middleware. Perms enforced inside `agentnetwork.Manager.requirePermission` (`manager.go:680-682`) on `modules.AgentNetwork`. Permission column shows the `op` passed to `requirePermission` — read = `Read`, etc. + +| Method | Path | Perm | Handler | +| ------ | ---- | ---- | ------- | +| GET | `/agent-network/catalog/providers` | authn only | `providers_handler.go:43` | +| GET | `/agent-network/providers` | read | `providers_handler.go:57` | +| POST | `/agent-network/providers` | create | `providers_handler.go:97` | +| GET | `/agent-network/providers/{providerId}` | read | `providers_handler.go:77` | +| PUT | `/agent-network/providers/{providerId}` | update | `providers_handler.go:132` | +| DELETE | `/agent-network/providers/{providerId}` | delete | `providers_handler.go:172` | +| GET | `/agent-network/policies` | read | `policies_handler.go:32` | +| POST | `/agent-network/policies` | create | `policies_handler.go:72` | +| GET | `/agent-network/policies/{policyId}` | read | `policies_handler.go:52` | +| PUT | `/agent-network/policies/{policyId}` | update | `policies_handler.go:102` | +| DELETE | `/agent-network/policies/{policyId}` | delete | `policies_handler.go:142` | +| GET | `/agent-network/guardrails` | read | `guardrails_handler.go:25` | +| POST | `/agent-network/guardrails` | create | `guardrails_handler.go:65` | +| GET | `/agent-network/guardrails/{guardrailId}` | read | `guardrails_handler.go:45` | +| PUT | `/agent-network/guardrails/{guardrailId}` | update | `guardrails_handler.go:95` | +| DELETE | `/agent-network/guardrails/{guardrailId}` | delete | `guardrails_handler.go:135` | +| GET | `/agent-network/budget-rules` | read | `budget_handler.go:24` | +| POST | `/agent-network/budget-rules` | create | `budget_handler.go:64` | +| GET | `/agent-network/budget-rules/{ruleId}` | read | `budget_handler.go:44` | +| PUT | `/agent-network/budget-rules/{ruleId}` | update | `budget_handler.go:95` | +| DELETE | `/agent-network/budget-rules/{ruleId}` | delete | `budget_handler.go:135` | +| GET | `/agent-network/settings` | read | `settings_handler.go:53` (200+`null` if no row) | +| PUT | `/agent-network/settings` | update | `settings_handler.go:27` | +| GET | `/agent-network/consumption` | read | `consumption_handler.go:21` | + +## gRPC RPCs added (or modified) + +| RPC | Direction | Trigger | +| --- | --------- | ------- | +| `CheckLLMPolicyLimits` | proxy→mgmt unary | Pre-flight gate; returns allow/deny, selected policy, attribution group, window, deny code+reason (`proxy.go:259-301`). `Unimplemented` when limits service is nil. | +| `RecordLLMUsage` | proxy→mgmt unary | Post-flight write of tokens+cost against policy-window dimensions + every applicable account budget rule (`proxy.go:303-349`). `window_seconds==0` ⇒ no policy cap, only account fan-out runs. | +| `GetMappingUpdate`/`SendServiceUpdate` (stream) | mgmt→proxy | Snapshot (`proxy.go:752-780`) now appends `SynthesizeServicesForCluster`. Live updates use `SendServiceUpdateToCluster` + `shallowCloneMapping`. | + +## Architecture & flow + +### HTTP request lifecycle + +```mermaid +sequenceDiagram + participant DB as Dashboard + participant R as gorilla.Router (/api) + participant H as handler (agentnetwork) + participant M as agentnetwork.Manager + participant S as store.Store + participant AM as accountManager (StoreEvent) + + DB->>R: POST /api/agent-network/providers + R->>H: createProvider (auth mw sets UserAuth) + H->>H: GetUserAuthFromContext + validate(req) + H->>M: CreateProvider(userID, provider, bootstrapCluster) + M->>M: requirePermission(AgentNetwork, Create) + M->>S: SaveAgentNetworkProvider + M->>AM: StoreEvent(AgentNetworkProviderCreated) + M-->>H: created provider + H-->>DB: 200 + api.AgentNetworkProvider JSON +``` + +### Synth-service delivery via gRPC + +```mermaid +sequenceDiagram + participant P as Proxy + participant G as ProxyServiceServer + participant SM as service.Manager (persisted) + participant SA as synthesizerAdapter + participant AN as SynthesizeServicesForCluster + participant ST as store.Store + + Note over P,G: Initial snapshot + P->>G: GetMappingUpdate (stream open) + G->>SM: GetServicesForCluster(conn.address) + SM-->>G: persisted []*Service + G->>SA: SynthesizeServicesForCluster(conn.address) + SA->>AN: SynthesizeServicesForCluster(store, clusterAddr) + AN->>ST: walk every account; read providers/policies/settings + AN-->>SA: in-memory []*Service + SA-->>G: []*Service + G->>P: response (persisted + synth) + + Note over G,P: Per-request live update + G->>G: SendServiceUpdateToCluster(update, clusterAddr) + G->>G: shallowCloneMapping(update) %% Private MUST survive + G->>P: response with single mapping +``` + +End-to-end: HTTP write persists rows and emits an activity event; the manager then triggers `proxyController.SendServiceUpdate` so proxies re-render. **The snapshot path is the only one that calls into the synthesiser** — on stream open it pulls persisted services then appends synth services for the cluster. Synth services are never persisted. For OIDC/session/tunnel-peer flows, `getServiceByDomain` falls back to `SynthesizeServicesForCluster(clusterFromDomain(domain))` when persisted lookup misses (`proxy.go:1763-1793`). The network_map contribution is orthogonal: per-peer compute prepends the same synth services to `account.Services` before `InjectProxyPolicies`. + +## Permissions model added + +- `permissions/modules/module.go:22` adds `AgentNetwork Module = "agent_network"`, registered in `All` (`module.go:42`). Standard `operations.{Read,Create,Update,Delete}` matrix. +- Handlers don't call `permissionsManager` directly — they extract `UserAuth` and delegate to `agentnetwork.Manager`, which gates every mutation through `requirePermission` (`manager.go:168, 308, 549`, etc.). Confirm your role-set provider has `agent_network` rows for owner/admin/user/billing-admin before merging. +- `getCatalogProviders` (`providers_handler.go:43`) intentionally skips RBAC — catalog is global static data. + +## Activity codes added + +`activity/codes.go:244-274` adds Activities 125-137 + string/code mappings (`codes.go:428-444`), following `..` (e.g., `agent_network.provider.create`). Audit-log exporters / SIEM forwarders need to know the new codes. + +## Invariants + +- **Synth services are never persisted.** Snapshot appends after `serviceManager.GetServicesForCluster` (`proxy.go:761-770`); network_map prepends before `InjectProxyPolicies` (`controller.go:117-126`). +- **`shallowCloneMapping` must round-trip every `ProxyMapping` field except `AuthToken`** — `proxy_clone_test.go:50-58` enforces via `gproto.Equal`. The bug it guards: a missing `Private` made every MODIFIED arrive `private=false`, the proxy skipped `ValidateTunnelPeer`, `UserGroups` stayed empty, `llm_router` denied `no_authorised_provider`; a restart "fixed" it because the snapshot uses the original mapping. +- **Limit-window floor is 60s** (`policies_handler.go:189-220`); enabled cap with both per-group and per-user at zero is rejected. Budget rules reuse the same validator (`budget_handler.go:170`). +- **Manager is optional at boot.** `NewAPIHandler` registers routes only when non-nil (`handler.go:129`); `ProxyServiceServer` returns `Unimplemented` from both RPCs when limits service is unwired (`proxy.go:262-265, 306-309`). +- **Settings GET on an unbootstrapped account returns 200 + `null`** (`settings_handler.go:65-72`) — not 404. + +## Things to scrutinize + +### Correctness +- **`injectAllProxyPolicies` runs on every per-peer compute**: `controller.go:163, 309, 415, 681`. `sendUpdateAccountPeers` is the target of the buffered fan-out — synth runs once per debounced account-update tick **and** once per direct `UpdateAccountPeer`. Cost is O(providers + policies × users-per-group) per account under `LockingStrengthNone`. No per-account synth cache — verify it fits the buffer interval for your largest tenant. +- **`clusterFromDomain` strips at the first `.`** (`proxy.go:1784-1792`). A zero-dot domain returns `""` and the synth call walks every account. Confirm no path reaches this with a malformed/internal domain. +- **Account-budget `RecordConsumption` fans out even when `window_seconds == 0`** (`proxy.go:341-348`) — intentional. Verify the proxy never sends `RecordLLMUsage` for a request that wasn't actually allowed. + +### Security +- Every handler extracts `UserAuth` via `nbcontext.GetUserAuthFromContext` before any work. Routes live behind the standard `/api` mux; bypass list is not extended. +- `CheckLLMPolicyLimits` / `RecordLLMUsage` ride the existing **proxy → mgmt** gRPC connection auth. No additional token check inside the RPCs — they trust the connection. Confirm the proxy-side token-verification interceptor in this package gates both. +- `RecordLLMUsage` only validates `account_id != ""` (`proxy.go:317-319`). A compromised proxy can attribute cost to any account in its cluster — was already true for prior RPCs but is louder now that data drives denials. + +### Concurrency +- `SetAgentNetworkSynthesizer` / `SetAgentNetworkLimitsService` write under `s.mu.Lock`; read paths copy the interface under read lock (`proxy.go:236-247, 260-263, 304-307`). Same pattern as existing `serviceManager`/`proxyController` setters. +- Manager writes use `LockingStrengthUpdate`; synth reads use `LockingStrengthNone` — read-after-write via the proxy snapshot can observe a stale view by up to one fan-out tick. +- Network_map controller is single-threaded per account; cross-account is parallel. + +### Backward compatibility +- `proxy_clone_test.go` is the regression net; any new `ProxyMapping` field must be cloned or explicitly nulled in the test. +- `AccessLogEntry` adds indexed `AgentNetwork bool` — implicit AutoMigrate; deploy story must handle table-rewrite cost on high-volume access-log tables. +- `TargetOptions` gains seven `omitempty` JSON fields (`service.go:69-94`); on-wire shape stays compatible. `targetOptionsToProto` tests all fields when deciding nil (`service.go:551-556`). +- `NewAPIHandler` signature changes — every caller must pass `agentNetworkManager`; `nil` is supported. + +### Observability +- 13 new activity codes via `accountManager.StoreEvent` in the manager — confirm dashboard's audit-log UI maps them. +- `AccessLogEntry.AgentNetwork` is indexed for the dashboard's agent-network log filter. +- New RPCs log at error level on store/selector failures (`proxy.go:284, 327, 332, 348`). Snapshot synth failures degrade to warnings — stream is not aborted (`proxy.go:765`). + +## Test coverage + +| Test | Locks down | +| ---- | ---------- | +| `handlers_test.go::TestPolicyHandler_WindowSecondsRoundTrip` | GET carries `window_seconds`; legacy `window_hours`/`window_days` absent. | +| `handlers_test.go::TestPolicyHandler_RejectsSubMinuteWindow` | POST `<60s` returns 4xx. | +| `handlers_test.go::TestConsumptionHandler_EmptyAccountReturnsArray` | `/consumption` returns `[]` — never null. | +| `handlers_test.go::TestConsumptionHandler_PopulatedAccountListsRows` | RecordConsumption×2 surfaces both with correct tokens/cost/window. | +| `budget_handler_test.go::TestBudgetRuleHandler_RoundTrip` | Targets + PolicyLimits shape round-trip. | +| `budget_handler_test.go::TestBudgetRuleHandler_ListReturnsArray` | Empty-list shape. | +| `budget_handler_test.go::TestBudgetRuleHandler_{RejectsMissingName,RejectsSubMinuteWindow}` | Validation rejections are 4xx. | +| `budget_handler_test.go::TestSettingsHandler_GetExposesCollectionToggles` | All four toggles + computed `Endpoint`. | +| `proxy_clone_test.go::TestShallowCloneMapping_PreservesAllFieldsExceptAuthToken` | Future-proofs clone; every field round-trips, `AuthToken` dropped. | + +Handler tests use a real sqlite store + real manager + always-allow permissions mock (`handlers_test.go:53-75`). Create/update/delete success paths flow through `accountManager.StoreEvent` which the fixture doesn't wire — covered by manager-level no-mock tests outside this module. + +## Known limitations / explicit non-goals + +- No pagination on any list endpoint; no bulk endpoints. +- Synth result is not cached — every snapshot and every per-peer compute repeats the store walk. +- `getSettings` returning `200 + null` is a deliberate dashboard concession. +- No rate-limiting beyond the global `/api` rate limiter. + +## Cross-references + +- Upstream: [shared/api](10-shared-api.md), [management/agentnetwork](21-management-agentnetwork.md), [management/store](20-management-store.md) +- Downstream: [proxy/runtime](33-proxy-runtime.md) +- End-to-end flow: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/30-proxy-middleware-framework.md b/docs/agent-networks/modules/30-proxy-middleware-framework.md new file mode 100644 index 000000000..39322fdce --- /dev/null +++ b/docs/agent-networks/modules/30-proxy-middleware-framework.md @@ -0,0 +1,215 @@ +# proxy/middleware-framework — generic plugin system + +> **Risk level:** **High** — every proxied request transits this chain. Budget exhaustion, panic recovery, or chain-close bugs hit the hot path for all targets, not just agent-network ones. +> **Backward-compat impact:** Additive at the proxy. The `middleware` and `bodytap` packages are new (`proxy/internal/middleware/middleware.go:1`, `proxy/internal/middleware/bodytap/request.go:13`); existing proxy targets keep working until a chain is bound to them via `Manager.Rebuild`. + +This module is the **framework only** — no LLM/agent-network domain knowledge is required, since every example built into it is generic. + +## Module boundary + +This module is the **framework only**: slots, chains, registry, dispatcher, accumulator, body-tap, output filters. No middleware *implementation* lives here — those land in `proxy/internal/middleware/builtin/*` (covered in module 31). The package contract is: + +1. The proxy hands a `Manager` to its config-apply path. The synth pushes per-path `PathTargetBinding` lists (`proxy/internal/middleware/manager.go:26`) into `Manager.Rebuild`, which resolves each spec via the `Registry`/`Resolver` (`proxy/internal/middleware/registry.go:81-121`) and produces an immutable `Chain` keyed by `serviceID|pathID` (`proxy/internal/middleware/manager.go:410-412`). +2. The reverse-proxy handler captures the request body via `bodytap.CaptureRequest`, calls `Chain.RunRequest`, applies returned mutations (already filtered by `chain.applyMutations`), forwards to the upstream behind a `bodytap.CapturingResponseWriter`, then calls `Chain.RunResponse` and `Chain.RunTerminal`. +3. Middlewares are inert plugins that receive a deep-cloned `Input` and return an `Output` whose decision/mutations are clamped by the dispatcher's `filterOutput` (`proxy/internal/middleware/dispatcher.go:149-172`). + +Everything that crosses the framework boundary in either direction is value-typed and deep-copied — middlewares cannot mutate the live request directly, and the framework cannot inadvertently leak middleware-owned slices into the request hot path. + +## Files + +| Path | Role | +| ---- | ---- | +| `proxy/internal/middleware/middleware.go` | `Middleware` + `Factory` interfaces. | +| `proxy/internal/middleware/types.go` | `Slot`, `FailMode`, `Decision`, all limit constants, `Input`/`Output`/`Mutations`/`UpstreamRewrite`/`AuthHeader` value types. | +| `proxy/internal/middleware/spec.go` | Apply-time `Spec` (validated wire shape + runtime-injected fields) and `Clone`. | +| `proxy/internal/middleware/registry.go` | `Registry` (factory map, RWMutex) and `Resolver` (Spec → bound `Middleware`). | +| `proxy/internal/middleware/manager.go` | `Manager`, `chainTable` reverse index, `Rebuild`/`Invalidate*`, async chain close. | +| `proxy/internal/middleware/chain.go` | `Chain.RunRequest`/`RunResponse`/`RunTerminal`, mutation gating, `cloneInputFor`. | +| `proxy/internal/middleware/chain_test.go` | Metadata threading, LIFO response order, rewrite gating, UserGroups propagation, terminal accumulation. | +| `proxy/internal/middleware/dispatcher.go` | Timeout/panic recovery, fail-mode, error classification, `filterOutput`. | +| `proxy/internal/middleware/decision.go` | `RenderDenyResponse`, deny-code regex, status clamp. | +| `proxy/internal/middleware/headerpolicy.go` | Compile-in header denylist + `FilterHeaderMutations`. | +| `proxy/internal/middleware/bodypolicy.go` | `ValidateBodyReplace` / `ApplyBodyReplace` smuggling guards. | +| `proxy/internal/middleware/keys.go` | Metadata key namespace constants. | +| `proxy/internal/middleware/metadata.go` | `Accumulator` — allowlist, per-mw/per-request byte caps, redaction. | +| `proxy/internal/middleware/metrics.go` | OTel instrument bundle (`proxy.middleware.*`). | +| `proxy/internal/middleware/redaction.go` | `Scan` — PEM/JWT/AWS/bearer/Luhn-validated CC patterns. | +| `proxy/internal/middleware/bodytap/request.go` | Capture + replay reader, `Budget` semaphore, bypass reason codes. | +| `proxy/internal/middleware/bodytap/response.go` | `CapturingResponseWriter` (tee with `PassthroughWriter` for Flusher/Hijacker preservation). | + +## Slot model + +Three slots, declared per-middleware exactly once (`proxy/internal/middleware/types.go:27-41`): + +- **`SlotOnRequest`** (`Slot=1`) — runs **before** the upstream call, in registration order. May `DecisionDeny`, may emit `Mutations` (header add/remove, body replace, `UpstreamRewrite`) when both `Spec.CanMutate` and `Middleware.MutationsSupported()` are true. May emit metadata. Each middleware in the slot sees metadata that earlier ones in the same slot just emitted (`proxy/internal/middleware/chain.go:144-178`) — this is how the framework gives middlewares an intra-slot side channel without a global bag. +- **`SlotOnResponse`** (`Slot=2`) — runs **after** the upstream returns, in **reverse** registration order. Cannot deny (clamped in `dispatcher.filterOutput`, `proxy/internal/middleware/dispatcher.go:153-157`). May still mutate response headers in principle, but the current chain only forwards `RewriteUpstream` from on_request, so on_response mutations are observe-only in practice. Threads the same per-slot metadata view as on_request. +- **`SlotTerminal`** (`Slot=3`) — runs **after** every on_response middleware has emitted, in registration order. Sees the full accumulated bag plus prior terminal emissions (`chain.go:221-245`). Cannot deny, cannot mutate (`dispatcher.go:168-170`). Designed for sinks (access log, metrics push, audit emitter). + +Splitting a feature across slots (e.g. "parse on the way out, ship on terminal") is the explicit architectural choice — `types.go:7-15` and `types.go:22-25` make it clear no middleware participates in more than one slot. + +## Architecture & flow + +### Chain dispatch + +```mermaid +sequenceDiagram + autonumber + participant H as proxy HTTP handler + participant BT as bodytap.CaptureRequest + participant CH as Chain + participant DI as Dispatcher + participant MW as Middleware (per slot) + participant US as Upstream + participant CW as CapturingResponseWriter + + H->>BT: CaptureRequest(r, cfg, budget) + BT-->>H: body[], truncated, release() + H->>CH: RunRequest(ctx, r, Input, Accumulator) + loop on_request, registration order + CH->>CH: cloneInputFor(in, OnRequest) + CH->>DI: Invoke(ctx, spec, mw, call) + DI->>MW: mw.Invoke(callCtx, in) + MW-->>DI: Output{decision, metadata, mutations?} + DI->>DI: filterOutput (clamp deny, gate mutations) + DI-->>CH: filtered Output + CH->>CH: Accumulator.Emit (allowlist + caps + redact) + alt DecisionDeny + CH-->>H: denied, merged, rewrite + else allow + CH->>CH: applyMutations(r, m) and capture rewrite + end + end + CH-->>H: nil, merged, rewrite + H->>US: ProxyRequest (with rewrite/mutations applied) + US-->>CW: bytes (streamed, tee'd into cap-bounded buf) + CW-->>H: passthrough complete + H->>CH: RunResponse(ctx, Input{RespBody:CW.Body(),...}, acc) + loop on_response, REVERSE order (LIFO) + CH->>DI: Invoke (same wrappers) + end + H->>CH: RunTerminal(ctx, Input{Metadata:full bag}, acc) + H->>BT: release() + CW.Release() +``` + +### Body-tap mechanics (request + response) + +```mermaid +flowchart LR + subgraph req[Request capture — bodytap.CaptureRequest] + R0[r.Body] --> R1{cfg.MaxRequestBytes > 0?\nUpgrade absent?\nContent-Type allowed?\nCL <= cap?} + R1 -- no --> R2[bypass = reason\nbody = nil\nr.Body untouched] + R1 -- yes --> R3[Budget.Acquire(cap)] + R3 -- denied --> R4[bypass=BypassBudget] + R3 -- ok --> R5[io.LimitReader(r.Body, cap+1)\nio.ReadAll] + R5 --> R6{len > cap?} + R6 -- truncated --> R7[viewable = buf[:cap]\nr.Body = replayReadCloser{buf, tail}] + R6 -- whole --> R8[r.Body = NopCloser(bytes.Reader(buf))\nclose original] + R7 --> R9[(release captured\nbudget on req end)] + R8 --> R9 + end + + subgraph resp[Response capture — CapturingResponseWriter] + W0[client] -.-> CW[Write(p)] + CW --> P1[PassthroughWriter.Write(p)\n— bytes leave to client first] + P1 --> P2{!stopped?} + P2 -- yes --> P3{remaining = cap - buf.Len()} + P3 --> P4[buf.Write(p[:take])\nset truncated if take P5[silent drop into the tee\n(client write already done)] + end +``` + +The body-tap is the highest-leak-risk surface in this module; three details matter: + +1. **Request capture is "read-and-replay", not "read-and-forward".** `CaptureRequest` always swaps `r.Body` for either a `bytes.Reader` (whole body fit) or a `replayReadCloser` that replays the captured prefix then drains the remaining stream from the original body (`bodytap/request.go:178-201`). This means the **upstream still sees the full body even when the tap truncates**. The original `r.Body` is **not** closed in the truncated branch — `replayReadCloser.Close()` only closes the tail (`bodytap/request.go:199-201`), which is the same reader, so close once on request end is correct, but reviewers should confirm the upstream proxy always reads to EOF (otherwise the tail is leaked). +2. **Response capture is a write-through tee.** `CapturingResponseWriter.Write` forwards to the underlying writer **first** (`bodytap/response.go:116-117`), then tees into `buf` under its own mutex. Client never blocks on the tee. `Flusher`/`Hijacker` are preserved via the embedded `responsewriter.PassthroughWriter`. SSE/chunked streams flow through untouched; middlewares only see the bounded prefix. +3. **Budget is a single shared semaphore.** `Manager` constructs one `bodytap.Budget` at startup (`manager.go:138-144`, default `256 MiB` from `bodytap/request.go:39`). Every capture pre-acquires its full `MaxRequestBytes` / `MaxResponseBytes` from the budget regardless of actual body size; that prevents a flood of small captures from collectively exceeding the cap, but it also means a misconfigured `MaxRequestBytes = 1 MiB` with 256 concurrent requests already exhausts the default budget. Reviewers should sanity-check the operator-facing defaults that ship with synth-service. + +The framework explicitly aborts capture (and increments `proxy.middleware.capture_bypass_total`) before reading the first byte when `Upgrade`/`Connection: upgrade` is set (`bodytap/request.go:120-125`), when the content-type isn't in the allowlist (`bodytap/request.go:126-128`), or when the advertised `Content-Length` already exceeds the cap (`bodytap/request.go:131-133`). This is the right place to make sure WebSocket upgrades and large file uploads never reach the buffer. + +## Public contracts + +- **`Middleware` interface** (`middleware.go:14-36`): `ID()`, `Version()`, `Slot()`, `AcceptedContentTypes()`, `MetadataKeys()`, `MutationsSupported()`, `Invoke(ctx, *Input) (*Output, error)`, `Close()`. `MetadataKeys()` is the **closed set** the middleware is allowed to emit — the accumulator drops anything outside it (`metadata.go:71-75`). `Close` must be idempotent (called even when `Invoke` was never reached). +- **`Factory` interface** (`middleware.go:44-47`): `ID()`, `New(rawConfig []byte) (Middleware, error)`. `RawConfig` is opaque JSON bytes on the wire (`spec.go:6-12`); each factory owns its own typed config. +- **`Decision` type** (`types.go:59-69`): `Allow=0`, `Deny=1`, `Passthrough=2`. Default-zero is permissive — important because every middleware that omits `Decision` gets `Allow`. Dispatcher clamps `Deny` to `Passthrough` outside `SlotOnRequest` (`dispatcher.go:153-157`). +- **`Mutations`** (`types.go:196-201`): `HeadersAdd`/`HeadersRemove` (filtered through `headerpolicy.go`), `BodyReplace` (gated through `bodypolicy.go`), and `RewriteUpstream`. `RewriteUpstream` is **last-write-wins** within the on_request slot (`chain.go:170-172`, locked down by `TestChain_RunRequest_LatestRewriteWins`). +- **Metadata propagation keys** (`keys.go`): all keys live in a single file and follow `^[a-z][a-z0-9_-]*(\.[a-z0-9_-]*)+$` (`metadata.go:8`). Framework-injected error tagging uses `mw..error_kind` (`keys.go:81`) so operators can distinguish framework-emitted entries from middleware-emitted ones. + +## Invariants + +- **Per-request context isolation.** `cloneInputFor` deep-copies every mutable field (`Headers`, `RespHeaders`, `Metadata`, `Body`, `RespBody`, `UserGroups`, `UserGroupNames`) before each invocation (`chain.go:286-308`). A misbehaving middleware that mutates `in.Headers` only corrupts its own copy. +- **Body-tap bounded by capture limit.** Request side uses `io.LimitReader(r.Body, limit+1)` (`bodytap/request.go:152`) — the `+1` is how the code detects truncation (`bodytap/request.go:160`); the surfaced buffer is sliced back down to `limit`. Response side stops teeing once `buf.Len() >= cap` (`bodytap/response.go:121-133`). Neither side can grow the buffer past the configured cap. +- **Headers/body redaction order.** Accumulator runs `Scan(value)` **before** counting cost (`metadata.go:81-82`), so the byte budgets are computed against post-redaction sizes. `Scan` order is PEM → JWT → AWS key → bearer → Luhn-validated CC (`redaction.go:25-51`) — the comment block in `redaction.go:8-13` is explicit that this is best-effort, not DLP. +- **No middleware can starve the chain.** Every invocation runs inside `context.WithTimeout(ctx, clampTimeout(spec.Timeout))` in a separate goroutine (`dispatcher.go:51-94`), with the deadline race-`select`ed against the result channel. A blocked middleware fires the timeout path, gets fail-mode'd, and `IncError(kind=timeout)`. Timeouts are clamped to `[10ms, 5s]` (`types.go:80-86`, `dispatcher.go:174-185`). +- **Panic recovery.** `recover()` captures the panic, logs only the type + a 4 KiB stack prefix (no panic value — avoids leaking secrets the middleware was processing), and produces a `panicError` that flows through fail-mode (`dispatcher.go:64-76`). +- **Chain immutability + atomic swap.** `chainTable` is cloned on every `Rebuild`/`Invalidate*` and swapped via `atomic.Pointer` (`manager.go:44-69`, `manager.go:221-300`). Readers (`ChainFor`) are lock-free; writers serialise on `writeMu`. The retired chain is `Close`-d in a background goroutine bounded by `chainCloseTimeout = 2 * MaxTimeout` (`manager.go:21-22`, `manager.go:326-346`), so in-flight invocations finish on the old chain after the swap. + +## Things to scrutinize + +### Correctness + +- **Chain ordering deterministic from synth output?** `Manager.buildChain` iterates `b.Specs` in slice order and appends to `bound` (`manager.go:366-391`); `NewChain` then partitions by slot but **preserves slice order within each slot** (`chain.go:50-60`). So order on the wire = order observed at runtime. Synth must therefore emit specs in the intended execution order — there is no per-spec `Priority` field. Worth flagging. +- **Decision short-circuit semantics.** `RunRequest` returns immediately on `DecisionDeny` (`chain.go:164-167`) **with the metadata accumulated so far** plus the `denied.Metadata`. Callers that ignore `merged` on deny will lose framework-injected `mw..error_kind` entries. The proxy runtime is the only caller; confirm it always feeds `merged` into the access log on the deny path as well. +- **`UpstreamRewrite` `AuthHeader` bypass** (`types.go:218-235`). The `AuthHeader`/`StripHeaders` fields *intentionally* bypass the header denylist on the basis that the proxy itself rewrites auth. The denylist still blocks middleware-emitted `HeadersAdd: Authorization=...`. This is a delicate carve-out — review the runtime consumer to confirm only the trusted upstream-build path unpacks `AuthHeader`, never the generic `applyMutations` loop. +- **`replayReadCloser.Close` only closes the tail** (`bodytap/request.go:199-201`). The replay buffer doesn't own a resource, so this is correct, but it conflates "replay finished" with "underlying body closed". If a caller `Close()`s without reading to EOF, the original body is closed but the captured prefix is lost; harmless for the proxy path (upstream always reads to EOF) but worth a doc-comment. + +### Security + +- **Body-tap memory bounds.** Discussed above — bounded by `MaxBodyCapBytes = 1 MiB` per direction (`types.go:77`) and the shared `Budget` (default 256 MiB). The concerning case is the **deep-copy in `cloneInputFor`** (`chain.go:300-306`): every middleware invocation gets its **own copy** of `Body` and `RespBody`. A chain of N middlewares with a 1 MiB body allocates N MiB of transient bytes per request. With `MaxMiddlewaresPerChain = 16` (`types.go:103`) that's up to 16 MiB extra per in-flight request. Worth pricing into the budget model. +- **Header redaction completeness.** `denyHeaders` (`headerpolicy.go:5-17`) covers the auth/forwarding family and framing (`Content-Length`, `Transfer-Encoding`, `Trailer`). `denyHeaderPrefixes` covers `X-Authenticated-*`, `X-Forwarded-*`, `X-Remote-*`, `X-NetBird-*`. Notably absent: `Range`, `If-Match`/`If-None-Match` (mutation could cause cache poisoning), `Origin`/`Referer`. Not necessarily wrong, but worth a deliberate decision. +- **Metadata key collisions across middlewares.** The accumulator has no cross-middleware uniqueness check; two middlewares with the same key in their allowlist can both emit it, and both copies land in `merged` (`metadata.go:51-99`). Downstream consumers must tolerate duplicates. Worth documenting. +- **Deny rendering.** `RenderDenyResponse` only allows codes matching `^[a-z][a-z0-9._-]{0,63}$` (`decision.go:9`), redacts/truncates message + detail values, caps `Details` at 8 entries (`decision.go:42-50`), clamps status to `[400,499]\{401}` (`decision.go:65-73`). The deny body type is fixed; middlewares cannot inject arbitrary JSON. + +### Concurrency + +- **Per-request state vs shared state in factories.** Each `Factory.New` is called once per chain build; the returned `Middleware` instance is **shared across all requests** for that chain. `Invoke` must be reentrant. The framework does not enforce this — a buggy middleware that holds per-call state on the struct will silently race. Suggest a `// Invoke must be safe for concurrent use` doc on the interface. +- **`chainTable` clone-on-write** is correct, but `addChain`/`removeChain` mutate the *cloned* table before the swap (`manager.go:71-108`), and they're called under `writeMu`. Readers only ever see the post-swap pointer. Good. +- **`Chain.inflight` WaitGroup**. `Run*` does `Add(1)`/`Done()` (`chain.go:142-143`, `chain.go:194-195`, `chain.go:225-226`); `Close` waits on it bounded by ctx (`chain.go:75-85`). One concern: a *new* `RunRequest` can `Add(1)` *after* `Close` started waiting if the caller still holds a stale chain pointer. `WaitGroup` does not panic on this if the count was already > 0 at `Wait` time, but it does panic if `Add` happens after `Wait` returns and another `Wait` runs. `Close` is documented one-shot, so single-`Wait` is fine, but callers must drop the chain reference before calling `Close`. Worth a code comment near `Close`. +- **Goroutine leaks.** `Dispatcher.Invoke` spawns one goroutine per call and *always* writes to a buffered (cap=1) channel (`dispatcher.go:62-76`), so even if the timeout fires the goroutine completes its send and exits. No leak. +- **`closeChainsAsync`** detaches retired chains into a goroutine (`manager.go:326-346`). If `Manager` is never GC'd this is fine, but there's no shutdown hook to wait on outstanding closes. Reviewers should confirm the proxy shutdown path explicitly drains in-flight requests before tearing down `Manager`, or accept that the last chain-close round may be cut short on exit. + +### Performance + +- **Allocations per request.** `cloneInputFor` allocates new slices for `Headers`, `RespHeaders`, `Metadata`, `Body`, `RespBody`, `UserGroups`, `UserGroupNames` — once per middleware per request. For a typical 5-middleware chain on a 1 KiB body that's ~10 small slice allocs plus one `Body` copy each. Not a hot-path crisis, but `sync.Pool` for the per-call `Input` would be a natural follow-up. +- **Accumulator allocates a fresh `allowSet` per `Emit` call** (`metadata.go:55-58`). One per middleware per slot pass = up to 48 per request. Cheap, but worth noting. +- **Regex cost.** `Scan` runs five regex passes on every accepted metadata value (`redaction.go:25-51`). Bounded by `MaxMetadataValueBytes = 4 KiB` so worst case is small. + +### Observability + +- **Per-middleware metrics.** `proxy.middleware.requests_total{middleware,target_id,outcome}` (`metrics.go:34-41`), `duration_ms`, `invocations_total`, `errors_total{kind}`, `metadata_rejected_total{reason}`, `header_mutation_blocked_total{header}`, `capture_bypass_total{reason}`. Comprehensive surface; operators can alert on `errors_total{kind=panic}` and `errors_total{kind=timeout}` separately. **Latency histogram is in milliseconds with default OTel buckets** — for a 10ms–5s timeout range default buckets cover OK, but a custom bucket set centred on 1–500ms would resolve the agent-network response-parser tail better. +- **Decision logs.** Panic logs (`dispatcher.go:69`) include `request_id`, type, and stack but not the panic value (safe). `Chain.Close` logs middleware-close errors at debug (`chain.go:91`). `applyMutations` logs body-replace rejections at warn (`chain.go:278`). No log on the deny path itself — by design, since the access-log terminal middleware is expected to record outcomes. + +## Test coverage + +| Test file | Locks down | +| --------- | ---------- | +| `proxy/internal/middleware/chain_test.go:77` | `RunRequest` threads metadata across on_request middlewares (regression for the "later mw can't see earlier mw's emissions" bug). | +| `chain_test.go:110` | `RunResponse` reverse-order threading. | +| `chain_test.go:142` | `cost_meter`-shaped scenario: response_parser registered after cost_meter still emits *before* cost_meter sees the bag (guards the `cost.skipped=missing_tokens` regression). | +| `chain_test.go:178` | `UpstreamRewrite` last-write-wins. | +| `chain_test.go:206` | No middleware emits → nil rewrite. | +| `chain_test.go:224` | Rewrite filtered when `CanMutate=false`. | +| `chain_test.go:245` | `Input.UserGroups` propagates verbatim through `cloneInputFor`. | +| `chain_test.go:304` | Terminal middlewares see the full accumulated bag + prior terminal emissions. | + +**Gaps** worth raising with the author: +- No direct test for `Dispatcher.Invoke` timeout / panic / fail-mode behaviour at the framework level (covered indirectly by built-in tests, but a unit test pinning `errors_total{kind=...}` labels would be cheap insurance). +- No test for `bodytap.CaptureRequest` truncated replay (the upstream-sees-full-body invariant is exactly the kind of thing a regression would silently break). +- No test for `Budget` exhaustion behaviour under concurrency. +- No test for `Manager.InvalidateMiddleware` + `LiveServiceCheck` race (the auth-revocation race the comment at `manager.go:33-38` calls out is the load-bearing reason for `LiveServiceCheck`). + +## Known limitations / explicit non-goals + +- **No middleware-to-middleware RPC.** Side-channel is metadata only. +- **No streaming body inspection.** Middlewares see a bounded prefix; SSE / chunked parsing happens against that prefix in the response middleware. +- **No per-spec priority.** Order is registration order in the spec slice. +- **No retry / circuit-breaker** on middleware errors. Fail-mode is binary (open/closed) and per-spec. +- **Mutations cannot rewrite the request URL path or query** — only `RewriteUpstream` can change scheme/host (+ optional path replacement, see `types.go:218-235`). +- **Redaction is best-effort.** Explicitly documented in `redaction.go:8-13`. Not a DLP solution. + +## Cross-references + +- Upstream wire shape: [../modules/10-shared-api.md](10-shared-api.md) (Spec/RawConfig encoding from management). +- Built-in middlewares using this framework: [../modules/31-proxy-middleware-builtin.md](31-proxy-middleware-builtin.md). +- Runtime wiring (where `Manager`, `Chain`, and `bodytap` are consumed by the HTTP handler): [../modules/33-proxy-runtime.md](33-proxy-runtime.md). +- End-to-end request flow including capture + chain dispatch: [../01-end-to-end-flows.md](../01-end-to-end-flows.md). +- Top-level architecture: [../00-overview.md](../00-overview.md). diff --git a/docs/agent-networks/modules/31-proxy-middleware-builtin.md b/docs/agent-networks/modules/31-proxy-middleware-builtin.md new file mode 100644 index 000000000..ad56feb77 --- /dev/null +++ b/docs/agent-networks/modules/31-proxy-middleware-builtin.md @@ -0,0 +1,402 @@ +# proxy/middleware-builtin — the LLM chain + +The registry-mounted middleware set the proxy executes on every agent-network +LLM request. The two highest-blast-radius areas are the **capture-pointer +semantics** and the **limit_check ⇒ limit_record** record-once invariant. + +Sibling module: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — the SDK +adapters + pricing table and cost formula this chain delegates to. + +--- + +## Module boundary + +This module is the registry-mounted middleware set the proxy executes on +every agent-network LLM request. Each sub-package registers itself via +`init()` +([builtin.go:32–34](../../../proxy/internal/middleware/builtin/builtin.go)); +the proxy server anonymous-imports the set +([all_test.go:11–19](../../../proxy/internal/middleware/builtin/all_test.go)) +so the registry is populated at boot. The chain is wired by the management +synthesiser and executed by the framework +(`proxy/internal/middleware/{chain,dispatcher,accumulator}.go` — both out +of scope). Everything here reads from / writes to one envelope: the +`middleware.KV` metadata bag plus `middleware.Mutations` for header/body +rewrites. + +## The 8 middlewares + +| Name | Slot | Inputs (metadata read) | Outputs (metadata written) | Side effects | +|---|---|---|---|---| +| `llm_request_parser` | OnRequest | `Input.{URL,Body,BodyTruncated}` | `llm.{provider,model,stream,request_prompt_raw,capture_truncated}` | none | +| `llm_router` | OnRequest | `llm.model`, `Input.{URL,UserGroups}` | `llm.{resolved_provider_id,authorising_groups}`, `llm_policy.{decision,reason}` | upstream rewrite + auth strip/inject | +| `llm_limit_check` | OnRequest | `llm.{resolved_provider_id,model}`, `Input.{AccountID,UserID,UserGroups}` | `llm.{selected_policy_id,attribution_group_id,attribution_window_seconds}`, `llm_policy.{decision,reason}` | gRPC `CheckLLMPolicyLimits` | +| `llm_identity_inject` | OnRequest | `llm.{resolved_provider_id,authorising_groups}`, `Input.{UserEmail,UserID,UserGroups,UserGroupNames}` | none | header strip/inject + optional body rewrite | +| `llm_guardrail` | OnRequest | `llm.{model,request_prompt_raw}` | `llm_policy.{decision,reason}`, `llm.request_prompt` | none (model allowlist deny) | +| `llm_response_parser` | OnResponse | `llm.provider`, `Input.{RespHeaders,RespBody,Status}` | `llm.{input,output,total,cached_input,cache_creation}_tokens`, `llm.response_completion` | none | +| `cost_meter` | OnResponse | `llm.{provider,model,resolved_provider_id}`, token buckets | `cost.usd_{input,cached_input,cache_creation,output,total,cache}` or `cost.skipped` | none (in-memory pricing lookup) | +| `llm_limit_record` | OnResponse | `llm.{attribution_group_id,attribution_window_seconds,input_tokens,output_tokens}`, `cost.usd_total` | none | gRPC `RecordLLMUsage` | + +[all_test.go:26–40](../../../proxy/internal/middleware/builtin/all_test.go) +locks the ID set; adding or removing one is a conscious extension. + +## Files + +| File | LOC | Notes | +|---|---:|---| +| `builtin.go` | 90 | Registry + `FactoryContext` (ctx, meter, logger, mgmt client) | +| `all_test.go` | 41 | Locks the 8-ID registry surface | +| `agentnetwork_chain_integration_test.go` | 319 | Live sqlite + real gRPC bufconn; gate→recorder wire path | +| `llm_request_parser/*` | 162 / 66 / 356 | Provider detection, body parse, prompt extraction with capture-pointer gating | +| `llm_router/*` | 385 / 84 / 586 | Three-pass route selection (model → groups → path-prefix) | +| `llm_limit_check/*` | 196 / 38 / 182 | Pre-flight `CheckLLMPolicyLimits` (2s, fail-open) | +| `llm_identity_inject/*` | 440 / 108 / 666 | HeaderPair (LiteLLM) + JSONMetadata (Portkey) + ExtraHeaders | +| `llm_guardrail/*` | 176 / 82 / 75 / 219 / 217 | Model allowlist + optional prompt capture with PII redaction | +| `llm_response_parser/*` | 258 / 222 / 43 / 433 / 169 / 111 | Buffered + SSE accumulation; AWS event-stream accumulator (`streaming_bedrock.go`) for Bedrock; capture-pointer gates completion emit | +| `cost_meter/*` | 236 / 98 / 586 | Token → USD via `proxy/internal/llm/pricing`; both pricing tiers arrive in the middleware config | +| `llm_limit_record/*` | 144 / 35 / 191 | Post-flight `RecordLLMUsage` (5s, debug-on-error) | + +## Per-middleware + +### llm_request_parser + +Detects the LLM provider via `llm.DetectParser` (URL sniff) or by name via +`llm.ParserByName` when synthesiser stamps `provider_id` +([middleware.go:96–99](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)). +**Path-routed providers short-circuit first:** `parseVertexPath` and +`parseBedrockPath` ([middleware.go:85–94](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)) +pull the model + vendor out of the URL before parser selection runs — Vertex +from `/v1/projects/.../publishers/{pub}/models/{model}:{action}` (publisher → +vendor via `vertexPublisherVendor`), Bedrock from `/model/{id}/{action}` with +`normalizeBedrockModel` stripping the region prefix + version suffix. See +[50-path-routed-providers.md](./50-path-routed-providers.md) for the full path +grammar. For body-routed providers it decodes the body into `RequestFacts` +(model + stream) and extracts the prompt. On +`capture_prompt=true` (or absent — see capture-pointer semantics below) the +prompt is run through `llm_guardrail.RedactPII` when `redact_pii=true` and +truncated rune-safely to 3500 bytes +([middleware.go:109–122](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)). +**Key invariant:** redaction is parser-side, not guardrail-side — access-log +reads `llm.request_prompt_raw` directly. + +### llm_router + +Three-pass route selection in `matchRoute` +([middleware.go:241–300](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)): +filter by `Models` claim → vendor-pin (a vendor-tagged request never crosses to +another vendor's route) → filter by `AllowedGroupIDs` intersection → model +precedence over path → tie-break by longest `UpstreamPath` prefix match. +Model-miss returns `llm_policy.model_not_routable`; known-but-unauthorised +returns `llm_policy.no_authorised_provider`. **Key invariant:** auth-header +strip+inject rides on `UpstreamRewrite.{StripHeaders,AuthHeader}` +([middleware.go:606–646](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +— NOT `HeadersAdd/HeadersRemove` — because the framework's mutation gate +blocks `Authorization` on the generic header path. + +**Path-routed providers route before the model table.** `Invoke` checks +`isVertexPath` / `isBedrockPath` +([middleware.go:138–216](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +ahead of the model lookup, so a path-carried model can't be claimed by a +same-vendor body-routed provider. `matchPathRoute` enforces the route's `Models` +allowlist (empty = catch-all) even though the model came from the URL. +Two path-only behaviours: +- **Vertex unmeterable publisher** — when `llm_request_parser` emits no + `llm.provider` (e.g. Gemini/`google`), the router denies with + `llm_policy.unmeterable_publisher` (403) rather than forward it uncounted. +- **GCP token minting** — when the route carries `GCPServiceAccountKeyB64` + (set from a `keyfile::` api_key), `gcpBearer` mints + caches a short-lived + OAuth2 token per request instead of injecting a static value; a bad key or + unreachable token endpoint denies with `llm_policy.upstream_auth_failed` + (502). Bedrock uses its static bearer token directly (no minting). +- **`/bedrock` prefix** — an optional `/bedrock` gateway-namespace prefix is + accepted and stripped via `RewriteUpstream.StripPathPrefix` so the native + `/model/...` path reaches the upstream. + +Full treatment in [50-path-routed-providers.md](./50-path-routed-providers.md). + +### llm_limit_check + +Pre-flight gate. Reads `llm.resolved_provider_id`, calls +`CheckLLMPolicyLimits` with a 2s context timeout +([middleware.go:24, 97–106](../../../proxy/internal/middleware/builtin/llm_limit_check/middleware.go)), +on allow stamps `llm.selected_policy_id`, `llm.attribution_group_id`, +`llm.attribution_window_seconds`. **Key invariant:** fail-open. Nil +`MgmtClient`, empty provider id, or RPC error returns `allowNoAttribution()` +— management outage doesn't take down every LLM request. Operators audit via +the access-log; a future flag may switch this to fail-closed. + +### llm_identity_inject + +Dispatches per-rule between LiteLLM-shaped `HeaderPair` +([middleware.go:169](../../../proxy/internal/middleware/builtin/llm_identity_inject/middleware.go)) +and Portkey-shaped `JSONMetadata` +([middleware.go:292](../../../proxy/internal/middleware/builtin/llm_identity_inject/middleware.go)). +Identity is the peer's email (or `UserID` fallback); tags are the +**authorising-groups intersection** emitted by `llm_router`, not the full +`UserGroups` — a peer in 5 groups authorised under 1 only tags as that 1. +**Anti-spoof:** every `HeadersAdd` is preceded by a `HeadersRemove` of the +same name; the framework runs `Remove` before `Add` so client-supplied +identity never reaches the upstream. Body-level inject (`tags_in_body`, +`end_user_id_in_body`) is skipped on empty / truncated / non-JSON bodies so +header attribution stays intact. + +### llm_guardrail + +Model allowlist deny + optional prompt-capture-with-redaction. Allowlist +match is case-insensitive via `normaliseModel`; empty allowlist disables the +check. Prompt capture reads `llm.request_prompt_raw` and emits +`llm.request_prompt` only when `prompt_capture.enabled` +([middleware.go:149–165](../../../proxy/internal/middleware/builtin/llm_guardrail/middleware.go)). +**Key invariant:** `RedactPII` is the exported function the parsers call — +single PII contract across all three keys. + +### llm_response_parser + +Buffered and SSE paths share one `Invoke` +([middleware.go:102–127](../../../proxy/internal/middleware/builtin/llm_response_parser/middleware.go)): +content-type sniffing dispatches to `invokeBuffered` (JSON, status<400) or +`invokeStreaming` (text/event-stream, partial bodies tolerated). Streaming +delegates to `accumulateStream` +([streaming.go:21–30](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)) +using `llm.NewScanner`. A third path, `accumulateBedrockStream` +([streaming_bedrock.go](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go)), +decodes the AWS binary event-stream (`application/vnd.amazon.eventstream`) +returned by Bedrock's `-stream` actions — InvokeModel `chunk` frames wrap a +base64 Anthropic event, Converse frames carry text + a trailing usage block. +Cached / cache-creation buckets emit only when non-zero, preserving the existing +token schema. + +### cost_meter + +Reads `llm.provider` + `llm.model` + token buckets, looks up the per-1k rates, +and emits the full `cost.usd_*` breakdown (four per-bucket values plus the +`_total` and `_cache` aggregates) or a closed-set `cost.skipped` reason +(`missing_provider/model/tokens`, `unparseable_tokens`, `zero_tokens`, +`unknown_model`). + +**Management owns pricing.** The proxy carries no embedded price list: the whole +table arrives in this middleware's `ConfigJSON` as +`{pricing: {defaults, providers}}`, synthesized by management from the catalog +plus the operator's stored per-provider prices +([factory.go:13–34](../../../proxy/internal/middleware/builtin/cost_meter/factory.go)). +Both tiers are validated by `pricing.NewTable` / `pricing.NewEntries` at +construction, so a non-finite or negative rate fails the chain build. A price +change is an ordinary mapping push — the chain rebuild yields a fresh instance +over a fresh immutable table, so there is no data dir, no pricing file, no +reload goroutine, and nothing to invalidate. + +**Two-tier lookup** +([middleware.go:165–183](../../../proxy/internal/middleware/builtin/cost_meter/middleware.go)): + +1. **Per-provider-record** — the operator's stored price for the route that + actually served the request, keyed by the `llm.resolved_provider_id` that + `llm_router` stamped on the allow path, then by normalized model id. Entries + arrive fully materialized (management folds default cache rates in at synth + time), so there is no merging here. Absent metadata — no router in the chain + — skips this tier. +2. **Surface defaults** — the catalog-derived table keyed by `llm.provider` + (`openai`/`anthropic`/`bedrock`). This is also what prices gateway-style + providers, which enumerate no models and therefore get no per-record entry. + +**Backward compatibility:** a config with no `pricing` block means management +predates config-delivered pricing. The factory logs one warning at build time +and the instance records `cost.skipped=unknown_model` ($0) for every request +rather than falling back to a stale built-in price list +([factory.go:55–60](../../../proxy/internal/middleware/builtin/cost_meter/factory.go)). + +**Key invariant:** the provider-shape switch lives in `pricing.EntryCosts` +(sibling doc) and is selected by the **surface**, not by which tier the entry +came from — `cost_meter` stays provider-agnostic, and a per-record override on +an Anthropic route still bills its cache buckets additively. + +### llm_limit_record + +Post-flight write. Always returns `DecisionAllow`; response has already been +served so RPC errors mustn't surface (logged at `Debugf`). Skip-on-no-signal +at line 81 (zero tokens + zero cost). **Key invariant:** the +skip-on-missing-attribution guard at line 98 is a safety net independent of +the framework's deny short-circuit — if the gate denied and the framework +still runs the recorder, the recorder skips on absent +`UserID`+`groupID`+`UserGroups` and no phantom counter materialises. + +## Full-chain diagram (canonical order) + +```mermaid +flowchart TD + A[HTTP request] --> B[llm_request_parser
    OnRequest] + B -->|llm.provider, llm.model,
    llm.stream, llm.request_prompt_raw| C[llm_router
    OnRequest] + C -->|llm.resolved_provider_id,
    llm.authorising_groups,
    upstream rewrite + auth| D[llm_limit_check
    OnRequest] + D -->|deny path| Z1[403 llm_policy.*] + D -->|allow + llm.selected_policy_id,
    llm.attribution_group_id,
    llm.attribution_window_seconds| E[llm_identity_inject
    OnRequest] + E -->|header strip+inject
    + optional body rewrite| F[llm_guardrail
    OnRequest] + F -->|deny: model_blocked| Z2[403 llm_policy.model_blocked] + F -->|allow + llm.request_prompt| G[upstream LLM call] + G --> H[llm_response_parser
    OnResponse] + H -->|llm.{input,output,total,cached_input,cache_creation}_tokens,
    llm.response_completion| I[cost_meter
    OnResponse] + I -->|cost.usd_total or cost.skipped| J[llm_limit_record
    OnResponse] + J --> K[response to client] +``` + +## limit_check ⇒ limit_record record-once invariant + +```mermaid +sequenceDiagram + participant LC as llm_limit_check + participant M as management gRPC + participant U as upstream LLM + participant LR as llm_limit_record + participant DB as sqlite consumption table + + LC->>M: CheckLLMPolicyLimits (2s) + alt allow + M-->>LC: selected_policy_id, attribution_group_id, window_s + LC->>U: stamps attribution metadata + U-->>LR: response + tokens (via llm_response_parser + cost_meter) + LR->>M: RecordLLMUsage (5s, debug-on-error) + M->>DB: increment (user, group, window) row + else deny + M-->>LC: llm_policy.token_cap_exceeded + Note over LR: framework short-circuits; even if invoked,
    recorder skips on absent UserID+groupID+UserGroups + else mgmt nil / rpc error + LC-->>LC: allowNoAttribution() — fail open + Note over LR: no window_s ⇒ recorder books only account-level
    budget rules (which run independently) + end +``` + +The integration test +[agentnetwork_chain_integration_test.go](../../../proxy/internal/middleware/builtin/agentnetwork_chain_integration_test.go) +exercises all three branches against a real sqlite store + bufconn gRPC — +no mocks. Tests: `TestChain_AllowPath_StampsAttributionAndRecordsCounter` +(line 130), `TestChain_DenyPath_GateRejectsAndNoConsumptionWritten` (line +207), `TestChain_CapExhaustTransition` (line 265). + +## Public contracts (per-middleware JSON config) + +| Middleware | Config shape | +|---|---| +| `llm_request_parser` | `{provider_id?, redact_pii?, capture_prompt?: *bool}` ([factory.go:19–37](../../../proxy/internal/middleware/builtin/llm_request_parser/factory.go)) | +| `llm_router` | `{providers: [{id, models, upstream_scheme, upstream_host, upstream_path?, auth_header_name, auth_header_value, allowed_group_ids}]}` | +| `llm_limit_check` | `{}` — pulls `MgmtClient` from `FactoryContext` | +| `llm_identity_inject` | `{providers: [{provider_id, header_pair?|json_metadata?, extra_headers?}]}` | +| `llm_guardrail` | `{provider_allowlists: {providerID: []string}, prompt_capture: {enabled, redact_pii}}` — allowlist keyed by resolved provider id; a provider absent from the map is unrestricted (fail-closed backstop; authoritative per-policy/group check is management's `CheckLLMPolicyLimits`) | +| `llm_response_parser` | `{redact_pii?, capture_completion?: *bool}` | +| `cost_meter` | `{pricing: {defaults: {surface: {model: rates}}, providers: {providerRecordID: {model: rates}}}}` — rates are `{input_per_1k, output_per_1k, cached_input_per_1k?, cache_read_per_1k?, cache_creation_per_1k?}`. A missing `pricing` key means "management predates config-delivered pricing": every request records `cost.skipped=unknown_model` | +| `llm_limit_record` | `{}` — same pattern as `llm_limit_check` | + +All factories accept empty / null / `{}` / whitespace as zero-value config; +only structurally invalid JSON is rejected so misconfig surfaces at chain +build time. `cost_meter` adds a semantic check on top of that: a `pricing` +block carrying a negative or non-finite rate fails the build too, rather than +mispricing live traffic. + +## Invariants + +1. **limit_check ↔ limit_record paired.** They MUST appear together. Gate + stamps attribution metadata on the request leg; recorder reads it on the + response leg. If a chain contains only the recorder, the + skip-on-missing-attribution guard at + [llm_limit_record/middleware.go:81–87, 98–103](../../../proxy/internal/middleware/builtin/llm_limit_record/middleware.go) + keeps counters consistent but no enforcement runs. Only-gate means + counters never tick and headroom appears infinite. + +2. **`capture_prompt` / `capture_completion` pointer semantics.** Both are + `*bool`. `nil` = "preserve legacy emit" (back-compat default for + non-agent-network callers and pre-toggle tests). `false` = suppress the + key entirely (access-log row carries zero prompt / completion content). + `true` = emit. The synthesiser sets the pointer explicitly to the + account's `EnablePromptCollection` toggle. The handling lives + in [llm_request_parser/factory.go:55–61](../../../proxy/internal/middleware/builtin/llm_request_parser/factory.go) + and the symmetric [llm_response_parser/middleware.go:62–68](../../../proxy/internal/middleware/builtin/llm_response_parser/middleware.go); + a missing pointer must not be treated as `false` (that would suppress + capture for legacy non-agent-network callers). + `redact_pii` is an orthogonal `bool` controlling **form** of emitted + content, not whether it's emitted. + +3. **`redact_pii` is parser-side.** Both parsers import + `llm_guardrail.RedactPII` and run it BEFORE stamping the metadata bag. + Load-bearing because the access-log sink reads `llm.request_prompt_raw` + and `llm.response_completion` directly — by the time `llm_guardrail` + runs its own pass on `llm.request_prompt`, the raw key has already been + stamped. Tests: `TestInvoke_RedactPii_RedactsBeforeEmittingRawPrompt`, + `TestInvoke_RedactPii_RedactsCompletionBeforeEmit`. + +4. **Metadata allowlist enforcement.** Every middleware declares + `MetadataKeys()`. The framework accumulator drops any KV outside that + allowlist. When adding a new key, also extend the docstring in + `middleware/keys.go`. + +5. **Closed deny-code set.** All deny paths emit one of: + `llm_policy.model_not_routable`, `llm_policy.no_authorised_provider`, + `llm_policy.model_blocked`, `llm_policy.token_cap_exceeded`, + `llm_policy.unmeterable_publisher` (path-routed Vertex publisher with no + parser → 403), `llm_policy.upstream_auth_failed` (GCP token mint failure → + 502), or the management-supplied code on `llm_limit_check`. These surface + verbatim; arbitrary middleware text never reaches the wire. + +## Things to scrutinise + +**Correctness.** `llm_router` model match treats an empty `Models` slice as +"claim every model" +([middleware.go:238–248](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +for gateway-style providers — confirm no real provider record ships with an +empty `Models` by accident. Path-prefix tie-break falls back to declaration +order when no candidate prefix-matches, so the synthesiser must emit a +deterministic order. `llm_limit_record` discards `strconv.ParseInt` errors +([middleware.go:78–80](../../../proxy/internal/middleware/builtin/llm_limit_record/middleware.go)) +— relies on `llm_response_parser` always emitting parseable values; spot-check +the streaming partial path on truncated bodies. + +**Security.** Auth headers must NEVER appear on `Mutations.HeadersAdd/Remove` +for the router — a direct headers path would bypass the framework gate. The +capture-pointer handling is the kind of place a bug ships PII to logs +silently; every synthesiser config path must set the pointer explicitly. +`llm_identity_inject` body inject silently skips on a +non-object `metadata` field +([middleware.go:262–270](../../../proxy/internal/middleware/builtin/llm_identity_inject/middleware.go)) +— header path still attributes, but body-level tag-budget enforcement +doesn't run for that request. + +**Concurrency.** `cost_meter`'s two pricing tables are built once from the +middleware config and never mutated, so the lookup path needs no lock or atomic +swap — a price change replaces the whole instance. Every middleware is +otherwise a stateless value receiver. Integration test uses real bufconn gRPC — +race detector is the meaningful bar. + +**Perf.** Hot path is `lookupKV` linear scan over <10 KVs; `cost_meter.Cost` +is O(1); SSE accumulation is single-pass. No map allocation per call. + +**Observability.** Every deny stamps `llm_policy.decision=deny` and a +matching `llm_policy.reason` — access-log can pivot on either. +`llm_limit_record` only logs at `Debugf` on RPC failure +([middleware.go:125–130](../../../proxy/internal/middleware/builtin/llm_limit_record/middleware.go)); +operators need an alternate signal (metric on `RecordLLMUsage` failures) for +counter accuracy. + +## Test coverage + +| File | Tests | Notes | +|---|---:|---| +| `all_test.go` | 1 | Registry surface lock | +| `agentnetwork_chain_integration_test.go` | 3 | Allow/deny/cap-exhaust vs live sqlite + bufconn gRPC | +| `llm_request_parser/middleware_test.go` | 18 | `provider_id` bypass, redaction, capture-pointer, rune-safe truncation | +| `llm_router/middleware_test.go` | 19 | Three-pass match, deny codes, path-prefix tie-break, header strip+inject | +| `llm_limit_check/middleware_test.go` | 6 | Allow/deny, fail-open on nil mgmt / RPC error, attribution stamping | +| `llm_identity_inject/middleware_test.go` | 28 | HeaderPair, JSONMetadata, ExtraHeaders, body inject, anti-spoof | +| `llm_guardrail/middleware_test.go` | 15 | Allowlist case-insensitivity, prompt capture toggle, deny shape | +| `llm_guardrail/redact_test.go` | 15 | Email, SSN, phone (E.164 + NA), bearer, IPv4; fixture-driven | +| `llm_response_parser/middleware_test.go` | 18 | Buffered OAI+Anthro, capture-pointer, redact, truncation | +| `llm_response_parser/streaming_test.go` | 7 | OAI usage frame, Anthro message_delta, truncated body best-effort | +| `cost_meter/middleware_test.go` | 22 | Each skip reason, provider-shape formulas, config-delivered defaults, per-record-beats-defaults + miss-falls-back, per-record uses surface formula, nil-pricing skips everything, invalid-rate rejection | +| `llm_limit_record/middleware_test.go` | 7 | Skip-on-no-signal, skip-on-missing-attribution, RPC failure swallowed | + +## Cross-references + +- Sibling: [32-proxy-llm-parsers.md](./32-proxy-llm-parsers.md) — SDK adapters + + SSE framer + pricing table and cost formula. +- Path-routed providers (Vertex AI + Bedrock), `keyfile::` credential, GCP + token minting, `/bedrock` prefix: + [50-path-routed-providers.md](./50-path-routed-providers.md). +- Upstream config: `management/server/agentnetwork/synthesizer` (out of scope). +- Framework: `proxy/internal/middleware/{chain,dispatcher,accumulator,registry}.go`. +- Metadata key registry: `proxy/internal/middleware/keys.go`. +- gRPC surface: `proto.ProxyServiceClient.{CheckLLMPolicyLimits,RecordLLMUsage}`. diff --git a/docs/agent-networks/modules/32-proxy-llm-parsers.md b/docs/agent-networks/modules/32-proxy-llm-parsers.md new file mode 100644 index 000000000..52faeaac1 --- /dev/null +++ b/docs/agent-networks/modules/32-proxy-llm-parsers.md @@ -0,0 +1,434 @@ +# proxy/llm-parsers — SDK adapters + pricing + SSE + +The runtime-agnostic LLM library: the OpenAI Responses API (`/v1/responses`) +and the older Chat Completions API (`/v1/chat/completions`), the Anthropic +Messages API (`/v1/messages`), the SSE wire format (`event:` / `data:` lines, +`\n\n` framing, CRLF tolerance), and per-provider token accounting (OpenAI's +cached-prompt **subset** vs Anthropic's cache_read **additive** model). The +pricing table's per-provider cost formula is the highest-leverage place a +small bug would silently mis-bill operators. + +Sibling module: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md) +— the 8 middlewares that consume this package's parsers + pricing table. + +--- + +## Module boundary + +`proxy/internal/llm` is the runtime-agnostic LLM library shared by every +middleware that needs to understand provider-specific shapes. Zero +proxy-framework dependencies: + +- `parser.go` — `Parser` interface, `Provider` enum, public factories + (`Parsers`, `DetectParser`, `ParserByName`). +- `openai.go` / `anthropic.go` / `bedrock.go` — per-provider `Parser` impls. +- `sse.go` — SSE scanner (`Scanner`, `Event`, `NewScanner`). +- `errors.go` — sentinels callers branch on with `errors.Is`. +- `pricing/` — immutable pricing table + the per-surface cost formula. The + rates themselves come from management inside `cost_meter`'s middleware + config; this package holds no price list and reads no files. +- `fixtures/` — captured request/response/stream bodies the tests replay. + +The package carries zero proxy-framework dependencies so the same parsers can +be reused later by a WASM adapter +([parser.go:1–6](../../../proxy/internal/llm/parser.go)). + +## Files + +| File | LOC | Notes | +|---|---:|---| +| `parser.go` | 104 | Interface + factories + `Provider{Unknown,OpenAI,Anthropic}` enum | +| `openai.go` | 347 | Chat Completions + Completions + Responses API; cached_tokens subset | +| `openai_test.go` | 222 | 11 tests; fixture replay + cached/Responses-API matrix | +| `anthropic.go` | 172 | Messages + legacy `/v1/complete`; cache_read + cache_creation additive | +| `anthropic_test.go` | 154 | 7 tests including streaming-extraction-skipped contract | +| `bedrock.go` | 190 | AWS Bedrock InvokeModel (snake_case) + Converse (camelCase) response shapes; model lives in URL path | +| `bedrock_test.go` | — | InvokeModel + Converse usage shapes; AWS event-stream content-type → `ErrStreamingUnsupported` on buffered `ParseResponse` | +| `sse.go` | 117 | `bufio`-backed scanner; CRLF normalised; trailing-event handling | +| `sse_test.go` | 175 | 12 tests; fixture replay + multiline + size limits | +| `parser_test.go` | 53 | `Parsers()`, `DetectParser`, provider enum values | +| `errors.go` | 31 | 6 sentinels: `Err{Unknown,Unsupported}Provider/Model`, `Err{NotLLM,Malformed}Response`, `ErrStreamingUnsupported`, `ErrMalformedRequest` | +| `pricing/pricing.go` | 234 | `Table`, `Entry`, `EntryJSON`, `Costs`; `NewTable`/`NewEntries` validation + `EntryCosts` formula. No I/O, no reload, no embedded rates | +| `pricing/pricing_test.go` | 177 | 10 tests — provider-shape formulas, cached clamp, rate fallback, nil-safety, rate validation | +| `fixtures/*` | 21–59 | OAI chat/responses/stream + Anthro messages/stream | + +## Request body → parser dispatch + +```mermaid +flowchart TD + A[HTTP request
    URL + JSON body] --> B{ParserByName?
    provider_id config set} + B -- yes --> P[matched Parser] + B -- no --> C[DetectParser] + C --> D{loop Parsers
    OpenAIParser, AnthropicParser} + D -- DetectFromURL match --> P + D -- no match --> X[ok=false
    middleware skips] + P --> E[ParseRequest body] + E -->|err: ErrMalformedRequest| Y[middleware emits provider only] + E --> F[RequestFacts
    model + stream] + P --> G[ExtractPrompt body] + G --> H[joinMessages
    extractContentParts
    decodeStringOrJoin] + H --> I[prompt text
    or empty] + F --> J[stamps llm.model + llm.stream] + I --> K[stamps llm.request_prompt_raw
    subject to capture_prompt gate] +``` + +OpenAI's URL hints +([openai.go:27–33](../../../proxy/internal/llm/openai.go)) include +both `/v1/chat/completions` and the bare `/chat/completions` — the latter +covers Cloudflare AI Gateway, which rewrites the canonical version segment. +Anthropic's hints are `/v1/messages` and `/v1/complete` +([anthropic.go:14–17](../../../proxy/internal/llm/anthropic.go)). +Both implementations use case-insensitive substring matching so a proxy prefix +strip / rewrite doesn't defeat detection. + +`ParserByName` ([parser.go:93–103](../../../proxy/internal/llm/parser.go)) +is the **agent-network bypass**: the synthesiser knows which parser to use +because it built the synth service from the catalog, so it stamps +`provider_id` on the parser config and the middleware skips URL sniffing +entirely. This is what makes the same parser set work whether the request +flows to OpenAI direct, to LiteLLM, to Portkey, or to any gateway with a +non-canonical URL shape. + +**Path-routed providers (Vertex AI, Bedrock) bypass both `ParserByName` and +`DetectParser`.** The model and the parser surface live in the URL path, so the +request middleware extracts them directly (`parseVertexPath` / +`parseBedrockPath`) before the parser-selection step. For Vertex the publisher +segment picks the parser (`anthropic` → Anthropic parser; `google`/Gemini → +none, request denied as unmeterable). For Bedrock the dedicated `BedrockParser` +handles the response. Full treatment in +[50-path-routed-providers.md](./50-path-routed-providers.md). + +## Streaming response → SSE chunker → response parser → completion + token count + +```mermaid +sequenceDiagram + participant U as upstream LLM + participant LR as llm_response_parser
    (OnResponse) + participant S as llm.NewScanner
    (SSE framer) + participant P as Parser-specific accumulator
    (accumulateOpenAIStream
    or accumulateAnthropicStream) + + U-->>LR: text/event-stream
    (buffered prefix in RespBody) + LR->>S: NewScanner(bytes.NewReader(body)) + loop until EOF or [DONE] + S-->>LR: Event{Type, Data} + LR->>P: dispatch per event.Type
    (OpenAI: data-only
    Anthropic: named events) + P-->>P: accumulate completion text
    track usage from final frame + end + P-->>LR: llm.Usage + completion string + LR->>LR: appendUsage stamps
    llm.{input,output,total,cached_input,cache_creation}_tokens + LR->>LR: truncateCompletion(3500 bytes, rune-safe) + LR->>LR: redactPII if redact_pii && captureCompletion +``` + +`Scanner.Next` +([sse.go:44–87](../../../proxy/internal/llm/sse.go)) returns one +event per `\n\n` boundary; multiple `data:` lines join with `\n`; comment lines +(starting with `:`) are skipped per the SSE spec; a trailing event without a +closing blank line is still returned before `io.EOF` so a server that closes +the connection cleanly doesn't lose the last frame +([sse.go:55–58](../../../proxy/internal/llm/sse.go)). CRLF is +normalised in `trimEOL` so fixtures captured from live servers replay +unchanged. + +## Per-provider + +### OpenAI + +[openai.go:54–67](../../../proxy/internal/llm/openai.go) defines +`openAIRequest` with three prompt fields: `messages` (Chat Completions), +`prompt` (legacy), `input` (Responses API). The decoder uses +`json.RawMessage` so each shape is parsed lazily. + +`ParseResponse` +([openai.go:117–146](../../../proxy/internal/llm/openai.go)) +accepts both naming conventions: Chat Completions returns +`prompt_tokens`/`completion_tokens`, Responses API returns +`input_tokens`/`output_tokens`. `pickInt64` prefers Responses-API names and +falls back — same parser handles both endpoints without per-route config. +`openAICachedTokens` mirrors the fallback for +`input_tokens_details.cached_tokens` vs `prompt_tokens_details.cached_tokens`. + +**Key invariant:** `CachedInputTokens` for OpenAI is a SUBSET of +`InputTokens`. The cost meter clamps to guard against malformed upstream +responses where `cached > total`. + +### Anthropic + +[anthropic.go:37–49](../../../proxy/internal/llm/anthropic.go) +defines `anthropicRequest` covering Messages API (`system` + `messages[]`) +and legacy `/v1/complete` (`prompt` string). `ExtractPrompt` emits +`system: ` first when present, then per-message `role: content`. + +`ParseResponse` +([anthropic.go:82–104](../../../proxy/internal/llm/anthropic.go)) +fills three independent token buckets: `InputTokens`, `CacheReadInputTokens`, +`CacheCreationInputTokens`. Latter two are **additive** (not subset). +`TotalTokens` sums all four so downstream dashboards render one "tokens" +number without double-counting. + +`ExtractCompletion` walks `content[]` `{type, text}` parts and concatenates +non-empty text with newlines, falling back to legacy `completion`. + +### Bedrock + +[bedrock.go](../../../proxy/internal/llm/bedrock.go) implements the +`Parser` interface for the AWS Bedrock runtime. Bedrock is **path-routed**: the +model lives in the URL (`/model/{id}/{action}`), so the request middleware +extracts it (see [50-path-routed-providers.md](./50-path-routed-providers.md)) +and `ParseRequest` is a deliberate no-op. The parser's real work is on the +response leg, covering both Bedrock body shapes: + +- **InvokeModel** — vendor-native. Anthropic-on-Bedrock returns snake_case usage + (`input_tokens`, `output_tokens`, `cache_read_input_tokens`, + `cache_creation_input_tokens`) with the same additive cache buckets as + first-party Anthropic. +- **Converse** — unified camelCase (`inputTokens`, `outputTokens`, + `totalTokens`). `firstNonZero` folds the two naming conventions into one + `Usage`; when Converse omits `totalTokens` the parser sums the buckets. + +`ProviderName()` returns `"bedrock"` — its own pricing surface in the table +management ships, keyed by the **normalised** model id (region prefix + version +suffix stripped by the request parser; management normalises its keys the same +way at synth time so the two compare equal). `ParseResponse` returns +`ErrStreamingUnsupported` for an +AWS binary event-stream content-type (`application/vnd.amazon.eventstream`, +`isAWSEventStream`) so the caller routes to the streaming accumulator instead. + +### SSE framing + +`Scanner` is `bufio`-backed, 64 KiB read buffer, 1 MiB max line so a +malicious upstream can't blow process memory +([sse.go:33–38, 97–100](../../../proxy/internal/llm/sse.go)). +`splitField` strips one space after the `:` per the SSE spec. Documented +`not safe for concurrent use`; every consumer creates a fresh scanner per +response body. Streaming accumulators live in the middleware package +([llm_response_parser/streaming.go](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)) +but use `llm.NewScanner` so the framing contract stays here. + +### Pricing table + +**Management is the sole pricing authority.** The proxy carries no embedded +price list and reads no pricing file: the whole table arrives inside +`cost_meter`'s `ConfigJSON` on the ordinary mapping push, and a price change +is just another push — the chain rebuild constructs a fresh `Table`, so there +is nothing to reload +([pricing.go:1–7](../../../proxy/internal/llm/pricing/pricing.go)). The +management side of the contract (catalog defaults, the operator's stored +per-provider prices, and `AgentNetwork.PricingDefaultsFile`) is covered in the +management-side module guide; `cost_meter`'s wire shape is in +[31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md). + +`EntryJSON` +([pricing.go:36–45](../../../proxy/internal/llm/pricing/pricing.go)) is the +management→proxy contract — five USD-per-1k rates under `input_per_1k`, +`output_per_1k`, `cached_input_per_1k`, `cache_read_per_1k`, +`cache_creation_per_1k`. Management's `pricing.Entry` marshals the identical +names, and `EntryJSON`/`Entry` are field-identical so `NewEntries` converts by +direct struct conversion rather than field-by-field copying (a new rate can't +be silently dropped in transit). + +`EntryCosts` +([pricing.go:183–234](../../../proxy/internal/llm/pricing/pricing.go)) +is the cost formula — most security-relevant math in this module. The +**surface** (the `llm.provider` value the request parser stamped) selects the +formula, never the tier the entry came from: a per-provider-record override on +an Anthropic route still bills its cache buckets additively. + +| Provider | Formula | +|---|---| +| `openai` | `(inTokens − clamped) × InputPer1K + clamped × CachedInputPer1K + outTokens × OutputPer1K` where `clamped = min(cachedInput, inTokens)` | +| `anthropic`, `bedrock` | `inTokens × InputPer1K + cachedInput × CacheReadPer1K + cacheCreation × CacheCreationPer1K + outTokens × OutputPer1K` | +| default | `inTokens × InputPer1K + outTokens × OutputPer1K` | + +`bedrock` shares the Anthropic additive-cache formula +([pricing.go:214–229](../../../proxy/internal/llm/pricing/pricing.go)): +Anthropic-on-Bedrock reports the same additive cache buckets, while non-Anthropic +Bedrock models (Nova, Llama) simply report zero in those buckets so cost reduces +to `input + output`. + +Each per-bucket rate falls back to `InputPer1K` when zero — operators opt in +to discounts by setting the field. + +`Costs` +([pricing.go:143–163](../../../proxy/internal/llm/pricing/pricing.go)) is the +per-request split. The four per-bucket fields are the base; `TotalUSD` and +`CacheUSD` are **derived** in `newCosts` so the aggregates can never drift from +the breakdown. `InputUSD` is always the non-cached input bucket on both +provider shapes, so input and cached-input never double-count. + +## Public contracts + +**`Parser` interface** +([parser.go:50–66](../../../proxy/internal/llm/parser.go)): + +```go +type Parser interface { + Provider() Provider + ProviderName() string + DetectFromURL(path string) bool + ParseRequest(body []byte) (RequestFacts, error) + ParseResponse(status int, contentType string, body []byte) (Usage, error) + ExtractPrompt(body []byte) string + ExtractCompletion(status int, contentType string, body []byte) string +} +``` + +Adding a provider means implementing this interface and appending to the +slice returned by `Parsers()` ([parser.go:78–84](../../../proxy/internal/llm/parser.go)). +Order matters: `DetectFromURL` ties resolve by registration order. +`Parsers()` today returns `{OpenAIParser, AnthropicParser, BedrockParser}`. + +**`Provider` enum** +([parser.go:8–18](../../../proxy/internal/llm/parser.go)): +`ProviderUnknown = 0`, `ProviderOpenAI = 1`, `ProviderAnthropic = 2`, +`ProviderBedrock = 3`. Numeric values are persisted in nothing today but treat +them as wire-stable — new providers must take fresh numbers. + +**`Pricing` construction + lookup** +([pricing.go:60–130](../../../proxy/internal/llm/pricing/pricing.go)): + +```go +func NewEntries(raw map[string]map[string]EntryJSON) (map[string]map[string]Entry, error) +func NewTable(raw map[string]map[string]EntryJSON) (*Table, error) + +func (t *Table) Lookup(provider, model string) (Entry, bool) +func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool) +func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (Costs, bool) +func EntryCosts(entry Entry, surface string, inTokens, outTokens, cachedInput, cacheCreation int64) Costs +``` + +`NewTable` is the surface-keyed defaults table; `NewEntries` returns the raw +two-level map `cost_meter` uses for the per-provider-record tier (it looks up an +`Entry` directly and calls `EntryCosts`, so it needs no `Table` wrapper). Both +reject any non-finite or negative rate, so a corrupt config fails the chain +build rather than mispricing silently. Nil input yields an empty, +never-matching table. + +Nil-safe: `t.Cost`/`t.Lookup` on a nil receiver returns `ok=false` +([pricing.go:96–99](../../../proxy/internal/llm/pricing/pricing.go)). +`ok=false` means the surface or model is absent from the table management sent; +the caller emits `cost.skipped=unknown_model`. + +## Invariants + +1. **The pricing package is pure and platform-independent.** No file I/O, no + `//go:embed`, no goroutines, no build tags — the rates arrive as config, so + there is nothing platform-specific left to port. Anything reintroducing a + read-from-disk path here re-splits pricing authority between management and + the proxy, which is exactly what this design removed. + +2. **SSE scanner handles partial chunks.** A buffered prefix that doesn't end + in `\n\n` still yields its accumulated event before `io.EOF` + ([sse.go:55–58](../../../proxy/internal/llm/sse.go)). Tests: + `TestSSEScanner_OpenAIFixture`, `TestSSEScanner_AnthropicFixture`, + `TestSSEScanner_MultilineData`, `TestSSEScanner_CRLF`. The streaming + accumulators ride on this: `accumulateAnthropicStream` and + `accumulateOpenAIStream` `break` on any scanner error to return partial + usage rather than aborting + ([streaming.go:68–73, 144–150](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming.go)). + +3. **Management is the only source of rates.** `Table` has no constructor that + invents prices: the only way in is `NewTable`/`NewEntries` over the wire map + management sent. A missing or empty `pricing` block therefore means *no + prices at all* (`cost_meter` records `cost.skipped=unknown_model`, $0) — + never a stale built-in fallback that would silently bill list price. + +4. **Tables are immutable once built.** `Table.entries` is written only in + `NewEntries` and never mutated afterwards, and `cost_meter`'s `perRecord` + map is likewise build-time-only + ([pricing.go:47–52](../../../proxy/internal/llm/pricing/pricing.go)). This + is what makes the no-reload design safe: a price change arrives as a mapping + push that builds a new middleware instance over a new table, so concurrent + readers can't observe a half-updated price list and no atomic swap or lock + is needed on the hot path. + +5. **Rate validation happens at chain-build time, not per request.** + `NewEntries` rejects negative, NaN, and ±Inf rates field by field + ([pricing.go:60–83](../../../proxy/internal/llm/pricing/pricing.go)), naming + the offending surface/model/field in the error. Management enforces the same + constraints at its API boundary and in its YAML parser, so this is + defense-in-depth — but it means a corrupt push fails loudly at build instead + of producing negative costs on live traffic. Test: + `TestNewTable_ValidatesRates`. + +6. **New rates must be added to `Entry`, `EntryJSON`, *and* management's + `pricing.Entry` together.** `NewEntries` converts by direct struct + conversion `Entry(e)` + ([pricing.go:76–78](../../../proxy/internal/llm/pricing/pricing.go)), which + only compiles while the two structs stay field-identical — so the proxy half + is compiler-enforced. The management half is not: a rate added there but not + here unmarshals into nothing and prices that bucket at `InputPer1K`. + +## Things to scrutinise + +**Correctness.** Verify the OpenAI cached-prompt clamp at +[pricing.go:203–206](../../../proxy/internal/llm/pricing/pricing.go) +short-circuits before subtraction. Negative token counts are clamped to zero up +front ([pricing.go:186–197](../../../proxy/internal/llm/pricing/pricing.go)) so +no formula can yield a negative cost. `Anthropic.TotalTokens` sums all four +buckets (in + out + cache_read + cache_creation) — downstream dashboards +need to know this differs from `input + output`. +`OpenAIParser.ExtractPrompt` falls through `messages → input → prompt`; a +request sending all three reports only `messages` (uncommon but worth +noting). + +**Security.** `Scanner.maxLine = 1 MiB`; a 2 MiB single-line `data:` event +errors from `Scanner.Next` and both accumulators stop with partial usage. +Pricing is no longer file-backed, so the loader's path-traversal / symlink / +oversize surface is gone entirely — the config channel (an authenticated +mapping push from management) is now the only way rates enter the proxy, and +`NewEntries` is the validation boundary on it. A new rate added to management's +`pricing.Entry` but not to `EntryJSON` here is the remaining silent-mispricing +path (see invariant 6). + +**Concurrency.** Nothing in this package is shared mutable state: tables are +built once and never written again, so `cost_meter`'s hot path is lock-free by +construction rather than by atomic swap. Per-call `Scanner` instances mean no +shared state across concurrent response-parser calls. + +**Perf.** `Table.Cost` is two map lookups + multiplications, O(1); the +per-provider-record tier adds at most one more lookup. `Scanner.Next` is one +`ReadString('\n')` per line. No background goroutines and no per-request +allocation of pricing state. + +**Observability.** A config carrying no `pricing` block logs one warning at +chain-build time (`cost_meter` factory) and then records +`cost.skipped=unknown_model` per request, so an old-management deployment is +visible in both logs and the access log rather than quietly reporting $0. +Parser errors return sentinels — middleware uses `errors.Is` to map to the +right `cost.skipped` reason. + +## Test coverage + +| File | Tests | Coverage highlights | +|---|---:|---| +| `parser_test.go` | 3 | `Parsers()` shape lock, `DetectParser` URL matrix, provider enum stability | +| `openai_test.go` | 11 | Chat Completions + Responses API + legacy `prompt`; cached-tokens subset for both naming conventions; fixture replays | +| `anthropic_test.go` | 7 | Messages + legacy `/v1/complete`; streaming REJECTED on `ParseResponse` (must use scanner); fixture replays | +| `sse_test.go` | 12 | Fixture replay both providers; multiline `data:`; CRLF; comment skip; trailing-event-without-blank-line; oversize rejection | +| `pricing/pricing_test.go` | 10 | Provider-shape switch (surface selects the formula); cached-rate + cache-read/creation fallback to `InputPer1K`; cached-clamp; negative-token clamp; nil-receiver safety; rate validation (negative / NaN / Inf rejected); nil + empty table | + +**Fixtures** ([proxy/internal/llm/fixtures/](../../../proxy/internal/llm/fixtures/)): +`openai_chat_completion.json` (chat.completions with usage), +`openai_responses.json` (Responses API shape), +`openai_stream.txt` (3 deltas + usage + `[DONE]`), +`anthropic_messages.json` (Messages API non-streaming), +`anthropic_stream.txt` (full 7-event sequence: message_start → +content_block_{start,delta×2,stop} → message_delta (usage) → message_stop). +No pricing fixture: the table is config-delivered, so pricing tests construct +it in-process from a wire-shape map. + +## Cross-references + +- Sibling: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md) + — the chain that calls `llm.Parsers()`, `llm.ParserByName`, + `llm.NewScanner`, `pricing.NewTable` / `pricing.NewEntries`. +- Path-routed providers (Vertex AI + Bedrock), credential syntax, and the + Bedrock AWS event-stream accumulator: + [50-path-routed-providers.md](./50-path-routed-providers.md). +- Direct callers: `llm_request_parser/middleware.go:82–94`, + `llm_response_parser/middleware.go:113–123`, + `llm_response_parser/streaming.go:65, 142`, `cost_meter/factory.go:49–57`. +- Related elsewhere: the agent-network synthesiser stamping `provider_id` + is covered in the management-side module guide; proxy server boot + + `FactoryContext` construction is covered in the proxy-framework guide. diff --git a/docs/agent-networks/modules/33-proxy-runtime.md b/docs/agent-networks/modules/33-proxy-runtime.md new file mode 100644 index 000000000..54046b614 --- /dev/null +++ b/docs/agent-networks/modules/33-proxy-runtime.md @@ -0,0 +1,193 @@ +# proxy/runtime — translate + serve + log + +> **Risk level:** High — every config push from management is translated here, and the chain runs on every HTTP request to a synth target. +> **Backward-compat impact:** Additive at the wire (`PathTargetOptions.middlewares`, `agent_network`, `disable_access_log`, capture caps) and on the proxy `Server` struct (`MiddlewareCaptureBudgetBytes`). Non-agent-network targets stay on the no-middleware fast path. Middleware config is entirely wire-delivered — no proxy-side data dir is involved, including for LLM pricing, which management ships inside `cost_meter`'s config. + +## Module boundary + +Turns the synth-service wire format from `ProxyService.SyncMappings`/`GetMappingUpdate` into in-process middleware chains and runs them on top of the existing `httputil.ReverseProxy`. Four concerns: (a) **translate** — `proto.MiddlewareConfig` → validated `middleware.Spec` (proxy/middleware_translate.go) + self-register the eight built-ins (proxy/middleware_register.go); (b) **boot + rebuild** — construct the `middleware.Manager`, share the OTel meter, install the live-service check, rebuild per-path chains on every `addMapping`/`modifyMapping` (proxy/server.go); (c) **serve** — resolve chain at request time, capture bodies under a global budget, invoke `RunRequest`/`RunResponse`/`RunTerminal`, render deny responses, apply `UpstreamRewrite` (proxy/internal/proxy/reverseproxy.go); (d) **log + tag** — emit access-log entries with the new `agent_network` flag, gate emission on `EnableLogCollection` via `DisableAccessLog` (proxy/internal/accesslog). + +**Inert for non-agent-network targets**: nil or empty chain → existing fast path (reverseproxy.go:127-139); `SuppressAccessLog` defaults false so the access-log middleware emits unchanged. + +## Files + +| Path | Role | +| ---- | ---- | +| proxy/middleware_translate.go | proto→Spec translation; slot/failmode/timeout mapping; caps | +| proxy/middleware_translate_test.go | translator unit tests | +| proxy/middleware_register.go | blank-imports the eight builtins for `init()` registration | +| proxy/server.go | `initMiddlewareManager`, `rebuildMiddlewareChains`, `isLiveService`, `buildMiddlewareBindings`, new Server fields, `protoToMapping` stamps AgentNetwork/DisableAccessLog/CaptureConfig/Middlewares | +| proxy/internal/proxy/reverseproxy.go | `WithMiddlewareManager`, chain dispatch, body capture, `applyUpstreamRewrite`/`Headers`, `buildRequestInput`, response-leg respInput identity fields | +| proxy/internal/proxy/reverseproxy_test.go | `TestBuildRequestInput_PropagatesIdentityAndGroups` | +| proxy/internal/proxy/context.go | `agentNetwork`, `suppressAccessLog`, `userGroupNames` on `CapturedData` | +| proxy/internal/proxy/servicemapping.go | new `PathTarget` fields | +| proxy/internal/proxy/agent_network_chain_realstack_test.go | end-to-end self-contained chain test | +| proxy/internal/accesslog/logger.go | `logEntry.AgentNetwork` → `proto.AccessLog` | +| proxy/internal/accesslog/middleware.go | reads `GetAgentNetwork()`; gates `l.log` on `!GetSuppressAccessLog()` | +| proxy/internal/accesslog/middleware_test.go | suppress/default/preserves-usage assertions | +| proxy/internal/auth/middleware_test.go | tunnel-peer group propagation contract | +| proxy/internal/metrics/metrics.go | `Meter()` getter for the middleware manager | + +## Architecture & flow + +### Synth-service ingestion → translate → register → serve + +```mermaid +flowchart TD + A[Management SyncMappings/GetMappingUpdate] --> B["processMappings\nserver.go:1492"] + B --> C{Mapping type} + C -->|CREATED| D["addMapping → setupHTTPMapping → updateMapping"] + C -->|MODIFIED| E["modifyMapping → cleanupMappingRoutes → setupHTTPMapping → updateMapping"] + C -->|REMOVED| F["removeMapping → cleanupMappingRoutes → invalidateMiddlewareChains"] + D --> G["protoToMapping\nserver.go:2181"] + E --> G + G --> H["translateMiddlewareConfigs\nmiddleware_translate.go:55"] + G --> I["translateMiddlewareCaptureConfig\nmiddleware_translate.go:18"] + H --> J["[]middleware.Spec on PathTarget"] + I --> K["*bodytap.Config on PathTarget"] + J --> L["proxy.AddMapping\nservicemapping.go:118"] + K --> L + L --> M["rebuildMiddlewareChains\nserver.go:2017 → Manager.Rebuild"] + F --> N["Manager.Invalidate(serviceID)"] +``` + +### Per-request lifecycle through the chain + accesslog + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant M as accesslog.Middleware + participant A as auth.Middleware (Protect) + participant RP as ReverseProxy.ServeHTTP + participant CH as middleware.Chain + participant U as Upstream + C->>M: HTTP request + M->>M: NewCapturedData(requestID), WithCapturedData(ctx) + M->>A: next.ServeHTTP + A->>A: Private → ValidateTunnelPeer → stamp UserID/Email/Groups/GroupNames/AuthMethod + A->>RP: next.ServeHTTP + RP->>RP: findTargetForRequest → targetResult + RP->>RP: stamp ServiceID/AccountID/AgentNetwork/SuppressAccessLog on CapturedData + RP->>RP: resolveChain via Manager.ChainFor + alt chain == nil or Empty + RP->>U: httputil.ReverseProxy.ServeHTTP (fast path) + else chain non-empty + RP->>RP: bodytap.CaptureRequest (global budget) + RP->>CH: RunRequest + CH-->>RP: denyOutput? requestMeta + upstreamRewrite + alt deny + RP->>C: RenderDenyResponse + else allow + RP->>RP: capturingWriter + applyUpstreamRewrite/Headers + RP->>U: httputil.ReverseProxy.ServeHTTP(respWriter) + U-->>RP: response + RP->>CH: RunResponse (respInput carries UserGroups) + RP->>CH: RunTerminal (merged request+response metadata) + end + end + RP-->>M: handler returns + M->>M: build logEntry incl. AgentNetwork + alt SuppressAccessLog == true + M->>M: skip l.log; still trackUsage + else default + M->>M: l.log → goroutine SendAccessLog + end +``` + +### EnableLogCollection suppression path + +```mermaid +flowchart LR + S["agentnetwork.Settings.EnableLogCollection"] --> B["synthesizer: target.DisableAccessLog = !EnableLogCollection"] + B --> P["proto PathTargetOptions.disable_access_log (field 13)"] + P --> T["protoToMapping reads GetDisableAccessLog()\nserver.go:2211"] + T --> M["PathTarget.DisableAccessLog\nservicemapping.go:47"] + M --> R["ServeHTTP: cd.SetSuppressAccessLog\nreverseproxy.go:106"] + R --> G["accesslog middleware: if !GetSuppressAccessLog l.log\nmiddleware.go:95"] + R --> U["trackUsage unconditional — bandwidth telemetry preserved"] +``` + +**Ingestion** lands as a `ProxyMapping` batch on `handleSyncMappingsStream`/`handleMappingStream`. `processMappings` dispatches to `addMapping`/`modifyMapping`/`removeMapping`; HTTP goes `setupHTTPMapping → updateMapping → protoToMapping`. `protoToMapping` (server.go:2181) is the single translation surface that materialises `[]middleware.Spec`, `*bodytap.Config`, `AgentNetwork`, `DisableAccessLog` onto each `PathTarget`; `updateMapping` finishes with `s.proxy.AddMapping(m)` (atomic swap under `mappingsMux`) and `s.rebuildMiddlewareChains(svcID, m)`. + +At **request time** the access-log middleware stamps `CapturedData`; the auth chain runs (Private services lift `peer_group_ids` from `ValidateTunnelPeer` — auth/middleware_test.go:322). `ReverseProxy.ServeHTTP` resolves the chain; nil or empty → original `httputil.ReverseProxy`, no body capture. When a chain matches, body is captured under the global budget, `RunRequest` produces an `UpstreamRewrite` (`llm_router` selects a provider, rewrites scheme/host/path, injects `Authorization`), and `RunResponse`+`RunTerminal` run after the upstream returns. The terminal slot sees the merged metadata bag — that's how `llm_limit_record` ships the consumption sample. The **access-log** addition: `logEntry.AgentNetwork` from `GetAgentNetwork()` onto `proto.AccessLog.AgentNetwork`; the gate at middleware.go:95 honors `EnableLogCollection`, skipping `l.log` but keeping `trackUsage` so bandwidth telemetry survives. + +## Public contracts touched + +- `proxy.Server.MiddlewareCaptureBudgetBytes` (int64) — process-wide capture cap; defaults to 256 MiB (server.go:249-253). There is no `MiddlewareDataDir`: no built-in middleware reads config from disk, so `builtin.FactoryContext` carries only the proxy-lifetime context, meter, logger, and management client. +- `proxy/internal/proxy.WithMiddlewareManager(*middleware.Manager) Option` — new option on `NewReverseProxy`; nil keeps the fast path (reverseproxy.go:48-56). +- `proxy/internal/proxy.PathTarget` adds `Middlewares`, `CaptureConfig`, `AgentNetwork`, `DisableAccessLog` (servicemapping.go:27-51), all zero-default. +- `proxy/internal/proxy.CapturedData` adds `agentNetwork`, `suppressAccessLog`, `userGroupNames` behind `sync.RWMutex`; slices deep-copied (context.go:47-66, 183-258). +- `accesslog.logEntry.AgentNetwork` + `proto.AccessLog.AgentNetwork` (logger.go:131, 268). +- `metrics.Metrics.Meter()` exposes the OTel meter for the middleware manager (metrics.go:53-58). + +## Invariants + +- **Synth-service updates are live (no proxy restart).** Every `MODIFIED` flows through `modifyMapping → cleanupMappingRoutes` (invalidates chains) `→ setupHTTPMapping → updateMapping → rebuildMiddlewareChains`. **ProxyMapping.Private preservation:** the relevant logic lives in `management/internals/shared/grpc/proxy.go:shallowCloneMapping`, not this module, but it surfaces here — if a `MODIFIED` synth service arrives `private=false`, auth skips `ValidateTunnelPeer`, `CapturedData.UserGroups` stays empty, and `llm_router` denies with `llm_policy.no_authorised_provider` until a management restart re-pushes the snapshot. This module assumes `mapping.GetPrivate()` is correct on every batch. +- **`EnableLogCollection=false` suppresses access-log writes but middleware still runs.** Gate is one `if !cd.GetSuppressAccessLog()` immediately around `l.log(entry)` (middleware.go:95); `trackUsage` runs below the gate. Locked by `TestMiddleware_SuppressAccessLog_PreservesUsageTracking` (middleware_test.go:139). +- **`agent_network` flag on access-log entries is set when the chain processed the request.** Source `target.AgentNetwork`, stamped at reverseproxy.go:105, read at accesslog/middleware.go:86. +- **auth → builtin group propagation.** `Protect` writes `UserGroups`/`UserGroupNames`; `buildRequestInput` (reverseproxy.go:333) copies them into `middleware.Input`. The response-leg `respInput` (reverseproxy.go:196-223) also carries `UserEmail`/`UserGroups`/`UserGroupNames` — `llm_limit_record` needs `UserGroups` to ship `group_ids` so management's group-targeted budget rules match (comment at reverseproxy.go:211-215). +- **Empty chains stay on the fast path.** `ServeHTTP` skips body capture and the run sequence when `chain == nil || chain.Empty()` (reverseproxy.go:127). +- **Self-registration is the only way a builtin reaches the registry.** `middleware_register.go` blank-imports each builtin; `init()` adds the factory to `mwbuiltin.DefaultRegistry()`. Missing it → translator drops the entry with a warn (translate.go:97). + +## Things to scrutinize + +### Correctness +- **Translate edge cases** — drops on nil cfg, empty ID, unknown ID, UNSPECIFIED slot; each logs one warn; volume bounded by `MaxMiddlewaresPerChain`. +- **Re-translate without dropping in-flight requests** — `Manager.Rebuild` is the only call from `rebuildMiddlewareChains`. Reverse proxy reads `ChainFor` once per request (reverseproxy.go:327) and runs the captured `*Chain` for the whole request. Verify in module 30 that `Rebuild` swaps atomically. +- **ProxyMapping.Private preservation** — enforced management-side in `shallowCloneMapping`. Proxy-side regression catches: `TestProtect_PrivateService_TunnelPeerGroupsPropagate` + the integration test. +- **Body-capture cleanup** — `defer releaseBudget()` (reverseproxy.go:145) and `defer capturingWriter.Release()` (reverseproxy.go:180) must run on every return; confirm no future `return` lands between acquisition and defer. +- **`applyUpstreamRewrite` clones the URL** — `cloned := *orig` value-copies `*url.URL`; safe because overwritten fields are strings, not slices/maps (reverseproxy.go:285-292). + +### Security +- **Translate validates every config** — registry membership rejects unknown IDs; UNSPECIFIED slot drops; ID-less drops; raw config copied (not aliased) at translate.go:109. +- **`AuthHeader`/`StripHeaders` only reachable via `UpstreamRewrite`** — regular mutation surface goes through the framework denylist (`Authorization`/`Cookie` blocked); only the router middleware can replace `Authorization` (reverseproxy.go:296-304). Confirm in module 30 nothing outside the proxy-trusted path populates `UpstreamRewrite.AuthHeader`. +- **`stampNetBirdIdentity` strips client-sent values first** (reverseproxy.go:742-743) — anti-spoof for `X-NetBird-User`/`X-NetBird-Groups`; control chars filtered; comma-bearing labels dropped (reverseproxy_test.go:1217/:1243/:1193). +- **Auth → group propagation** — `auth/middleware_test.go:322` and `:366` cover the contract. If auth ever stops calling `ValidateTunnelPeer` for Private services, every agent-network request silently denies. + +### Concurrency +- **Chain replacement under in-flight requests** — `findTargetForRequest` takes `mappingsMux.RLock`; `AddMapping` writes. `resolveChain` calls `ChainFor` once; even if `Rebuild` swaps mid-request, in-flight requests keep running on the captured pointer. +- **`CapturedData` mutation across slots** — accessors take `sync.RWMutex`; slices deep-copied on both Set and Get. Verify no caller mutates the returned slice expecting it to land back. +- **`Manager.Invalidate` race** — `removeMapping` invalidates after `cleanupMappingRoutes`; mapping read happens before chain resolution, so requests before invalidate run captured chains; later ones fail `findTargetForRequest`. +- **`Logger.log` goroutine** — `logSem` caps at `maxLogWorkers = 4096`; overflow → `dropped.Add(1)` + debug log. Middleware test uses a buffered channel and 150ms negative-assertion window — review whether 150ms holds on slow CI. + +### Backward compatibility +- **Non-agent-network services unaffected** — `protoToMapping` reads new fields only when `opts != nil`; defaults leave `Middlewares`/`CaptureConfig` nil → chain resolves nil → fast path. Existing `reverseproxy_test.go` (non-chain) still passes. +- **`disable_access_log` is proto field 13, default false** — every existing target unset; gate is no-op. Locked by `TestMiddleware_SuppressAccessLog_DefaultEmitsLog` (middleware_test.go:104). +- **`Server` additions optional** — 256 MiB default when `MiddlewareCaptureBudgetBytes ≤ 0` (server.go:1997-2000). + +### Performance +- **Translate cost per push** — O(n) with per-entry registry lookup and `config_json` copy; negligible vs. the upstream gRPC unmarshal. +- **Empty-chain hot path** — one `ChainFor` map lookup + one `chain.Empty()` check; no allocation delta vs. pre-PR. +- **Body capture buffer churn** — `bodytap.CaptureRequest` allocates `MaxRequestBytes` per chain-hitting request; `releaseBudget` ties allocation to the 256 MiB proxy-wide budget. Confirm in module 30 the budget is a hard cap. + +### Observability +- **Metrics** — `Metrics.Meter()` shared with `middleware.NewMetrics` (server.go:1990-1993) so middleware instruments land in the same prometheus exporter. No new metrics defined here. +- **Access-log accuracy** — every entry carries `AgentNetwork`; terminal-slot metadata merged into `CapturedData.Metadata` (reverseproxy.go:238-241). +- **Deny logs at `Infof`** (reverseproxy.go:170) — review whether `Info` is too noisy at high deny rates; consider Debug or rate-limit. + +## Test coverage + +| Test file | Locks down | +| --------- | ---------- | +| proxy/middleware_translate_test.go | Empty/nil → nil; field preservation; unknown ID skip; nil registry permissive; timeout clamping; fail-mode + slot incl. UNSPECIFIED-drop; empty-ID drop; truncation above + at `MaxMiddlewaresPerChain` | +| proxy/internal/proxy/reverseproxy_test.go | Rewrite host/headers/cookies/query; trusted proxy; path forwarding; classifyProxyError; X-NetBird-User/Groups anti-spoof + CSV-join + control-char/comma rejection + fallback-to-ID; `TestBuildRequestInput_PropagatesIdentityAndGroups` (UserGroups/Email/GroupNames/AgentNetwork reach `middleware.Input`) | +| proxy/internal/proxy/agent_network_chain_realstack_test.go | **The end-to-end integration test.** Drives a real agent-network request through `ReverseProxy.ServeHTTP` with the chain the synthesizer produces, against an in-process management gRPC (bufconn) backed by a real sqlite store + real `agentnetwork.Manager`, plus an `httptest` upstream — no external infrastructure or real LLM. Guarantees: (1) response-leg `respInput` carries `UserGroups` so `llm_limit_record` ships non-empty `group_ids` and the admin-group consumption row increments; (2) `RedactPii=true` redacts both prompt and completion on captured metadata; (3) the full chain runs against a real management stack. **Line 189-211 inlines the proto→Spec mapping** instead of calling the proxy's private `translateMiddlewareConfig` — keep that inline mirror in sync with `proxy/middleware_translate.go` or the test silently diverges from production. | +| proxy/internal/accesslog/middleware_test.go | `SuppressAccessLog=true` skips `SendAccessLog` (150ms negative wait); default emits one send (2s positive); usage tracking runs under suppression | +| proxy/internal/auth/middleware_test.go | `TestProtect_PrivateService_TunnelPeerGroupsPropagate` proves `peer_group_ids` reach `CapturedData.UserGroups`; `TestProtect_PrivateService_TunnelPeerDenied` proves rejected peers 403 without reaching the handler | + +The integration test runs in a few seconds with no external infrastructure — exercising the real synthesizer, `Manager.Rebuild`, `ServeHTTP` dispatch, and `llm_limit_record` writing a real consumption row through the real `agentnetwork.Manager` over real gRPC. + +## Known limitations / explicit non-goals + +- **Translator does not validate `RawConfig` JSON** — factory's job at `New([]byte)`. Confirm in module 30 that a per-binding factory failure doesn't poison the rest of the chain. +- **No throttle on management push rate** — every `MODIFIED` triggers `Manager.Rebuild`. Mitigation upstream. +- **Streaming responses (SSE)** — body capture is streaming-aware, but response-leg middleware runs only after the response completes; long SSE streams delay `llm_limit_record` until close. +- **OIDC-only path doesn't carry tunnel-peer groups** — agent-network synth services rely on the Private tunnel-peer path; JWT groups claim is the only carrier for non-Private OIDC. +- **`agent_network` flag on L4 entries** not added; HTTP-only. +- **`mw.capture.bypass_reason` metadata key** documented at reverseproxy.go:151,184; namespace this in module 30/31 to avoid collisions. + +## Cross-references +- Upstream: [shared/api](10-shared-api.md), [proxy/middleware-framework](30-proxy-middleware-framework.md), [proxy/middleware-builtin](31-proxy-middleware-builtin.md), [proxy/llm-parsers](32-proxy-llm-parsers.md) +- End-to-end flow: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/40-dashboard.md b/docs/agent-networks/modules/40-dashboard.md new file mode 100644 index 000000000..4ed9021bb --- /dev/null +++ b/docs/agent-networks/modules/40-dashboard.md @@ -0,0 +1,228 @@ +# dashboard — UI for agent-networks + +This module documents code that lives in the **dashboard repo** (under +`src/modules/agent-network/` and `src/app/(dashboard)/agent-network/`), not +in this repo. It is co-located here so backend readers see the full picture. + +> **Risk level:** Medium. The new surface is isolated under `src/modules/agent-network/` and `src/app/(dashboard)/agent-network/`, but it also reshapes the sidebar, splits `/peers`, renames `reverse-proxy/clusters` → `self-hosted-proxies`, and overlays the Control Center graph. Regressions here would be cross-cutting. +> **Backward-compat impact:** Additive on the API side. Breaking on URL/navigation: `/peers` redirects to `/peers/devices` (src/app/(dashboard)/peers/page.tsx:7-15), `/reverse-proxy/clusters` was renamed to `/reverse-proxy/self-hosted-proxies`, the sidebar lost Access Control / Networks / Reverse Proxy / DNS / standalone Guardrails / Consumption / Activity (Navigation.tsx:165-171 — routes still resolve via URL), and the standalone `/agent-network/{access-log,consumption,global-controls}` routes are gone in favor of `/agent-network/observability`. + +## Module boundary + +The dashboard is the only place an operator interacts with agent-networks: provider catalog, configured providers, policies, guardrails, account-level budget rules, account settings (collection / redaction toggles), per-request access log, and consumption rollups all render, paginate, and edit here. Data flows in via SWR (`useFetchApi`) keyed by REST URL. One big context provider (`src/modules/agent-network/AIProvidersProvider.tsx`) aggregates five resources (providers, policies, guardrails, budget rules, settings) plus the proxy access-log stream filtered to `agent_network=true`, and exposes `add* / update* / toggle* / delete*` mutators that call through `useApiCall` and re-`mutate()` SWR. Pages mount the provider once at the top and compose presentational tables and modals beneath. The control-center page additionally fetches `/agent-network/{providers,policies}` directly (control-center/page.tsx:123-130) to overlay graph nodes. + +## What the UI delivers + +- **AI Observability** page with four tabs: Access Logs, Budget Dashboard, + Budget Settings, Log Settings (replaces the standalone access-log, + consumption, and global-controls routes). +- **Providers** page: provider catalog + connect/edit wizard with per-vendor + copy (LiteLLM, Portkey, Bifrost, Cloudflare, Vercel, OpenRouter, custom). +- **Policies** page: group → provider authorization with per-policy Limits + (minute-granular windows) + guardrail attach. +- **Guardrails** page: reusable model-allowlist + prompt-capture sets. +- **Account controls**: Log Collection / Prompt Collection / Redact PII toggles. +- **Budget rules**: account-level rules reusing the policy Limits UI. +- **Control Center overlay**: provider + agent-policy nodes on the graph. +- **Navigation + peers reshaping**: peers split into Devices / Agents, + `reverse-proxy/clusters` renamed to `self-hosted-proxies`, sidebar + repackaged for agent-network focus. + +## Surface added + +### New pages + +| Route | Purpose | Backing module(s) | +| ----- | ------- | ----------------- | +| `/agent-network` | Redirect to `/agent-network/providers` | page.tsx:7-15 | +| `/agent-network/providers` | List + connect providers; header surfaces per-account base URL | providers/page.tsx + AgentProvidersTable + AIProviderModal | +| `/agent-network/policies` | Group → Provider authorization with per-policy Limits + Guardrail attach | policies/page.tsx + AgentPoliciesTable + AgentPolicyModal | +| `/agent-network/guardrails` | Reusable guardrail sets (model allowlist + prompt capture) | guardrails/page.tsx + AgentGuardrailsTable + AgentGuardrailModal | +| `/agent-network/observability` | Tabs: Access Logs / Budget Dashboard / Budget Settings / Log Settings | observability/page.tsx | +| `/peers/devices`, `/peers/agents` | Split of `/peers`, shared via `PeersListView` keyed by `kind` | peers/{devices,agents}/page.tsx | +| `/reverse-proxy/self-hosted-proxies` | Renamed from `clusters` | self-hosted-proxies/page.tsx | + +Removed in favor of `/agent-network/observability`: `/agent-network/access-log`, `/agent-network/consumption`, `/agent-network/global-controls`. + +### New modules under src/modules/agent-network + +| File | Role | +| ---- | ---- | +| AIProvidersProvider.tsx (~1158 LOC) | Aggregates every agent-network resource via SWR; normalises snake↔camel; exposes mutators; holds wizard-open state | +| AIProviderModal.tsx (~1268 LOC) | Connect / edit provider wizard with per-vendor copy (Bifrost, Portkey, LiteLLM, Cloudflare, Vercel, OpenRouter, custom) | +| AIProviderLogo + useProviderCatalog | Catalog-driven brand swatch + SWR hook over `/agent-network/catalog/providers` | +| AgentPoliciesTable + AgentPolicyModal + AgentPolicyGuardrailsTab + AgentPolicyLimitsTab | Policies; modal has 3 tabs (Rule, Limits, Guardrails) | +| AgentGuardrailsTable + AgentGuardrailModal + AgentGuardrailBrowseModal + AgentGuardrailChecksCell | Guardrails CRUD + attach-from-policy | +| AgentBudgetRulesTable + AgentBudgetRuleModal | Account-level budget rules; modal reuses AgentPolicyLimitsTab verbatim | +| AgentAccountControlsCard | Three account-wide toggles (Log Collection / Prompt Collection / Redact PII) | +| AgentAccessLogTable + AgentAccessLogExpandedRow | Access log on `/events/proxy?agent_network=true` | +| AgentConsumptionPanel + AgentConsumptionTable | Token + cost panel: charts + counter table | +| table/AgentProvidersTable + AgentProviderActionCell | Providers table + per-row actions | +| data/mockData.ts | Domain types and a few residual `MOCK_*` constants (see scrutinize) | + +### Touched non-agent-network areas + +- **control-center**: agent-network overlay (provider + agent-policy nodes); removed the All Networks dropdown; hid the Networks tab in FlowSelector (FlowSelector.tsx:9-14 — enum value kept so `?tab=networks` still type-checks); wrapped `ControlCenterView` in `AIProvidersProvider` (page.tsx:73-83); `agentPolicyNode` clicks routed to a separate state slot (page.tsx:1871-1874). New node renderers: nodes/ProviderNode.tsx, nodes/AgentPolicyNode.tsx (registered at utils/nodes.ts:21-22). +- **peers**: Split into Devices and Agents sub-routes; shared via `PeersListView` keyed by `kind` (PeersListView.tsx:24-95). New compact-toolbar `UserFilterSelector` (users/UserFilterSelector.tsx). +- **reverse-proxy**: Folder rename `clusters/` → `self-hosted-proxies/`; deleted `ClustersFeaturesCell.tsx`, `ClusterTypeIndicator.tsx`; new ReverseProxyClusterTargetSelector for cluster target type; Private toggle on target modal; body-capture knobs removed; new ReverseProxyEventExpandedRow. +- **events**: `ReverseProxyEventsUserCell` rewritten with user + peer fallback (ReverseProxyEventsUserCell.tsx:14-21), shared with the access-log table. +- **navigation**: Full repackaging in Navigation.tsx — Agent Network items flattened (no collapsible parent), distinct icons per item; Access Control, Networks, Reverse Proxy, DNS, standalone Guardrails, Consumption, Activity removed (still URL-reachable, per lines 165-171). + +## Architecture & flow + +### Page → Provider → Table/Modal hierarchy + +```mermaid +graph TD + Nav[Navigation.tsx] + Nav --> ProvidersPage[/agent-network/providers/] + Nav --> PoliciesPage[/agent-network/policies/] + Nav --> GuardrailsPage[/agent-network/guardrails/] + Nav --> ObsPage[/agent-network/observability/] + + ProvidersPage --> AIPP1[AIProvidersProvider] + PoliciesPage --> AIPP2[AIProvidersProvider] + GuardrailsPage --> AIPP3[AIProvidersProvider] + ObsPage --> AIPP4[AIProvidersProvider] + ObsPage -.wraps.-> GroupsProvider + ObsPage -.wraps.-> PeersProvider + + AIPP1 --> ProvTable[AgentProvidersTable] + ProvTable --> ProvModal[AIProviderModal] + AIPP2 --> PolTable[AgentPoliciesTable] + PolTable --> PolModal[AgentPolicyModal] + PolModal --> PolGuardTab[AgentPolicyGuardrailsTab] + PolModal --> PolLimitsTab[AgentPolicyLimitsTab] + PolGuardTab --> GuardBrowse[AgentGuardrailBrowseModal] + PolGuardTab --> GuardModal[AgentGuardrailModal] + AIPP3 --> GuardTable[AgentGuardrailsTable] + GuardTable --> GuardModal + AIPP4 --> Tabs[Tabs] + Tabs --> AccessLog[AgentAccessLogTable] + Tabs --> Consumption[AgentConsumptionPanel] + Tabs --> BudgetRules[AgentBudgetRulesTable] + Tabs --> AccountCtl[AgentAccountControlsCard] + BudgetRules --> BudgetModal[AgentBudgetRuleModal] + BudgetModal -.reuses.-> PolLimitsTab +``` + +### AI Observability tab page + +```mermaid +graph LR + Page[AIObservabilityPage] --> RA[RestrictedAccess
    permission.services.read] + RA --> GP[GroupsProvider] + GP --> PP[PeersProvider] + PP --> AIP[AIProvidersProvider] + AIP --> Tabs[Tabs / TabsList] + Tabs --> T1[Access Logs
    AgentAccessLogTable] + Tabs --> T2[Budget Dashboard
    AgentConsumptionPanel] + Tabs --> T3[Budget Settings
    AgentBudgetRulesTable] + Tabs --> T4[Log Settings
    AgentAccountControlsCard] + T1 -.GET.-> EP[/events/proxy?agent_network=true/] + T2 -.GET poll 5s.-> CONS[/agent-network/consumption/] + T3 -.GET/PUT.-> BR[/agent-network/budget-rules/] + T4 -.GET/PUT.-> ST[/agent-network/settings/] +``` + +### Data fetch path + +```mermaid +graph TD + Page[Page component] --> Prov[AIProvidersProvider] + Prov -->|useFetchApi| SWR[(SWR cache
    key = URL)] + SWR -.GET.-> P[/agent-network/providers/] + SWR -.GET.-> POL[/agent-network/policies/] + SWR -.GET.-> G[/agent-network/guardrails/] + SWR -.GET.-> BR[/agent-network/budget-rules/] + SWR -.GET ignoreError.-> ST[/agent-network/settings/] + SWR -.GET.-> CAT[/agent-network/catalog/providers/] + SWR -.GET pageSize=100.-> EVT[/events/proxy agent_network=true/] + Prov --> Mut[useApiCall.post/put/del] + Mut -.on success.-> MutateSWR[SWR mutate keys] + Prov --> Children[Tables / Modals via useAIProviders] +``` + +Every list view reaches management through SWR over `/api/agent-network/*`. The provider context maps snake-case payloads to camelCase domain types (`fromAPI`, `policyFromAPI`, `guardrailFromAPI`, `budgetRuleFromAPI`, `settingsFromAPI`, `accessLogFromAPI` — AIProvidersProvider.tsx:138-562) and back via matching `*ToRequest` adaptors. The access log piggy-backs on `/events/proxy` with `agent_network=true&page_size=100` (line 707-709) and decodes LLM-specific fields from per-event `metadata`. Group IDs on events are resolved to current names through the surrounding GroupsProvider catalog (lines 515-521, 717-731) — no extra round trip. Mutators run `*ToRequest`, await `useApiCall.post/put/del`, call SWR `mutate()`, then `notify`. Errors caught and surfaced via `notify` — no exceptions escape into render. The Connect Provider modal's open state lives in the provider itself (`isWizardOpen` at lines 732-735) so the providers-page empty-state CTA and the table's + button share one modal. Control-center re-fetches `/agent-network/{providers,policies}` directly on top of `AIProvidersProvider` — SWR de-dupes but the code path is harder to reason about. + +## Public contracts consumed + +- `GET/POST /api/agent-network/providers`, `PUT/DELETE /:id` +- `GET/POST /api/agent-network/policies`, `PUT/DELETE /:id` +- `GET/POST /api/agent-network/guardrails`, `PUT/DELETE /:id` +- `GET/POST /api/agent-network/budget-rules`, `PUT/DELETE /:id` +- `GET/PUT /api/agent-network/settings` (ignoreError-tolerant; 404 = not yet bootstrapped — auto-bootstrap on first provider create via `bootstrap_cluster` field — AIProvidersProvider.tsx:737-760) +- `GET /api/agent-network/catalog/providers` (read-only declarative; backend owns vendor list, IDs, brand colors, models, extra_headers, identity_injection — useProviderCatalog.ts:6-95) +- `GET /api/agent-network/consumption` (polled every 5s on Budget Dashboard — ConsumptionPanel.tsx:53,65-71) +- `GET /api/events/proxy?agent_network=true&page_size=100` (shared with Proxy Events) +- `permission?.services?.read` gates every agent-network route via RestrictedAccess. + +`AIProviderId` is a closed union in dashboard types (data/mockData.ts:8-21) but the converter tolerates anything the backend ships — unknown ids fall through to `"custom"` (AIProvidersProvider.tsx:497-506). Catalog values are pure read-through: anything declared in `extra_headers` renders in the modal automatically, copy keyed by header name (`EXTRA_HEADER_UI` in AIProviderModal.tsx:61-89), labeled-fallback for unknown ones. + +## Invariants + +- Provider context wrap order on user-attribution pages: `GroupsProvider > PeersProvider > AIProvidersProvider` (observability/page.tsx:87-89). Reverse it and access-log group resolution silently drops names. +- Every agent-network route checks `permission?.services?.read` via `RestrictedAccess` (observability/page.tsx:85, providers/page.tsx:184, policies/page.tsx:53, guardrails/page.tsx:55). +- Modal `key={open ? 1 : 0}` pattern is used to force unmount/remount on close so internal `useState` resets between edits (AgentBudgetRuleModal.tsx:60, AgentPolicyModal.tsx:66). Removing this would leak prior-row state into a new-row session. +- `mockData.ts` is the canonical home for ALL agent-network domain types; `MOCK_*` constants must never reach a production code path. One leak remains (below). + +## Things to scrutinize + +### Correctness + +- **Tab-state URL hand-off is one-way.** observability/page.tsx:53-58 reads `?tab=` on mount (despite the file comment at line 28 saying URL hand-off is future) but `setTab` does NOT push back, so reload preserves the chosen tab only if it came in via the link. Inconsistent with control-center (page.tsx:1817-1831). +- **Provider overlay runs only in `applySingleGroupView` / `applyPeerView`** (control-center/page.tsx:557, 1159-1166). User view does NOT show providers — if agent-network is a primary lens, that's a gap. +- **Two useEffects race to invalidate the control-center layout.** page.tsx:1655-1657 drops `layoutInitialized` when `agentPolicies` / `agentProviders` arrive; the main effect (1786-1799) also lists them as deps. Functional but fragile — watch for flash-of-empty-graph. +- **`updateProvider` / `updatePolicy` / `updateBudgetRule` use `??` on `enabled`** (AIProvidersProvider.tsx:784, 859, 1018). Toggle paths are safe; any caller sending `enabled: false` thinking "leave it off" gets `existing.enabled` instead. Audit modal callers. +- **Form validation in modals is minimal.** Window-seconds picker — mockData.ts:209-215 documents "minimum 60 — one minute" but there is no matching UI guard in PolicyLimitsTab; the backend validator is the enforcement point. + +### Security + +- **No client-side enforcement claims** — every cap, allowlist, and toggle is display + edit; proxy is the source of truth for deny decisions (AccessLogTable.tsx:177-191 renders backend-emitted `denyReason` as-is). +- **Prompt display is gated by what the backend stamps.** When `enable_prompt_collection` is OFF the proxy must not put prompt/completion into event metadata; the dashboard renders whatever it gets verbatim (AccessLogTable lines 532-534, AccessLogExpandedRow.tsx:42-57). No UI filter on top of backend collection switches. +- Account Controls disables `Redact PII` when `Prompt Collection` is off (AgentAccountControlsCard.tsx:122) and clears it on off-transition (line 100), but relies on backend to enforce the same gate at write — confirm PUT handler rejects `redact_pii=true && enable_prompt_collection=false`. +- **Bifrost identity-header overrides**: empty-string vs nil semantics documented in AIProvidersProvider.tsx:772-781 ("omitted = preserve, empty = explicit clear"). Mishandling could leak group attribution to a header the operator thought disabled. Focused read of Bifrost code path in AIProviderModal.tsx recommended. + +### Accessibility + +- Observability TabsList (observability/page.tsx:96-113) uses the shared Tabs component — should inherit Radix roving-tabindex. All four TabsTriggers carry only icon + text, no `aria-label`; fine because text is visible. +- Modal focus traps are inherited from the shared Modal; agent-network modals don't override them. Quick keyboard pass recommended. +- `EndpointBadge` Copy button (providers/page.tsx:66-76) has an `aria-label`, good. + +### Performance + +- `AgentConsumptionPanel` polls `/agent-network/consumption` every 5s (ConsumptionPanel.tsx:53,70). Tab switches unmount the panel, so the poll stops — verify in network panel. +- `AgentAccessLogTable` is hard-capped at 100 rows via `page_size=100` (AIProvidersProvider.tsx:707-709). Server-side pagination is future work; high-traffic tenants miss everything past row 100 — known limitation. +- Observability page mounts providers ONCE at page level (observability/page.tsx:87-89); tab switches keep SWR cache hot. Moving the provider mount inside `TabsContent` would re-fetch the access log on every switch. + +### Visual consistency + +- The observability tab style mirrors peers/page.tsx. Outer Tabs `pt-4 pb-0 mb-0`, TabsList `px-8` (observability/page.tsx:94-96) — confirm chrome height matches so the page doesn't visually jump. +- Sidebar: `Boxes` for Providers, `AccessControlIcon` for Policies, `TelescopeIcon` for AI Observability (Navigation.tsx:113,120,133). Reusing `AccessControlIcon` makes Policies look identical to the (now hidden) Access Control item — if Access Control ever comes back, they collide. +- `AgentNetworkIcon` is used in breadcrumbs on every agent-network page but NOT in the sidebar (per-page icons instead). Deliberate departure — record so it doesn't get reverted. + +## Test coverage + +- **Cypress**: One file (`cypress/e2e/test.cy.ts`) covering only the install-page copy-to-clipboard flow. NOTHING covers agent-network UI. +- **Component / unit tests**: `src/utils/version.test.ts` is the only `.test.*` file in the repo. The agent-network modules ship without component tests. +- Data-cy hooks exist on key controls: `save-account-controls` (AgentAccountControlsCard.tsx:71), `enable-log-collection`, `enable-prompt-collection`, `redact-pii`, plus existing `data-cy={policy.name}` / `data-cy={provider.name}` on ActiveInactiveRow. Sufficient hooks for Cypress flows; none written yet. +- **Tooling gap (pre-existing):** `npm run lint` (`next lint`) is broken in Next 16 — the `lint` subcommand was removed from the Next CLI in 16.x, so the dashboard effectively has no working lint gate. The fix is to add either a flat-config `eslint .` script or wire ESLint via an explicit `eslint-config-next` invocation. + +## Known limitations / explicit non-goals + +- **`data/mockData.ts` still contains `MOCK_GROUPS`, `MOCK_PROVIDERS`, `MOCK_PEERS`.** Only `MOCK_GROUPS` is referenced from production — AgentPoliciesTable.tsx:45,76 uses it as a name-lookup fallback when a policy references a group ID the real GroupsProvider doesn't know about. `MOCK_PROVIDERS` / `MOCK_PEERS` are unreferenced; safe to delete. The file is `/* eslint-disable */` so dead-code warnings don't flag them. +- **Tab-state URL hand-off on observability page is one-way** (read-only). +- **Access log hard-capped at 100 rows**; no server-side pagination. +- **No optimistic updates.** All mutations are round-trip; failures rollback via SWR revalidation. +- **`FlowView.NETWORKS` retained but hidden** from FlowSelector (FlowSelector.tsx:9-14). Old `?tab=networks` links still route to the hidden view because `applyNetworksView` still runs. +- **Redirects are not query-preserving** — `router.replace("/peers/devices")` (peers/page.tsx:13) strips any incoming filter params. +- **Control-center cross-fetches** `/agent-network/{providers,policies}` directly on top of `AIProvidersProvider`. Could be collapsed. +- **Sidebar permanently hides Access Control, Networks, Reverse Proxy, standalone Guardrails, DNS, Activity, Consumption.** Routes still resolve via URL (Navigation.tsx:165-171); intentional. + +## Cross-references + +- Upstream API contracts: [shared/api](10-shared-api.md) +- Backend persistence: [management/store](20-management-store.md) +- Backend handler wiring: [management/handlers + wiring](22-management-handlers-wiring.md) +- End-to-end flow narrative: [../01-end-to-end-flows.md](../01-end-to-end-flows.md) +- Top-level overview: [../00-overview.md](../00-overview.md) diff --git a/docs/agent-networks/modules/50-path-routed-providers.md b/docs/agent-networks/modules/50-path-routed-providers.md new file mode 100644 index 000000000..08c976c5f --- /dev/null +++ b/docs/agent-networks/modules/50-path-routed-providers.md @@ -0,0 +1,257 @@ +# path-routed providers — Vertex AI + Bedrock + +This guide pulls the **path-routed** provider story together in one place +because it crosses the catalog, the synthesiser, the request parser, and the +router. The relevant building blocks are the `llm_router` / +`llm_request_parser` middlewares +([31-proxy-middleware-builtin.md](31-proxy-middleware-builtin.md)), the +per-provider parser surface ([32-proxy-llm-parsers.md](32-proxy-llm-parsers.md)), +and the synthesiser's catalog → `ProviderRoute` mapping +([21-management-agentnetwork.md](21-management-agentnetwork.md)). + +Sibling modules: [31-proxy-middleware-builtin.md](31-proxy-middleware-builtin.md) +(router + request parser) and [32-proxy-llm-parsers.md](32-proxy-llm-parsers.md) +(Bedrock parser + pricing). + +--- + +## What "path-routed" means + +Most catalog providers carry the model in the request **body** (`{"model": …}`), +so `llm_router` selects an upstream by matching the model name against each +provider's `Models` claim. Two providers instead carry the model in the **URL +path**, so they are routed by path before the model/vendor table is consulted: + +| Catalog id | Style flag | Request path shape | +|---|---|---| +| `vertex_ai_api` | `IsVertexPathStyle` → `ProviderRoute.Vertex` | `/v1/projects/{project}/locations/{region}/publishers/{publisher}/models/{model}:{action}` | +| `bedrock_api` | `IsBedrockPathStyle` → `ProviderRoute.Bedrock` | `/model/{modelId}/{action}` (optionally behind `/bedrock`) | + +The catalog declares the style with +[`catalog.IsVertexPathStyle` / `catalog.IsBedrockPathStyle`](../../../management/server/agentnetwork/catalog/catalog.go) +and the synthesiser copies the result onto the router route as the `Vertex` / +`Bedrock` booleans +([synthesizer.go:450-451](../../../management/server/agentnetwork/synthesizer.go)). +On the request leg `llm_router.Invoke` dispatches `isVertexPath` / `isBedrockPath` +**before** the model lookup +([llm_router/middleware.go:138-216](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +so a model the parser extracted from the path can't be claimed by a same-vendor +*body-routed* provider (e.g. `claude-*` on `api.anthropic.com`). + +## Google Vertex AI (`vertex_ai_api`) + +### Catalog entry + +`KindProvider`, parser surface left unset on the catalog entry — the request +parser picks the parser from the URL **publisher** segment, not from +`ParserID`. Upstream host is `-aiplatform.googleapis.com` +(`https://aiplatform.googleapis.com` for the `global` location). The catalog +lists the Claude-on-Vertex lineup (`claude-opus-4-*`, `claude-sonnet-4-*`, +`claude-haiku-4-5`, `claude-fable-5`) at the same per-token rates as the +first-party Anthropic entry +([catalog.go:333-363](../../../management/server/agentnetwork/catalog/catalog.go)). + +### Credential — service-account OAuth (`keyfile::`) + +Vertex does **not** accept a static API key. The operator sets the provider +`api_key` to: + +``` +keyfile:: +``` + +The synthesiser recognises the `keyfile::` prefix in `providerAuthHeader` +([synthesizer.go:897-903](../../../management/server/agentnetwork/synthesizer.go)), +emits **no** static auth value, and carries the base64 key material on the +route as `GCPServiceAccountKeyB64` +([factory.go:56-61](../../../proxy/internal/middleware/builtin/llm_router/factory.go)). +At request time the router mints a short-lived OAuth2 access token from the key +(cloud-platform scope) and injects `Authorization: Bearer ` — +never the key itself +([llm_router/middleware.go:621-692](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)): + +- One auto-refreshing `oauth2.TokenSource` is cached per key (keyed by a + SHA-256 of the base64 material), so token minting happens once and refreshes + amortise across requests. +- Mint / refresh is bounded by a 10s timeout HTTP client (`gcpTokenTimeout`) so + a slow Google token endpoint can't hang the request. +- A malformed key or an unreachable token endpoint fails the request with + `llm_policy.upstream_auth_failed` at HTTP **502** (an upstream problem, not a + policy denial) — see `denyUpstreamAuth`. + +### Metering — Anthropic-on-Vertex only + +The request parser extracts `{publisher, model, action}` from the path +(`parseVertexPath`, [llm_request_parser/middleware.go:237-263](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)), +strips the `@version` suffix from the model, and maps the publisher to a parser +surface via `vertexPublisherVendor`: + +- `anthropic` → `llm.provider="anthropic"` → metered through the Anthropic + parser, priced under the **`anthropic`** surface of the pricing table + management ships (the parser emits the standard Anthropic provider label, so + Vertex Claude reuses first-party Anthropic prices). +- `openai` → `llm.provider="openai"` (reserved; not in the catalog lineup + today). +- anything else (notably `google` / Gemini) → empty vendor → **no parser**. + +**Gemini is intentionally denied as unmeterable.** When the parser emits no +`llm.provider` for a Vertex publisher, `llm_router` returns +`llm_policy.unmeterable_publisher` (403) rather than forwarding the request +uncounted — serving it would bypass token / budget metering +([llm_router/middleware.go:144-162, 712-728](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)). +A Gemini parser would lift this restriction; until then the `google` publisher +is omitted from the catalog. + +> Caveat: cross-region inference profiles in `eu` / `apac` carry a ~10% price +> premium that the base per-token rates do **not** model — cost annotations for +> those regions read low. Operators who need exact regional billing set the +> affected models' prices on the provider record, or replace the default entries +> via management's `AgentNetwork.PricingDefaultsFile`. + +## AWS Bedrock (`bedrock_api`) + +### Catalog entry + +`KindProvider`, upstream host `bedrock-runtime..amazonaws.com`. Metered +models are the Anthropic-on-Bedrock lineup (`anthropic.claude-*`) plus Amazon +Nova and Llama 3.3 entries +([catalog.go:300-332](../../../management/server/agentnetwork/catalog/catalog.go)). +Anthropic-on-Bedrock reuses the first-party Claude prices (with additive cache +buckets); Nova / Llama report no cache, so cost is `input + output`. + +### Credential — static bearer token + +Bedrock uses the **AWS Bedrock API key** as a static bearer. The operator sets +the provider `api_key` directly (no `keyfile::` prefix); the catalog template +is `Authorization: Bearer ${API_KEY}` +([catalog.go:306-307](../../../management/server/agentnetwork/catalog/catalog.go)). +No token minting — the synthesiser substitutes the key into the template and +the router injects the resulting `Authorization` header after stripping inbound +vendor auth (including client-supplied AWS SigV4 material: `X-Amz-Date`, +`X-Amz-Security-Token`, `X-Amz-Content-Sha256`, see `strippedAuthHeaders`). + +### Model id form — cross-region inference profiles + +Bedrock model ids in the request path must be the cross-region +**inference-profile** form, e.g. +`eu.anthropic.claude-sonnet-4-5-20250929-v1:0`. The bare +`anthropic.claude-…` id is rejected by AWS. `normalizeBedrockModel` +([llm_request_parser/middleware.go:398-414](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)) +strips the region prefix (`us.` / `eu.` / `apac.` / `global.`), an optional ARN +wrapper, and the `-YYYYMMDD-vN[:N]` version/throughput suffix so the normalised +id (`anthropic.claude-sonnet-4-5`) matches the catalog/pricing key. + +### Supported endpoints + actions + +`/model/{modelId}/{action}` where action ∈ `invoke`, +`invoke-with-response-stream`, `converse`, `converse-stream` +([llm_request_parser/middleware.go:363-390](../../../proxy/internal/middleware/builtin/llm_request_parser/middleware.go)). +`invoke` / `converse` are non-streaming; the `-stream` actions set the streaming +flag. + +- **InvokeModel** body uses the vendor-native shape — for Anthropic that means + `"anthropic_version":"bedrock-2023-05-31"` and snake_case usage with additive + cache buckets. +- **Converse** uses the unified camelCase shape with a precomputed `totalTokens`. +- The `BedrockParser` reads both shapes on the response leg + ([bedrock.go](../../../proxy/internal/llm/bedrock.go)); the request parser + doesn't need to distinguish them (`ParseRequest` is a no-op — model + stream + come from the path). + +### Streaming — AWS binary event-stream + +The `-stream` actions return `application/vnd.amazon.eventstream` (the AWS +binary event-stream framing), and streaming **is metered**. +`accumulateBedrockStream` +([llm_response_parser/streaming_bedrock.go](../../../proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go)) +decodes the frames with `aws-sdk-go-v2/aws/protocol/eventstream`: + +- InvokeModel `chunk` frames wrap a base64 `{"bytes":…}` payload carrying a + vendor-native (Anthropic) stream event — folded through the shared Anthropic + stream accumulator. +- Converse `contentBlockDelta` frames carry text; the trailing `metadata` frame + carries the final usage block. +- A truncated stream (cut at the body-tap capture cap) decodes best-effort: + frames up to the cut are applied and partial usage is returned. + +### Optional `/bedrock` gateway-namespace prefix + +Clients may place an optional `/bedrock` prefix before the native path +(`/bedrock/model/{modelId}/{action}`) to disambiguate Bedrock from other +providers that also use `/model/...`. Both the request parser +(`trimBedrockNamespace`) and the router (`splitBedrockNamespace`) accept it. +When the prefix is present, the router sets +`RewriteUpstream.StripPathPrefix = "/bedrock"` so the **native** path +(`/model/...`) is what reaches `bedrock-runtime..amazonaws.com` +([llm_router/middleware.go:168-184, 320-348](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)). + +## Model allowlist on path-routed providers + +Because the model lives in the URL rather than the body, a path-routed provider +credential could otherwise be used for any model the upstream supports. The +router still enforces the route's `Models` allowlist via `matchPathRoute` +([llm_router/middleware.go:370-416](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)): + +1. Filter to routes of the matching style (`Vertex` / `Bedrock`). +2. Filter to routes whose `AllowedGroupIDs` authorise the caller's groups + (else `no_authorised_provider`). +3. Filter to routes that **claim the requested model**. As with body-routed + providers, an **empty `Models` list = catch-all** (serve any model); + a non-empty list serves only the listed models (else `model_not_routable`). +4. Multiple survivors disambiguate by longest `UpstreamPath` prefix match. + +So an operator who lists explicit models on a Vertex/Bedrock provider gets a +hard allowlist; an operator who leaves `Models` empty accepts every model the +upstream serves (still subject to the unmeterable-publisher gate on Vertex). + +Model-less OpenAI endpoints (`GET /v1/models`) are **never** routed to a +Vertex/Bedrock provider — `matchModelless` skips path-routed routes +([llm_router/middleware.go:427-462](../../../proxy/internal/middleware/builtin/llm_router/middleware.go)) +so a model-listing call can't be rewritten onto an upstream that would 404 it. + +## Catalog ↔ pricing cross-check + +Catalog prices and context windows are cross-checked against LiteLLM's +`model_prices_and_context_window.json`. The **catalog is the source of default +prices**: management's `pricing.DefaultTable` folds every catalog provider's +models into the surfaces that provider declares (`PricingSurfaces`), so coverage +is structural rather than maintained in a parallel file +([pricing/defaults.go](../../../management/internals/modules/agentnetwork/pricing/defaults.go)). +`TestDefaultTable_CoversEveryCatalogModel` fails if a catalog model ends up +unpriced, and `TestDefaultTable_NoConflictingContributions` fails if two +providers contribute the same (surface, model) at different rates. Bedrock +entries are keyed by the **normalised** id the request parser emits (region +prefix + version suffix stripped) — management applies the same normalisation to +per-provider prices at synth time, so the two keys compare equal. Vertex Claude +carries no Bedrock-style prefix, so it prices straight off the `anthropic` +surface. + +## Things to scrutinise + +**Security.** The Vertex service-account key is never forwarded — only a minted +short-lived bearer. Confirm the key material stays out of access logs (it lives +on `ProviderRoute.GCPServiceAccountKeyB64`, not in any emitted metadata key). +The unmeterable-publisher deny is the only thing standing between an +operator-misconfigured Vertex provider and unmetered Gemini traffic; verify +`vertexPublisherVendor` stays conservative (deny by default for unknown +publishers). + +**Correctness.** `normalizeBedrockModel` is the join between the wire id and the +pricing key — a model that normalises to something absent from the shipped +pricing table meters at `cost.skipped=unknown_model` rather than failing the +request. The +`/bedrock` prefix strip must run on both the parser side (so the model is +extracted) and the router side (so the upstream path is native); a regression in +either silently breaks the other. + +**Metering caveats.** eu/apac cross-region Bedrock + Vertex profiles carry a +~10% premium not modelled by base pricing — flagged in the catalog comment. +Operators needing exact regional billing set per-provider prices on the model +rows (or replace the default entries via `AgentNetwork.PricingDefaultsFile`). + +## Cross-references + +- Router + request-parser detail: [31-proxy-middleware-builtin.md](31-proxy-middleware-builtin.md) +- Bedrock parser + pricing + SSE / event-stream: [32-proxy-llm-parsers.md](32-proxy-llm-parsers.md) +- Catalog → route synthesis + `keyfile::` handling: [21-management-agentnetwork.md](21-management-agentnetwork.md) +- Overview: [../00-overview.md](../00-overview.md) diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist index f42b6b3d2..fe10b5b63 100644 --- a/docs/io.netbird.client.plist +++ b/docs/io.netbird.client.plist @@ -66,6 +66,9 @@ disableAutoConnect + disableAutostart + + disableClientRoutes @@ -96,6 +99,10 @@ disableProfiles : hide the profile menu, reject profile CRUD. disableNetworks : hide the Networks / Exit Node menus, reject the related RPCs. + disableAdvancedView : hide the advanced-view section of the new + UI. Tristate at the daemon: set to true to + hide, false to explicitly show, omit the + key to let the UI apply its own default. disableMetricsCollection: opt out of anonymous usage telemetry. --> diff --git a/docs/netbird-macos.mobileconfig b/docs/netbird-macos.mobileconfig index 53453db5c..8216dd55d 100644 --- a/docs/netbird-macos.mobileconfig +++ b/docs/netbird-macos.mobileconfig @@ -103,6 +103,8 @@ diff --git a/docs/netbird-macos.sh b/docs/netbird-macos.sh index a2f5ff5e8..e40efd80d 100644 --- a/docs/netbird-macos.sh +++ b/docs/netbird-macos.sh @@ -58,12 +58,14 @@ preSharedKey="$NULL" # secret; redacted in log allowServerSSH='true' blockInbound="$NULL" disableAutoConnect="$NULL" +disableAutostart="$NULL" disableClientRoutes="$NULL" disableServerRoutes="$NULL" disableMetricsCollection="$NULL" disableUpdateSettings="$NULL" disableProfiles="$NULL" disableNetworks="$NULL" +disableAdvancedView="$NULL" # tristate at the daemon rosenpassEnabled="$NULL" rosenpassPermissive="$NULL" wireguardPort='51820' @@ -154,12 +156,14 @@ main() { is_set "$allowServerSSH" && emit_bool allowServerSSH "$allowServerSSH" is_set "$blockInbound" && emit_bool blockInbound "$blockInbound" is_set "$disableAutoConnect" && emit_bool disableAutoConnect "$disableAutoConnect" + is_set "$disableAutostart" && emit_bool disableAutostart "$disableAutostart" is_set "$disableClientRoutes" && emit_bool disableClientRoutes "$disableClientRoutes" is_set "$disableServerRoutes" && emit_bool disableServerRoutes "$disableServerRoutes" is_set "$disableMetricsCollection" && emit_bool disableMetricsCollection "$disableMetricsCollection" is_set "$disableUpdateSettings" && emit_bool disableUpdateSettings "$disableUpdateSettings" is_set "$disableProfiles" && emit_bool disableProfiles "$disableProfiles" is_set "$disableNetworks" && emit_bool disableNetworks "$disableNetworks" + is_set "$disableAdvancedView" && emit_bool disableAdvancedView "$disableAdvancedView" is_set "$rosenpassEnabled" && emit_bool rosenpassEnabled "$rosenpassEnabled" is_set "$rosenpassPermissive" && emit_bool rosenpassPermissive "$rosenpassPermissive" is_set "$wireguardPort" && emit_int wireguardPort "$wireguardPort" diff --git a/docs/netbird-policy.reg b/docs/netbird-policy.reg index ba4402e50..7c5090942 100644 Binary files a/docs/netbird-policy.reg and b/docs/netbird-policy.reg differ diff --git a/docs/netbird.adml b/docs/netbird.adml index d49b05022..07e6c73a6 100644 --- a/docs/netbird.adml +++ b/docs/netbird.adml @@ -24,6 +24,9 @@ Disable auto-connect When enabled, the NetBird tunnel does not auto-connect at daemon startup. Equivalent to --disable-auto-connect. + Disable autostart + When enabled, the NetBird GUI is prevented from registering itself as an OS autostart entry on fresh installs, and any existing OS autostart entry registration is removed on the next GUI launch (Windows Registry Run key, macOS Login Item, Linux .desktop). Once the admin lifts the policy, the setting stays off until the user re-enables it in Settings. + Disable client routes When enabled, this client will not consume routes advertised by routing peers. Equivalent to --disable-client-routes. @@ -60,6 +63,9 @@ Disable networks When enabled, the client UI/CLI cannot list, select or deselect NetBird networks (the corresponding daemon RPCs return Unavailable). Equivalent to --disable-networks. + Disable advanced view + When enabled, the client UI hides the advanced-view section of the new UI revision. Tristate at the daemon: 1 (enabled) hides the section; 0 (disabled) explicitly shows it; not configured leaves the UI's default behavior in place. MDM is the sole source — no equivalent CLI flag exists. + Disable metrics collection When enabled, the client does not collect or report local usage metrics. diff --git a/docs/netbird.admx b/docs/netbird.admx index 2f7645d63..747447085 100644 --- a/docs/netbird.admx +++ b/docs/netbird.admx @@ -64,6 +64,18 @@ + + + + + + + + + + + + + + 0", model) + } + require.NoError(t, gwRows.Err(), "iterate gateway usage rows") +} + +// validateAccessLogCost recomputes a live access-log row's expected total and cache cost from the +// published per-1k rates and the row's persisted token buckets, and asserts both stored values. +// Gateway-prefixed model ids the proxy deliberately does not price must store cost 0. +func validateAccessLogCost(t *testing.T, pc providerCase, row api.AgentNetworkAccessLog) { + t.Helper() + model := catalogModel(pc) + provider := "" + if row.Provider != nil { + provider = *row.Provider + } + t.Logf("[cost] %s: provider=%s model=%s in=%d out=%d total=%d cache_read=%d cache_write=%d cost=$%.6f cache_cost=$%.6f", + pc.name, provider, model, row.InputTokens, row.OutputTokens, row.TotalTokens, + row.CachedInputTokens, row.CacheCreationTokens, row.CostUsd, row.CacheCostUsd) + + rates, known := publishedPer1k[model] + if !known { + t.Logf("[cost] %s: no published rate on file for model %q (env-overridden?); skipping cost validation", pc.name, model) + return + } + + // input_tokens may legitimately be 0: Moonshot/Kimi reports fully cached prompts under the cache + // buckets only. Output and total must always be present on a priced row. + require.Positive(t, row.OutputTokens, "priced row must carry output tokens") + require.Positive(t, row.TotalTokens, "priced row must carry total tokens") + + var wantInput, wantCachedInput, wantCacheCreation float64 + if provider == "openai" { + cached := min(row.CachedInputTokens, row.InputTokens) // cached is a subset of input + wantInput = float64(row.InputTokens-cached) / 1000 * rates.in + wantCachedInput = float64(cached) / 1000 * rates.read + // OpenAI has no cache-write bucket; wantCacheCreation stays 0. + } else { + // Anthropic / Bedrock shape: cache buckets are additive to input_tokens. + wantInput = float64(row.InputTokens) / 1000 * rates.in + wantCachedInput = float64(row.CachedInputTokens) / 1000 * rates.read + wantCacheCreation = float64(row.CacheCreationTokens) / 1000 * rates.write + } + wantOutput := float64(row.OutputTokens) / 1000 * rates.out + wantCache := wantCachedInput + wantCacheCreation + wantTotal := wantInput + wantCache + wantOutput + + t.Logf("[cost] %s: expecting input=$%.6f cached_input=$%.6f cache_creation=$%.6f output=$%.6f total=$%.6f cache=$%.6f from published rates", + pc.name, wantInput, wantCachedInput, wantCacheCreation, wantOutput, wantTotal, wantCache) + assert.InDeltaf(t, wantInput, row.InputCostUsd, 1e-6, "stored input_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantCachedInput, row.CachedInputCostUsd, 1e-6, "stored cached_input_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantCacheCreation, row.CacheCreationCostUsd, 1e-6, "stored cache_creation_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantOutput, row.OutputCostUsd, 1e-6, "stored output_cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantTotal, row.CostUsd, 1e-6, "derived cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, wantCache, row.CacheCostUsd, 1e-6, "derived cache_cost_usd for %s (%s)", pc.name, model) + + // The aggregates must be exactly the sum of the stored components, not an + // independently-computed figure that could drift from the breakdown. + assert.InDeltaf(t, row.InputCostUsd+row.CachedInputCostUsd+row.CacheCreationCostUsd+row.OutputCostUsd, + row.CostUsd, 1e-9, "stored buckets must sum to the derived cost_usd for %s (%s)", pc.name, model) + assert.InDeltaf(t, row.CachedInputCostUsd+row.CacheCreationCostUsd, + row.CacheCostUsd, 1e-9, "stored cache buckets must sum to the derived cache_cost_usd for %s (%s)", pc.name, model) +} + +// providerCase is one entry in the live provider matrix. The same scenario runs +// for every available provider; availability is keyed off env vars so the suite +// covers whatever credentials are present (source ~/.llm-keys locally / set the +// Actions secrets in CI). +type providerCase struct { + name string + catalogID string + upstream string + apiKey string + model string // body model (chat/messages) or path model@version (vertex) + kind string // harness.WireChat, harness.WireMessages, or harness.WireVertex + project string // vertex only: GCP project for the rawPredict path + region string // vertex only: GCP region for the rawPredict path + pathPrefix string // base-URL path prefix the agent carries (e.g. "/anthropic" for Kimi) +} + +// availableProviders builds the matrix from the provider env vars that are set. +func availableProviders() []providerCase { + var ps []providerCase + if k := os.Getenv("OPENAI_TOKEN"); k != "" { + ps = append(ps, providerCase{name: "openai", catalogID: "openai_api", upstream: "https://api.openai.com", apiKey: k, model: "gpt-4o-mini", kind: harness.WireChat}) + } + if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" { + ps = append(ps, providerCase{name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k, model: "claude-haiku-4-5", kind: harness.WireMessages}) + } + if k := os.Getenv("KIMI_TOKEN"); k != "" { + // Kimi (Moonshot AI) serves two body shapes from the same key: OpenAI + // Chat Completions on the bare host (/v1/...) and the Anthropic + // Messages API under the /anthropic path prefix (the endpoint + // Moonshot's Claude Code guide uses). The provider keeps the bare + // default upstream and the AGENT carries the /anthropic prefix in + // its base URL — exactly the documented Claude Code / Kimi CLI + // setup (ANTHROPIC_BASE_URL=https:///anthropic) — so one + // provider serves both shapes and the prefix rides through to + // Moonshot. Run the Anthropic shape, the flagship Claude Code path; + // the OpenAI wire shape is covered live by the other chat-shaped + // matrix providers, and Kimi-over-chat passed with kimi-k3 before + // the single-model constraint surfaced (run #73 on the kimi feature + // branch). The platform serves this account exactly ONE model — + // kimi-k3 (kimi-k2-thinking and even kimi-latest return + // resource_not_found_error on both surfaces). + ps = append(ps, providerCase{name: "kimi", catalogID: "kimi_api", upstream: "https://api.moonshot.ai", apiKey: k, model: "kimi-k3", kind: harness.WireMessages, pathPrefix: "/anthropic"}) + } + if k, u := os.Getenv("VERCEL_TOKEN"), os.Getenv("VERCEL_URL"); k != "" && u != "" { + ps = append(ps, providerCase{name: "vercel", catalogID: "vercel_ai_gateway", upstream: u, apiKey: k, model: "openai/gpt-4o-mini", kind: harness.WireChat}) + } + if k, u := os.Getenv("OPENROUTER_TOKEN"), os.Getenv("OPENROUTER_URL"); k != "" && u != "" { + // Distinct model string from Vercel so each provider routes unambiguously + // while all are enabled together. + ps = append(ps, providerCase{name: "openrouter", catalogID: "openrouter", upstream: u, apiKey: k, model: "openai/gpt-4o", kind: harness.WireChat}) + } + if k, u := os.Getenv("CLOUDFLARE_TOKEN"), os.Getenv("CLOUDFLARE_URL"); k != "" && u != "" { + // Cloudflare AI Gateway routes by a provider segment in the URL path; + // append the openai provider unless the gateway URL already carries one. + if !strings.Contains(u, "/openai") { + u = strings.TrimRight(u, "/") + "/openai" + } + // Raw model (distinct string from OpenAI's gpt-4o-mini). + ps = append(ps, providerCase{name: "cloudflare", catalogID: "cloudflare_ai_gateway", upstream: u, apiKey: k, model: "gpt-4o", kind: harness.WireChat}) + } + // Vertex (vertex_ai_api): Anthropic-on-Vertex, path-routed, SA-OAuth + // (api_key = keyfile::). The model travels in the rawPredict path rather + // than the body, so the provider is created without a models array. Region + // defaults to "global" (host aiplatform.googleapis.com); a real region uses + // -aiplatform.googleapis.com. + if sa := os.Getenv("GOOGLE_VERTEX_SA_BASE64"); sa != "" { + project := os.Getenv("GOOGLE_VERTEX_PROJECT") + if project != "" { + region := os.Getenv("GOOGLE_VERTEX_REGION") + if region == "" { + region = "global" + } + host := "aiplatform.googleapis.com" + if region != "global" { + host = region + "-aiplatform.googleapis.com" + } + model := os.Getenv("GOOGLE_VERTEX_MODEL") + if model == "" { + model = "claude-sonnet-4-5@20250929" + } + ps = append(ps, providerCase{ + name: "vertex", catalogID: "vertex_ai_api", upstream: "https://" + host, + apiKey: "keyfile::" + sa, model: model, kind: harness.WireVertex, + project: project, region: region, + }) + } + } + + // Bedrock: path-routed, bearer auth. Model is the FULL cross-region + // inference-profile id exactly as AWS issues it — region-family prefix + // plus the date/version suffix. A bare or wrong-region id makes Bedrock + // reject the request with "The provided model identifier is invalid" + // before any inference runs. The proxy normalizes this id to the catalog + // key (anthropic.claude-haiku-4-5) for routing/pricing/allowlists. + // Defaults pair eu-central-1 with the eu.* profile; AWS_REGION overrides + // the region and the prefix follows its family. + if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" { + region := os.Getenv("AWS_REGION") + if region == "" { + region = "eu-central-1" + } + // A valid Bedrock inference-profile id, overridable per account (AWS_BEDROCK_MODEL, also the + // workflow's bedrock_model dispatch input). `global.` profiles work from any region. Defaults to + // Sonnet 4.6, whose id convention dropped the -YYYYMMDD-v1:0 suffix that Haiku 4.5 still carries. + model := os.Getenv("AWS_BEDROCK_MODEL") + if model == "" { + model = "global.anthropic.claude-sonnet-4-6" + } + ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: model, kind: harness.WireBedrock}) + } + return ps +} + +// providerRequest builds a create request for a matrix provider: enabled, with +// its model registered at the vendor's published rates for body-routed +// providers, and no models for the path-routed Vertex (whose model lives in the +// request path, so it prices from the defaults table management ships). +// +// The registered rates matter: management synthesizes them into the cost +// meter's per-provider-record table, which is consulted before the surface +// defaults, so these are the rates the proxy actually bills with. Registering +// the published rate keeps the cost assertions vendor-anchored while covering +// the operator-pricing path. A model with no published rate on file (an +// env-overridden Bedrock profile) falls back to a nominal rate, and +// validateAccessLogCost skips its cost check. +func providerRequest(pc providerCase) api.AgentNetworkProviderRequest { + req := api.AgentNetworkProviderRequest{ + Name: pc.name, + ProviderId: pc.catalogID, + UpstreamUrl: pc.upstream, + ApiKey: &pc.apiKey, + Enabled: ptr(true), + } + if pc.kind != harness.WireVertex { + // The router matches the normalized catalog id. Bedrock's request model + // travels as a region-prefixed inference-profile id in the URL path + // (us.anthropic...), which the router strips before matching, so register + // the normalized form here or routing fails as model_not_routable. + modelID := pc.model + if pc.kind == harness.WireBedrock { + modelID = catalogModel(pc) + } + model := api.AgentNetworkProviderModel{Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002} + if rates, known := publishedPer1k[catalogModel(pc)]; known { + model.InputPer1k = rates.in + model.OutputPer1k = rates.out + // Pin the cache rates too, rather than letting them inherit from the + // defaults table: a gateway-prefixed id has no default entry to + // inherit from, and an unset rate bills that bucket at the input + // rate, which would not match the published-rate recompute. + if rates.read > 0 { + model.CachedInputPer1k = ptr(rates.read) // OpenAI shape + model.CacheReadPer1k = ptr(rates.read) // Anthropic / Bedrock shape + } + if rates.write > 0 { + model.CacheCreationPer1k = ptr(rates.write) + } + } + req.Models = &[]api.AgentNetworkProviderModel{model} + } + return req +} + +// TestProvidersMatrix is Pillar 3: it provisions every available provider (all +// enabled, each with a unique model so routing stays unambiguous), runs proxy + +// client once, and drives the same live chat-completion scenario through each +// provider over the WireGuard tunnel. Each provider must return 200 and produce +// an ingested access-log row. +func TestProvidersMatrix(t *testing.T) { + matrix := availableProviders() + if len(matrix) == 0 { + t.Skip("no provider keys set; source ~/.llm-keys to run the provider matrix") + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + // Group + setup key the client joins into; the policy authorizes it. + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-agents"}) + require.NoError(t, err, "create agents group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // Create every provider, all enabled, each with a unique model string so the + // proxy's connect-time snapshot carries them all and model→provider routing + // is unambiguous (provider toggles after connect don't reconcile to the + // proxy, so we enable everything up front). + ids := make([]string, 0, len(matrix)) + for _, pc := range matrix { + req := providerRequest(pc) + prov, perr := srv.CreateProvider(ctx, req) + require.NoError(t, perr, "create provider %s", pc.name) + ids = append(ids, prov.Id) + id := prov.Id + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) }) + } + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-allow", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: ids, + // Token limit at the 60s window floor with caps far above the few hundred + // tokens this suite drives, so it never blocks traffic but switches on + // usage metering, which is what makes consumption rows get recorded. + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings for endpoint") + require.NotEmpty(t, settings.Endpoint, "agent-network endpoint must be assigned") + + // Proxy (global CLI token) + client, brought up once. + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-proxy") + require.NoError(t, err, "mint proxy token via CLI") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve agent-network endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + for _, pc := range matrix { + pc := pc + t.Run(pc.name, func(t *testing.T) { + before, _ := srv.ListAccessLogs(ctx) + + // Unique per provider so we can find this provider's row by its + // session id and confirm the marker propagated end-to-end. + sessionID := "e2e-session-" + pc.name + + // A long-form prompt so completions carry realistic token counts for cost validation; + // max_tokens in the harness bodies (2048) lets the full answer through. + const matrixPrompt = "explain GitHub workflow in 1000 words" + + // Retry briefly to absorb tunnel/DNS jitter on the first call. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + var c int + var b string + var cerr error + switch pc.kind { + case harness.WireVertex: + c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, matrixPrompt, sessionID) + case harness.WireBedrock: + c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, matrixPrompt, sessionID) + default: + c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, matrixPrompt, sessionID) + } + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + time.Sleep(5 * time.Second) + } + require.Equal(t, 200, code, "chat through %s (%s %s) should return 200; body: %s", pc.name, pc.kind, pc.model, body) + + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + return lerr == nil && logs.TotalRecords > before.TotalRecords + }, 30*time.Second, 2*time.Second, "an access-log row should be ingested for %s", pc.name) + + // The session id sent as x-session-id must round-trip into the + // access-log row for this provider. + var row api.AgentNetworkAccessLog + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + if lerr != nil { + return false + } + for _, r := range logs.Data { + if r.SessionId != nil && *r.SessionId == sessionID { + row = r + return true + } + } + return false + }, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row for %s", sessionID, pc.name) + + // Stored total and cache cost must match the published rates applied to the row's buckets. + validateAccessLogCost(t, pc, row) + }) + } + + // Metering: the policy's uncapped token limit switches on usage recording, + // so the live traffic just driven must surface as consumption rows with + // positive token counts. Consumption is account-scoped (keyed by source + // group / user and time window, not per provider), and ingest is async, so + // poll for any row that has booked tokens. + require.Eventually(t, func() bool { + rows, lerr := srv.ListConsumption(ctx) + if lerr != nil { + return false + } + for _, r := range rows { + if r.TokensInput > 0 && r.TokensOutput > 0 { + return true + } + } + return false + }, 60*time.Second, 3*time.Second, "consumption must be recorded with positive token counts after live traffic") + + // Final raw-SQL audit: bypass the API and re-verify every persisted usage row in the store. + verifyUsageRowsSQL(t, srv) +} diff --git a/e2e/agentnetwork/custom_pricing_test.go b/e2e/agentnetwork/custom_pricing_test.go new file mode 100644 index 000000000..90e198d3d --- /dev/null +++ b/e2e/agentnetwork/custom_pricing_test.go @@ -0,0 +1,776 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// The mock vLLM upstream (harness/vllm.go) always answers with this fixed usage +// block, so every request drives deterministic token counts regardless of the +// model the client asks for. The proxy prices off the REQUEST model, not the +// upstream response model, so a made-up model id billed at operator rates lets +// these tests assert exact costs without a real vendor key. +// Sourced from the harness so the counts can't drift from the mock's config. +const ( + vllmPromptTokens = harness.VLLMChatInputTokens + vllmCompletionTokens = harness.VLLMChatOutputTokens +) + +// pricedEnv is a connected single-provider agent-network deployment pointed at +// the mock vLLM upstream, with the proxy and client up and the endpoint resolved +// — ready to drive chat. All containers are torn down via t.Cleanup. +type pricedEnv struct { + providerID string + groupID string // source group of the policy; the client peer's auto-group + policyID string // policy that authorises (and meters) the requests + upstream string // provider upstream URL, needed to re-send on a PUT update + endpoint string + proxyIP string + client *harness.Client + proxy *harness.Proxy +} + +// provisionPricedProvider brings up the full path for a cost test: a mock vLLM +// upstream, a group + reusable setup key, one openai_api provider pointed at the +// mock enumerating exactly the given models (with the operator's per-1k prices), +// a policy whose token limit switches on usage metering, and a connected proxy + +// client. The provider is created with the given models so the router dispatches +// them to this provider and the cost meter bills at these rates. +// +// Passing nil models makes it a gateway-style catch-all: the router claims every +// model, and since the synthesizer ships no per-provider-record pricing entry +// for a provider that enumerates nothing, the shipped defaults table is the only +// thing that can price the request. The policy sets no model guardrail, so the +// proxy's per-provider allowlist backstop stays empty and any model routes. +func provisionPricedProvider(t *testing.T, ctx context.Context, name string, models []api.AgentNetworkProviderModel) pricedEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock vLLM upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-price-" + name}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-price-" + name + "-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // The mock ignores auth, so a dummy key satisfies the "Bearer ${API_KEY}" + // template. openai_api is a known catalog provider; the enumerated model id + // need NOT be in the catalog — the operator names it and prices it here. + dummyKey := "sk-price-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &models, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // Uncapped token limit: never blocks the handful of tokens driven here, but + // switches on usage metering — the switch that makes consumption rows record. + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-price-" + name, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-price-"+name+"-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint and its first packet wakes the + // lazy proxy peer, so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + return pricedEnv{ + providerID: prov.Id, + groupID: grp.Id, + policyID: pol.Id, + upstream: vllm.URL, + endpoint: settings.Endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + } +} + +// chatOnce drives one OpenAI-shaped chat for model through the tunnel, retrying +// to absorb first-call tunnel/DNS jitter, and returns the response body. +func chatOnce(t *testing.T, ctx context.Context, env pricedEnv, model, sessionID string) string { + t.Helper() + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, cerr := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, model, "Reply with exactly: pong", sessionID) + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + if !waitBeforeRetry(ctx, 5*time.Second) { + break + } + } + require.Equal(t, 200, code, + "chat for %s must return 200; body: %s\n=== proxy logs ===\n%s", model, body, env.proxy.Logs(context.Background())) + return body +} + +// accessLogIngestWindow is how long a single request's access-log row is given +// to appear before the caller gives up on it. +// accessLogIngestWindow bounds how long a row may take to appear after its +// request returned. The proxy streams each entry to management with a 10s send +// timeout of its own, so a request whose send hits one full timeout and is +// retried has not yet missed anything real — 30s left barely three send +// attempts of headroom and lost the race on a loaded runner. +const accessLogIngestWindow = 60 * time.Second + +// accessLogPollInterval is how long the lookup waits between pages. Ingest is +// asynchronous, so the row lands somewhere inside the window rather than on +// any particular poll. +const accessLogPollInterval = 2 * time.Second + +// lookupAccessLogBySession polls the access-log page for the row carrying +// sessionID and reports whether it arrived within the window. It never fails +// the test: callers that can recover — by firing a fresh request under a new +// session — need to see the miss rather than die on it. +func lookupAccessLogBySession(ctx context.Context, sessionID string, within time.Duration) (api.AgentNetworkAccessLog, bool) { + deadline := time.Now().Add(within) + for { + // Each poll is bounded by what is left of the window rather than by the + // caller's context: a single stalled request would otherwise hold the + // loop open long past the ingest window it is meant to enforce, and the + // caller would read the delay as a missing row. + if logs, lerr := listAccessLogsBy(ctx, deadline); lerr == nil { + for _, r := range logs.Data { + if r.SessionId != nil && *r.SessionId == sessionID { + return r, true + } + } + } + // The wait is bounded by the window as well, so the answer arrives when + // the caller's budget runs out rather than a poll interval later: a + // full interval slept past the deadline reports "no row" up to two + // seconds late, which reads as a slower lookup than the one asked for. + wait := time.Until(deadline) + if wait > accessLogPollInterval { + wait = accessLogPollInterval + } + if wait <= 0 { + return api.AgentNetworkAccessLog{}, false + } + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + return api.AgentNetworkAccessLog{}, false + case <-timer.C: + } + // Checked after the wait rather than before the request: a poll issued + // past the deadline carries no budget and would fail on arrival. + if !time.Now().Before(deadline) { + return api.AgentNetworkAccessLog{}, false + } + } +} + +// listAccessLogsBy fetches one access-log page under a context that expires at +// deadline, so no single call can outlive the window its caller is polling +// within. The parent's cancellation still applies: the child inherits it. +func listAccessLogsBy(ctx context.Context, deadline time.Time) (api.AgentNetworkAccessLogsResponse, error) { + reqCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + return srv.ListAccessLogs(reqCtx) +} + +// findAccessLogBySession polls the access-log page for the row carrying +// sessionID, failing the test if it never lands. Use it for a request whose row +// must exist; where a missing row is a recoverable race, use +// lookupAccessLogBySession and retry. +func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog { + t.Helper() + row, ok := lookupAccessLogBySession(ctx, sessionID, accessLogIngestWindow) + require.True(t, ok, "session id %q must be recorded in an access-log row", sessionID) + return row +} + +// assertOpenAICostAtRates asserts an access-log row's token counts and every cost +// bucket match the mock's fixed usage priced at the given operator rates. The +// openai surface has no cache-write bucket and the mock reports no cache tokens, +// so the whole cost is input + output; cache costs must be exactly zero. +func assertOpenAICostAtRates(t *testing.T, row api.AgentNetworkAccessLog, inRate, outRate float64) { + t.Helper() + wantInput := float64(vllmPromptTokens) / 1000 * inRate + wantOutput := float64(vllmCompletionTokens) / 1000 * outRate + wantTotal := wantInput + wantOutput + + model := "" + if row.Model != nil { + model = *row.Model + } + t.Logf("[cost] model=%s in=%d out=%d rates in/out=%.4f/%.4f stored input/output/total=$%.6f/$%.6f/$%.6f expected input/output/total=$%.6f/$%.6f/$%.6f", + model, row.InputTokens, row.OutputTokens, inRate, outRate, + row.InputCostUsd, row.OutputCostUsd, row.CostUsd, wantInput, wantOutput, wantTotal) + + assert.EqualValues(t, vllmPromptTokens, row.InputTokens, "prompt tokens from the mock usage block") + assert.EqualValues(t, vllmCompletionTokens, row.OutputTokens, "completion tokens from the mock usage block") + assert.InDeltaf(t, wantInput, row.InputCostUsd, 1e-6, "input_cost_usd must be prompt tokens at the operator input rate") + assert.InDeltaf(t, wantOutput, row.OutputCostUsd, 1e-6, "output_cost_usd must be completion tokens at the operator output rate") + assert.InDeltaf(t, wantTotal, row.CostUsd, 1e-6, "cost_usd must be the sum of the priced buckets") + assert.Zerof(t, row.CachedInputCostUsd, "no cache-read tokens, so cached_input_cost_usd must be 0") + assert.Zerof(t, row.CacheCreationCostUsd, "openai surface has no cache-write bucket, so cache_creation_cost_usd must be 0") + assert.Zerof(t, row.CacheCostUsd, "no cache usage, so cache_cost_usd must be 0") + assert.InDeltaf(t, row.InputCostUsd+row.OutputCostUsd, row.CostUsd, 1e-9, "stored buckets must sum to cost_usd") +} + +// verifyUsageRowForSession re-checks the persisted usage row for a session +// directly in the management sqlite store — the same audit an operator runs on a +// production store.db — asserting its cost buckets match the operator rates. +func verifyUsageRowForSession(t *testing.T, sessionID string, inRate, outRate float64) { + t.Helper() + dbPath, err := srv.SnapshotStoreDB(t.TempDir()) + require.NoError(t, err, "snapshot management sqlite store") + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err, "open store snapshot") + sqlDB, err := db.DB() + require.NoError(t, err) + defer func() { _ = sqlDB.Close() }() + + var provider, model string + var inTok, outTok, cachedTok, cacheCreateTok int64 + var inCost, cachedInCost, cacheCreateCost, outCost float64 + row := db.Raw(`SELECT provider, model, input_tokens, output_tokens, cached_input_tokens, cache_creation_tokens, + input_cost_usd, cached_input_cost_usd, cache_creation_cost_usd, output_cost_usd + FROM agent_network_request_usage WHERE session_id = ? ORDER BY timestamp DESC LIMIT 1`, sessionID).Row() + require.NoError(t, row.Scan(&provider, &model, &inTok, &outTok, &cachedTok, &cacheCreateTok, + &inCost, &cachedInCost, &cacheCreateCost, &outCost), + "a usage row must exist for session %q", sessionID) + + wantInput := float64(inTok) / 1000 * inRate + wantOutput := float64(outTok) / 1000 * outRate + t.Logf("[sql] session=%s %s/%s in=%d out=%d stored input/cached/create/output=$%.6f/$%.6f/$%.6f/$%.6f", + sessionID, provider, model, inTok, outTok, inCost, cachedInCost, cacheCreateCost, outCost) + assert.EqualValues(t, vllmPromptTokens, inTok, "usage row prompt tokens") + assert.EqualValues(t, vllmCompletionTokens, outTok, "usage row completion tokens") + assert.InDeltaf(t, wantInput, inCost, 1e-6, "usage input_cost_usd must be prompt tokens at the operator input rate") + assert.InDeltaf(t, wantOutput, outCost, 1e-6, "usage output_cost_usd must be completion tokens at the operator output rate") + assert.Zerof(t, cachedInCost, "usage cached_input_cost_usd must be 0 (no cache usage)") + assert.Zerof(t, cacheCreateCost, "usage cache_creation_cost_usd must be 0 (no cache usage)") +} + +// TestCustomModelPricing proves an operator can serve a model that is NOT in +// NetBird's compiled catalog, at prices they type themselves, and that those +// operator prices drive the recorded cost end to end — access log AND usage +// ledger. The provider enumerates one made-up model id at deliberately odd rates +// (no default entry could supply them), the client requests it, and every cost +// bucket must equal the mock's fixed token counts multiplied by those rates. +func TestCustomModelPricing(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + customModel = "e2e-custom-model" // absent from the compiled catalog + inRate = 0.037 // odd rates so a stray default can't match + outRate = 0.089 + ) + + env := provisionPricedProvider(t, ctx, "custommodel", []api.AgentNetworkProviderModel{ + {Id: customModel, InputPer1k: inRate, OutputPer1k: outRate}, + }) + + sessionID := "e2e-session-custommodel" + body := chatOnce(t, ctx, env, customModel, sessionID) + require.Contains(t, body, "chat.completion", "body should be an OpenAI-compatible completion; got: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + require.NotNil(t, row.Model, "access-log row must carry the requested model") + assert.Equal(t, customModel, *row.Model, "the row must be stamped with the requested (custom) model, not the mock's response model") + assertOpenAICostAtRates(t, row, inRate, outRate) + + // Metering: the uncapped token limit switches on usage recording, so the + // request must surface as a consumption row with positive tokens and cost. + require.Eventually(t, func() bool { + rows, lerr := srv.ListConsumption(ctx) + if lerr != nil { + return false + } + for _, r := range rows { + if r.TokensInput > 0 && r.TokensOutput > 0 && r.CostUsd > 0 { + return true + } + } + return false + }, 60*time.Second, 3*time.Second, "custom-model usage must be metered into a consumption row with positive cost") + + // Final raw-SQL audit: bypass the API and re-verify the persisted usage row. + verifyUsageRowForSession(t, sessionID, inRate, outRate) +} + +// TestPriceChangeUpdatesRecordedCost proves that changing a provider's model +// price is reflected in the cost recorded for subsequent requests — in both the +// access log and the usage ledger — while requests already priced at the old +// rate keep their original cost. The update propagates to the connected proxy +// live (a mapping push rebuilds the cost_meter chain with the new table), so no +// reconnect or restart is needed; the test polls a fresh request until the new +// rate lands. +func TestPriceChangeUpdatesRecordedCost(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + customModel = "e2e-repriced-model" + inRateA = 0.010 + outRateA = 0.020 + inRateB = 0.050 // 5x / 4x the original, so a repriced row is unmistakable + outRateB = 0.080 + // Per-attempt ingest wait, shorter than the default so a request that + // produces no row costs one retry rather than most of the budget, and an + // overall deadline long enough to hold several attempts. + repriceIngestWindow = 20 * time.Second + repriceDeadline = 180 * time.Second + ) + + env := provisionPricedProvider(t, ctx, "reprice", []api.AgentNetworkProviderModel{ + {Id: customModel, InputPer1k: inRateA, OutputPer1k: outRateA}, + }) + + // Phase 1 — request priced at the original rate A. + sessionA := "e2e-session-reprice-a" + chatOnce(t, ctx, env, customModel, sessionA) + rowA := findAccessLogBySession(t, ctx, sessionA) + assertOpenAICostAtRates(t, rowA, inRateA, outRateA) + verifyUsageRowForSession(t, sessionA, inRateA, outRateA) + + // Change the model's price. The API key is omitted so the stored one is kept; + // the models array is re-sent with the new rates (PUT replaces the list). + // This reconciles synchronously and pushes a fresh cost_meter table to the + // already-connected proxy — no reconnect. + _, err := srv.UpdateProvider(ctx, env.providerID, api.AgentNetworkProviderRequest{ + Name: "reprice", + ProviderId: "openai_api", + UpstreamUrl: env.upstream, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: customModel, InputPer1k: inRateB, OutputPer1k: outRateB}, + }, + }) + require.NoError(t, err, "update provider price") + + // Phase 2 — the push + chain rebuild is async, so drive fresh requests (each + // under its own session) until one is priced at the new rate B. Each iteration + // fires one request and waits for that session's row to be ingested before + // reading its cost, so an un-ingested row is never mistaken for "still rate A". + // The expected new input cost is unmistakably higher than rate A, so a + // lingering old-rate row can't satisfy the check. + // + // Every way an iteration can come up short — the request failing, its row not + // landing, or the row still carrying rate A — is a symptom of the same + // in-flight rebuild, so each one retries under a fresh session rather than + // ending the test. Only the outer deadline is fatal. + wantInputB := float64(vllmPromptTokens) / 1000 * inRateB + var repriced api.AgentNetworkAccessLog + var lastSession string + // The cost last read, kept separately: repriced is the zero value on every + // path that gives up, so reporting its cost would say "$0.000000" whether + // the rows were still at rate A or no row was ever read. + var lastCost float64 + var sawRow bool + deadline := time.Now().Add(repriceDeadline) + // Everything inside the loop runs under the deadline rather than the + // test's own context. An attempt started just before it would otherwise + // run well past it: the chat container is capped at 90s of its own and the + // row lookup at another 20s, so the loop could report a repricing failure + // nearly two minutes after the window it was given had closed. + repriceCtx, cancelReprice := context.WithDeadline(ctx, deadline) + defer cancelReprice() + for time.Now().Before(deadline) { + lastSession = fmt.Sprintf("e2e-session-reprice-b-%d", time.Now().UnixNano()) + code, _, cerr := env.client.Chat(repriceCtx, env.endpoint, env.proxyIP, harness.WireChat, customModel, "Reply with exactly: pong", lastSession) + if cerr != nil || code != 200 { + if !waitBeforeRetry(repriceCtx, 5*time.Second) { + break + } + continue + } + row, ok := lookupAccessLogBySession(repriceCtx, lastSession, repriceIngestWindow) + if !ok { + // No row for this request. The proxy now publishes a rebuilt chain + // before the route that reaches it, so a request can no longer be + // served unattributed mid-update; this retry covers the ingest + // window alone. Fire another one under a fresh session. + t.Logf("no access-log row for session %q within %s; retrying under a fresh session", lastSession, repriceIngestWindow) + continue + } + if inDelta(row.InputCostUsd, wantInputB, 1e-6) { + repriced = row + break + } + // Still priced at the old rate — the push hasn't landed yet; retry. + lastCost, sawRow = row.InputCostUsd, true + if !waitBeforeRetry(repriceCtx, 5*time.Second) { + break + } + } + lastSeen := "no row was ever read" + if sawRow { + lastSeen = fmt.Sprintf("last input_cost_usd=$%.6f", lastCost) + } + require.NotEmpty(t, repriced.Id, "a request after the price change must be priced at the new rate B; %s, wanted $%.6f\n=== proxy logs ===\n%s", + lastSeen, wantInputB, env.proxy.Logs(context.Background())) + + assertOpenAICostAtRates(t, repriced, inRateB, outRateB) + verifyUsageRowForSession(t, lastSession, inRateB, outRateB) + + // The original request keeps its original cost: repricing is not retroactive. + rowAStill := findAccessLogBySession(t, ctx, sessionA) + assertOpenAICostAtRates(t, rowAStill, inRateA, outRateA) + verifyUsageRowForSession(t, sessionA, inRateA, outRateA) +} + +// TestPricingDefaultsFileDrivesCost proves the operator-supplied pricing +// defaults file is what the proxy bills with. The harness configures +// server.agentNetwork.pricingDefaultsFile as a BARE FILENAME and writes that +// file into the bind-mounted datadir (see harness.PricingDefaultsFileName), so a +// pass exercises the whole chain: combined yaml → ToManagementConfig → +// pricing.LoadFile (relative path resolved against datadir) → DefaultTable → +// the synthesizer's cost_meter defaults payload → the proxy's lookup. +// +// The provider enumerates NO models, so it is a catch-all route with no +// per-provider-record pricing entry at all — the only rates that can price the +// request are the shipped defaults. The model is a real catalog model whose +// built-in rates the file replaces with deliberately odd values, so billing at +// the compiled-in rates (i.e. the file never loaded) fails the assertions. +func TestPricingDefaultsFileDrivesCost(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + // nil models: a gateway-style provider claiming every model. The synthesizer + // ships no per-record entry for it, so the defaults table is its price list. + env := provisionPricedProvider(t, ctx, "defaultsfile", nil) + + sessionID := "e2e-session-defaultsfile" + body := chatOnce(t, ctx, env, harness.PricedDefaultModel, sessionID) + require.Contains(t, body, "chat.completion", "body should be an OpenAI-compatible completion; got: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + require.NotNil(t, row.Model, "access-log row must carry the requested model") + assert.Equal(t, harness.PricedDefaultModel, *row.Model, "the row must be stamped with the requested model") + + // The file's rates, not the compiled-in catalog rates for this model. + assertOpenAICostAtRates(t, row, harness.PricedDefaultInputPer1k, harness.PricedDefaultOutputPer1k) + verifyUsageRowForSession(t, sessionID, harness.PricedDefaultInputPer1k, harness.PricedDefaultOutputPer1k) +} + +// TestPricingDefaultsFileLeavesOtherModelsAlone proves the defaults file merges +// per entry rather than replacing the whole table: the file names exactly one +// model, so a DIFFERENT catalog model must still bill at its compiled-in rates. +// Without this, a file that shipped as a wholesale replacement would silently +// zero-cost every model the operator didn't list, and TestPricingDefaultsFile- +// DrivesCost alone would not notice. +func TestPricingDefaultsFileLeavesOtherModelsAlone(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + // gpt-4o-mini is a catalog model the pricing file does NOT mention, so it must + // keep its built-in rates. Pinned here independently of the catalog source so + // a rate change in either place surfaces as a failure to reconcile rather + // than passing silently. + const ( + untouchedModel = "gpt-4o-mini" + builtinInRate = 0.00015 + builtinOutRate = 0.0006 + ) + + env := provisionPricedProvider(t, ctx, "defaultsfileother", nil) + + sessionID := "e2e-session-defaultsfile-other" + chatOnce(t, ctx, env, untouchedModel, sessionID) + + row := findAccessLogBySession(t, ctx, sessionID) + assertOpenAICostAtRates(t, row, builtinInRate, builtinOutRate) + verifyUsageRowForSession(t, sessionID, builtinInRate, builtinOutRate) +} + +// TestCustomModelAccessLogAttribution proves a custom (non-catalog) model is +// handled correctly in the ACCESS LOG, not just in the cost columns. The other +// tests here assert money; this one asserts the row's identity and attribution +// dimensions — the columns the dashboard filters, groups and drills down on. +// +// A custom model id is the interesting case precisely because nothing in +// NetBird's catalog describes it. Its provider vendor, parser surface, cost +// buckets, and dashboard filterability all have to come from the operator's +// provider record rather than from a compiled-in entry. So this checks: +// +// - the row is stamped with the REQUESTED model id verbatim, not the mock +// upstream's response model (Qwen/Qwen2.5-0.5B-Instruct) and not a +// normalized or catalog-substituted id; +// - provider is the vendor SURFACE ("openai", from the catalog entry's +// ParserID) — a custom model does not change which wire shape was spoken; +// - resolved_provider_id / selected_policy_id / group_ids attribute the row to +// the operator's provider record, the authorising policy, and the caller's +// group, so spend on a custom model is attributable; +// - decision is "allow" with no deny reason, and the request dimensions +// (status 200, POST, the OpenAI chat path, non-stream, source IP, duration) +// are recorded; +// - management's SERVER-SIDE model filter finds the row by its custom id, so +// the model column is genuinely indexed and queryable rather than merely +// stored; +// - prompt/completion capture stays empty, since prompt collection is off by +// default and a custom model must not bypass that gate. +func TestCustomModelAccessLogAttribution(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + // A model id no catalog entry carries, at odd rates so its cost cannot come + // from anywhere but the provider record. + const ( + customModel = "e2e-attribution-model-v9" + inRate = 0.0271 + outRate = 0.0913 + ) + + env := provisionPricedProvider(t, ctx, "attribution", []api.AgentNetworkProviderModel{ + {Id: customModel, InputPer1k: inRate, OutputPer1k: outRate}, + }) + + sessionID := "e2e-session-attribution" + body := chatOnce(t, ctx, env, customModel, sessionID) + require.Contains(t, body, "chat.completion", "body should be an OpenAI-compatible completion; got: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + + // Identity: the requested model verbatim. The mock answers with its own + // served model id, so a row carrying that instead means the log is sourced + // from the response body rather than the parsed request. + require.NotNil(t, row.Model, "access-log row must carry the requested model") + assert.Equal(t, customModel, *row.Model, + "the row must be stamped with the requested custom model id verbatim, not the mock upstream's response model (%s)", harness.VLLMModel) + + // Surface: a custom model id does not change the wire shape that was spoken. + // provider is the vendor surface from the catalog entry's parser, which is + // also the key the cost meter's cache formula switches on. + require.NotNil(t, row.Provider, "access-log row must carry the vendor surface") + assert.Equal(t, "openai", *row.Provider, + "openai_api's parser surface is openai, regardless of how exotic the model id is") + + // Attribution: which provider record served it, which policy authorised it, + // and which group the authorisation came through. Without these, spend on a + // custom model can be seen but not attributed. + require.NotNil(t, row.ResolvedProviderId, "row must name the provider record that served the request") + assert.Equal(t, env.providerID, *row.ResolvedProviderId, + "the router stamps the operator's provider record id; a custom model must attribute to the record that enumerated it") + require.NotNil(t, row.SelectedPolicyId, "row must name the policy that authorised the request") + assert.Equal(t, env.policyID, *row.SelectedPolicyId, + "the policy carrying the token limit is the one that paid for the request") + require.NotNil(t, row.GroupIds, "row must carry the authorising group ids") + assert.Contains(t, *row.GroupIds, env.groupID, + "the caller's group is the policy's source group, so it must be the authorising group") + + // Decision + request dimensions. + require.NotNil(t, row.Decision, "row must carry the policy decision") + assert.Equal(t, "allow", *row.Decision, "the uncapped policy allows this request") + if row.DenyReason != nil { + assert.Empty(t, *row.DenyReason, "an allowed request must carry no deny reason") + } + assert.Equal(t, 200, row.StatusCode, "the mock upstream answers 200") + if row.Method != nil { + assert.Equal(t, "POST", *row.Method, "a chat completion is a POST") + } + require.NotNil(t, row.Path, "row must record the request path") + assert.Equal(t, "/v1/chat/completions", *row.Path, + "the OpenAI chat path the client called, as seen by the proxy") + require.NotNil(t, row.Host, "row must record the host the client addressed") + assert.Equal(t, env.endpoint, *row.Host, "the agent-network endpoint the client resolved") + if row.Stream != nil { + assert.False(t, *row.Stream, "the harness sends a non-streaming request") + } + require.NotNil(t, row.SourceIp, "row must record the caller's tunnel IP") + assert.NotEmpty(t, *row.SourceIp, "the request arrived over the tunnel, so a source IP is known") + + // Tokens and cost, so the attribution above is anchored to a real priced row + // rather than an empty shell that happens to carry the right ids. + assertOpenAICostAtRates(t, row, inRate, outRate) + assert.EqualValues(t, vllmPromptTokens+vllmCompletionTokens, row.TotalTokens, + "total_tokens is the mock's reported total") + + // Prompt capture is off by default (account master switch), and a custom + // model must not bypass that gate. + if row.RequestPrompt != nil { + assert.Empty(t, *row.RequestPrompt, "prompt collection is off by default, so no prompt may be stored") + } + if row.ResponseCompletion != nil { + assert.Empty(t, *row.ResponseCompletion, "prompt collection is off by default, so no completion may be stored") + } + + // Queryability: management's SERVER-SIDE model filter must find the row by + // its custom id. findAccessLogBySession above scans a page client-side, so + // this is the check that the model column is actually indexed and filterable + // — the dashboard's per-model drill-down on a custom model depends on it. + filtered, err := srv.ListAccessLogsFiltered(ctx, url.Values{"model": []string{customModel}}) + require.NoError(t, err, "filter access logs by the custom model id") + require.Positive(t, filtered.TotalRecords, "the custom model must be findable via the server-side model filter") + foundSession := false + for _, r := range filtered.Data { + require.NotNil(t, r.Model, "filtered row must carry a model") + assert.Equal(t, customModel, *r.Model, "the model filter must not return rows for other models") + if r.SessionId != nil && *r.SessionId == sessionID { + foundSession = true + } + } + assert.True(t, foundSession, "the filtered page must include this test's request") + + // Final raw-SQL audit of the parallel usage row: the ledger must carry the + // same custom model, surface, and provider-record attribution as the log. + verifyUsageAttributionForSession(t, sessionID, customModel, "openai", env.providerID, env.groupID) +} + +// verifyUsageAttributionForSession checks the usage ledger's attribution columns +// for a session directly in the management sqlite store — including the group +// child row, which the API renders but which only exists if the proxy's +// authorising-group CSV was parsed into normalised rows. The usage table is +// written unconditionally (independent of the log-collection toggle), so this is +// the record that must attribute spend even for accounts with logs off. +func verifyUsageAttributionForSession(t *testing.T, sessionID, wantModel, wantProvider, wantProviderID, wantGroupID string) { + t.Helper() + dbPath, err := srv.SnapshotStoreDB(t.TempDir()) + require.NoError(t, err, "snapshot management sqlite store") + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err, "open store snapshot") + sqlDB, err := db.DB() + require.NoError(t, err) + defer func() { _ = sqlDB.Close() }() + + var id, provider, model, resolvedProviderID, userID string + require.NoError(t, db.Raw( + `SELECT id, provider, model, resolved_provider_id, user_id + FROM agent_network_request_usage WHERE session_id = ? ORDER BY timestamp DESC LIMIT 1`, sessionID). + Row().Scan(&id, &provider, &model, &resolvedProviderID, &userID), + "a usage row must exist for session %q", sessionID) + + t.Logf("[sql] usage attribution session=%s id=%s provider=%s model=%s resolved_provider_id=%s user_id=%s", + sessionID, id, provider, model, resolvedProviderID, userID) + assert.Equal(t, wantModel, model, "usage row must carry the requested custom model") + assert.Equal(t, wantProvider, provider, "usage row must carry the vendor surface") + assert.Equal(t, wantProviderID, resolvedProviderID, "usage row must attribute to the operator's provider record") + assert.NotEmpty(t, userID, "the tunnel peer resolves to a principal, so the usage row must be attributable to it") + + // The authorising group lands in the normalised child table, which is what + // the usage overview joins on to break spend down by group. + var groupIDs []string + require.NoError(t, db.Raw( + `SELECT group_id FROM agent_network_request_usage_group WHERE usage_id = ?`, id). + Scan(&groupIDs).Error, "read usage group child rows") + assert.Contains(t, groupIDs, wantGroupID, + "the authorising group must be normalised into a usage_group row so spend can be grouped by it") +} + +// inDelta reports whether a and b are within tol of each other. +func inDelta(a, b, tol float64) bool { + d := a - b + if d < 0 { + d = -d + } + return d <= tol +} + +// TestCustomDatedModelKeepsItsOwnPrice covers the review fix that anchored the +// release-date fallback to Claude ids. Pricing looks every model up through +// that helper, so while it matched a bare trailing date any operator id ending +// in eight digits inherited the rate of its undated sibling — a silent +// mis-bill on models NetBird knows nothing about. +func TestCustomDatedModelKeepsItsOwnPrice(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + baseModel = "internal-llm" + datedModel = "internal-llm-20250101" + baseIn = 0.010 + baseOut = 0.020 + // An order of magnitude apart, so a row billed at the wrong entry is + // unmistakable rather than a rounding argument. + datedIn = 0.100 + datedOut = 0.200 + ) + + env := provisionPricedProvider(t, ctx, "customdated", []api.AgentNetworkProviderModel{ + {Id: baseModel, InputPer1k: baseIn, OutputPer1k: baseOut}, + {Id: datedModel, InputPer1k: datedIn, OutputPer1k: datedOut}, + }) + + t.Run("the undated id bills at its own rate", func(t *testing.T) { + session := fmt.Sprintf("e2e-session-customdated-base-%d", time.Now().UnixNano()) + chatOnce(t, ctx, env, baseModel, session) + assertOpenAICostAtRates(t, findAccessLogBySession(t, ctx, session), baseIn, baseOut) + }) + + t.Run("the dated id keeps its own rate", func(t *testing.T) { + session := fmt.Sprintf("e2e-session-customdated-dated-%d", time.Now().UnixNano()) + chatOnce(t, ctx, env, datedModel, session) + row := findAccessLogBySession(t, ctx, session) + assertOpenAICostAtRates(t, row, datedIn, datedOut) + + // Spelled out because it is the regression: inheriting the sibling's + // rate would bill this request at a tenth of its price. + assert.Greater(t, row.InputCostUsd, float64(vllmPromptTokens)/1000*baseIn*2, + "a custom dated id must not inherit the undated entry's rate") + }) +} diff --git a/e2e/agentnetwork/discovery_live_test.go b/e2e/agentnetwork/discovery_live_test.go new file mode 100644 index 000000000..22c9f31c2 --- /dev/null +++ b/e2e/agentnetwork/discovery_live_test.go @@ -0,0 +1,447 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "encoding/json" + "os" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + sharedllm "github.com/netbirdio/netbird/shared/llm" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestLiveModelDiscovery drives model discovery against the REAL vendor +// endpoints — OpenAI, Anthropic, Bedrock and Vertex — rather than the mock. +// +// The mock upstream proves the filter's mechanics: it advertises ids we chose, +// so a listing narrowing to the ones we authorised is arithmetic we already +// controlled both sides of. What it cannot prove is that the filter survives +// contact with a real catalogue — ids we never enumerated, dated builds whose +// suffix the vendor picks, surfaces that answer a listing request with +// something other than a listing. That is what this covers, and it is the part +// a QA engineer would otherwise have to walk through by hand. +// +// One proxy serves every case. Each provider gets its own group, policy and +// client, because a model-less request matches exactly ONE route +// (matchModelless): with two providers authorised for the same caller, the +// listing would go to whichever won the tiebreak and the other would go +// untested. Group-scoping the caller makes each provider the only candidate +// for its own client. +func TestLiveModelDiscovery(t *testing.T) { + cases := liveDiscoveryCases() + if len(cases) == 0 { + t.Skip("no provider keys set; source ~/.llm-keys to run live model discovery") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + t.Logf("[discovery] live matrix: %s", strings.Join(caseNames(cases), ", ")) + + // Provision every provider, group and policy before the proxy starts: the + // proxy takes a configuration snapshot at connect time and does not + // reconcile provider changes made afterwards. + keys := make(map[string]string, len(cases)) + for i := range cases { + keys[cases[i].name] = provisionLiveDiscovery(t, ctx, &cases[i]) + } + + endpoint, firstIP, firstClient, px := connectClient(t, ctx, "disc-live", keys[cases[0].name]) + clients := map[string]*harness.Client{cases[0].name: firstClient} + ips := map[string]string{cases[0].name: firstIP} + for _, tc := range cases[1:] { + cl := joinClient(t, ctx, px, endpoint, keys[tc.name]) + ip, err := cl.ResolveProxyIP(ctx, endpoint) + require.NoError(t, err, "resolve endpoint from the %s client", tc.name) + clients[tc.name] = cl + ips[tc.name] = ip + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + runLiveDiscoveryCase(t, ctx, tc, clients[tc.name], endpoint, ips[tc.name]) + }) + } +} + +// discoveryOutcome is what a discovery request must produce end to end. The +// three are genuinely different contracts, not degrees of success: only the +// first puts a bounded listing in front of the caller. +type discoveryOutcome int + +const ( + // outcomeFiltered: the proxy routes the request and bounds the response to + // what the caller may use. + outcomeFiltered discoveryOutcome = iota + // outcomeDenied: no provider of this shape can serve the surface, so the + // proxy refuses rather than rewriting the request onto an upstream that + // would 404 it. The caller gets a NetBird error, not a vendor one. + outcomeDenied + // outcomeUpstreamNoListing: the proxy routes the request to the configured + // upstream, and the vendor does not implement the endpoint there. Proxy + // side correct, product side a dead end — see the Bedrock case. + outcomeUpstreamNoListing +) + +// liveDiscoveryCase is one provider's discovery surface and what the proxy +// must make of it. +type liveDiscoveryCase struct { + name string + catalogID string + upstream string + apiKey string + + // path is the discovery endpoint the client calls. Not every surface uses + // /v1/models: Bedrock lists inference profiles instead. + path string + // headers the vendor requires on a bare GET (Anthropic versions its API + // through a header, and rejects a request without one). + headers []string + + // models the provider record enumerates. Empty models a gateway record, + // which enumerates nothing and claims everything. + models []string + // allowlist, when non-empty, is a guardrail narrowing the policy below the + // provider's own enumeration — the second of the two bounds discovery + // applies, and the only one a provider record alone cannot demonstrate. + allowlist []string + + // outcome is what this surface must produce end to end. + outcome discoveryOutcome + + // permitted is every id allowed to survive filtering, in the form the + // provider record registers it. A surviving id counts as permitted when it + // matches one of these outright or after Anthropic date-normalisation. + permitted []string + // wantHidden are ids the upstream is known to advertise and the bound must + // remove. Only set where we enumerate the model ourselves, so the + // expectation cannot rot when a vendor changes its catalogue. + wantHidden []string +} + +// liveDiscoveryCases builds the matrix from whichever provider credentials are +// present, mirroring availableProviders' env-var gating so a partial key set +// still yields partial coverage. +func liveDiscoveryCases() []liveDiscoveryCase { + var cases []liveDiscoveryCase + + // OpenAI enumerates TWO real models and the policy permits one. That is + // the only case here where both bounds are observable at once: the + // upstream advertises dozens of ids, the provider record cuts them to two, + // and the guardrail cuts those to one. + if k := os.Getenv("OPENAI_TOKEN"); k != "" { + cases = append(cases, liveDiscoveryCase{ + name: "openai", catalogID: "openai_api", upstream: "https://api.openai.com", apiKey: k, + path: "/v1/models", + models: []string{"gpt-4o-mini", "gpt-4o"}, + allowlist: []string{"gpt-4o-mini"}, + outcome: outcomeFiltered, + permitted: []string{"gpt-4o-mini"}, + wantHidden: []string{"gpt-4o"}, + }) + } + + // Anthropic is the surface Claude Code actually calls. Its listing returns + // DATED build ids (claude-haiku-4-5-20251001) while the provider record + // registers the undated id, so this is the case that proves the filter's + // date-normalisation against ids the vendor chose rather than ids we wrote. + if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" { + cases = append(cases, liveDiscoveryCase{ + name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k, + path: "/v1/models", + headers: []string{"anthropic-version: 2023-06-01"}, + models: []string{"claude-haiku-4-5"}, + outcome: outcomeFiltered, + permitted: []string{"claude-haiku-4-5"}, + }) + } + + // Bedrock lists inference profiles, not models: matchModelless routes + // /inference-profiles to a Bedrock route and refuses /v1/models for one. + // + // The listing is served by the CONTROL PLANE (bedrock.), not the + // runtime host a provider record must point at for InvokeModel — the + // runtime host answers . The router now sends + // the listing, and only the listing, to the control plane, so this case + // asserts a real filtered listing rather than the 404 it used to get. + // + // The mock upstream cannot show any of this: it answers + // /inference-profiles on the same listener as everything else, so a + // mock-based test passes whichever host the request went to. + if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" { + region := os.Getenv("AWS_REGION") + if region == "" { + region = "eu-central-1" + } + model := os.Getenv("AWS_BEDROCK_MODEL") + if model == "" { + model = "global.anthropic.claude-sonnet-4-6" + } + cases = append(cases, liveDiscoveryCase{ + name: "bedrock", catalogID: "bedrock_api", + upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, + path: "/inference-profiles", + // Registered verbatim, as an operator would copy it from AWS: the + // region prefix is what makes the id invocable, and the listing + // returns ids in exactly this form. + models: []string{model}, + outcome: outcomeFiltered, + permitted: []string{model}, + }) + } + + // Vertex carries the model in the rawPredict path and serves no listing + // endpoint at all, so the proxy must refuse discovery rather than rewrite + // it onto an upstream that would 404. + if sa := os.Getenv("GOOGLE_VERTEX_SA_BASE64"); sa != "" { + if project := os.Getenv("GOOGLE_VERTEX_PROJECT"); project != "" { + region := os.Getenv("GOOGLE_VERTEX_REGION") + if region == "" { + region = "global" + } + host := "aiplatform.googleapis.com" + if region != "global" { + host = region + "-aiplatform.googleapis.com" + } + cases = append(cases, liveDiscoveryCase{ + name: "vertex", catalogID: "vertex_ai_api", upstream: "https://" + host, + apiKey: "keyfile::" + sa, + path: "/v1/models", + outcome: outcomeDenied, + }) + } + } + + return cases +} + +// provisionLiveDiscovery creates the group, provider, optional guardrail and +// policy for one case, and returns the setup key a client joins that group +// with. Scoping each provider to its own group is what keeps it the only +// candidate for its own client's model-less request. +func provisionLiveDiscovery(t *testing.T, ctx context.Context, tc *liveDiscoveryCase) string { + t.Helper() + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-disc-live-" + tc.name}) + require.NoError(t, err, "create group for %s", tc.name) + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-disc-live-" + tc.name, + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key for %s", tc.name) + require.NotEmpty(t, sk.Key, "setup key plaintext for %s", tc.name) + + req := api.AgentNetworkProviderRequest{ + Name: "e2e-disc-live-" + tc.name, + ProviderId: tc.catalogID, + UpstreamUrl: tc.upstream, + ApiKey: &tc.apiKey, + Enabled: ptr(true), + } + if len(tc.models) > 0 { + models := make([]api.AgentNetworkProviderModel, 0, len(tc.models)) + for _, id := range tc.models { + models = append(models, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.002}) + } + req.Models = &models + } + prov, err := srv.CreateProvider(ctx, req) + require.NoError(t, err, "create provider %s", tc.name) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + polReq := api.AgentNetworkPolicyRequest{ + Name: "e2e-disc-live-" + tc.name, + Enabled: ptr(true), + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + } + if len(tc.allowlist) > 0 { + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-disc-live-" + tc.name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = tc.allowlist + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail for %s", tc.name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + polReq.GuardrailIds = &[]string{g.Id} + } + pol, err := srv.CreatePolicy(ctx, polReq) + require.NoError(t, err, "create policy for %s", tc.name) + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + return sk.Key +} + +// runLiveDiscoveryCase issues the discovery request and reports everything the +// vendor said before asserting on any of it. The log is the point on the first +// run: a live catalogue is the one input we do not control, so a failure has to +// arrive with the response that caused it rather than just a count. +func runLiveDiscoveryCase(t *testing.T, ctx context.Context, tc liveDiscoveryCase, cl *harness.Client, endpoint, proxyIP string) { + t.Helper() + + // A single request is enough for the two non-listing outcomes, and retrying + // them would burn the retry window waiting for a status that is never + // coming. + if tc.outcome != outcomeFiltered { + code, body, err := cl.Get(ctx, endpoint, proxyIP, tc.path, tc.headers) + require.NoError(t, err, "request must reach the proxy") + t.Logf("[discovery] %s GET %s -> %d; body: %s", tc.name, tc.path, code, truncate(body, 2000)) + assert.NotEqual(t, 200, code, + "%s serves no bounded listing, so a 200 here would mean the caller was handed a picker nothing narrows; body: %s", + tc.name, truncate(body, 2000)) + + // Which side refused is the whole distinction between these two + // outcomes, and a NetBird error is the thing that tells them apart: the + // middleware chain stamps its own name on anything it generates. + if tc.outcome == outcomeDenied { + assert.True(t, isProxyError(body), + "%s serves no listing endpoint at all, so the proxy must refuse the request itself rather than forward it to an upstream that would answer for us; body: %s", + tc.name, truncate(body, 2000)) + return + } + assert.False(t, isProxyError(body), + "%s discovery must be routed to the configured upstream and refused by the vendor, not blocked by the proxy; body: %s", + tc.name, truncate(body, 2000)) + return + } + + code, body := callUntil(t, func() (int, string, error) { + return cl.Get(ctx, endpoint, proxyIP, tc.path, tc.headers) + }, 200) + // Status only, not the body. A Bedrock listing embeds inference-profile + // ARNs carrying the 12-digit AWS account id, and these job logs are + // readable by anyone who can see the run. The ids line below is the finding + // anyway. The failure paths below are the same log: a listing that fails to + // arrive is an AWS refusal naming the resource it refused, and that name is + // an ARN carrying the same account id. + t.Logf("[discovery] %s GET %s -> %d", tc.name, tc.path, code) + require.Equal(t, 200, code, "%s discovery must be served; response was %s", tc.name, bodyShape(body)) + + ids, ok := listingIDs(body) + require.Truef(t, ok, + "%s answered discovery with something other than a {\"data\":[{\"id\":…}]} listing, which the filter forwards untouched — the caller would get an unbounded picker; response was %s", + tc.name, bodyShape(body)) + sort.Strings(ids) + t.Logf("[discovery] %s: %d ids after filtering: %s", tc.name, len(ids), strings.Join(ids, ", ")) + + require.NotEmpty(t, ids, "%s filtered the listing down to nothing; the caller would see an empty picker", tc.name) + + permitted := make(map[string]struct{}, len(tc.permitted)*2) + for _, id := range tc.permitted { + permitted[id] = struct{}{} + permitted[sharedllm.NormalizeAnthropicModel(id)] = struct{}{} + } + for _, id := range ids { + _, direct := permitted[id] + _, dated := permitted[sharedllm.NormalizeAnthropicModel(id)] + // Bedrock ids carry a region prefix and version suffix the record may + // not repeat; the proxy's filter tries the same forms. + _, bedrock := permitted[sharedllm.NormalizeBedrockModel(id)] + assert.Truef(t, direct || dated || bedrock, + "%s offered %q, which no policy on this route permits — every entry the picker shows must be a request the guardrail would allow", tc.name, id) + } + for _, hidden := range tc.wantHidden { + assert.NotContainsf(t, ids, hidden, + "%s offered %q, which the provider enumerates but the policy does not permit", tc.name, hidden) + } +} + +// isProxyError reports whether a response body was generated by the middleware +// chain rather than forwarded from a vendor. Every chain-generated error names +// the middleware that raised it, which no upstream's error body does — so this +// separates "the proxy refused" from "the proxy routed it and the vendor +// refused", the two failures that otherwise look alike from the client side. +func isProxyError(body string) bool { + return strings.Contains(body, `"middleware":`) +} + +// listingIDs pulls the model ids out of a listing response. ok is false when +// the body is neither envelope the proxy's filter recognises — the two must +// stay in step, or this test reports "not a listing" for a response the proxy +// filtered perfectly well. +func listingIDs(body string) ([]string, bool) { + var doc struct { + // OpenAI's shape, which Anthropic adopted. + Data []struct { + ID string `json:"id"` + } `json:"data"` + // Bedrock returns inference-profile summaries under a key of its own, + // with the id under a field of its own. + Summaries []struct { + ID string `json:"inferenceProfileId"` + } `json:"inferenceProfileSummaries"` + } + if err := json.Unmarshal([]byte(body), &doc); err != nil { + return nil, false + } + switch { + case doc.Data != nil: + ids := make([]string, 0, len(doc.Data)) + for _, entry := range doc.Data { + ids = append(ids, entry.ID) + } + return ids, true + case doc.Summaries != nil: + ids := make([]string, 0, len(doc.Summaries)) + for _, entry := range doc.Summaries { + ids = append(ids, entry.ID) + } + return ids, true + } + return nil, false +} + +func caseNames(cases []liveDiscoveryCase) []string { + names := make([]string, 0, len(cases)) + for _, c := range cases { + names = append(names, c.name) + } + return names +} + +// bodyShape describes a response without quoting any of it: its size and the +// top-level keys it arrived under. That is what a discovery failure is +// diagnosed from — which envelope the vendor answered with — and it is all +// that may go in a message rendered into a public job log, because the values +// underneath can carry an ARN and its account id. +func bodyShape(body string) string { + var doc map[string]json.RawMessage + if err := json.Unmarshal([]byte(body), &doc); err != nil { + return strconv.Itoa(len(body)) + " bytes, not a JSON object" + } + keys := make([]string, 0, len(doc)) + for key := range doc { + keys = append(keys, key) + } + sort.Strings(keys) + if len(keys) == 0 { + return strconv.Itoa(len(body)) + " bytes, an empty JSON object" + } + return strconv.Itoa(len(body)) + " bytes, keyed by: " + strings.Join(keys, ", ") +} + +// truncate bounds a logged response body. A live catalogue can run to tens of +// kilobytes, and the useful part is the front. +func truncate(s string, limit int) string { + if len(s) <= limit { + return s + } + return s[:limit] + "… (" + strconv.Itoa(len(s)-limit) + " more bytes)" +} diff --git a/e2e/agentnetwork/discovery_multipolicy_test.go b/e2e/agentnetwork/discovery_multipolicy_test.go new file mode 100644 index 000000000..447c1314c --- /dev/null +++ b/e2e/agentnetwork/discovery_multipolicy_test.go @@ -0,0 +1,170 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestDiscoveryBoundToCallersPolicies covers a model listing on a provider two +// teams reach under different allowlists. +// +// Bounding the listing by the provider's enumerated models alone is not enough +// once more than one policy is in play: the caller would be offered every model +// any team may use, and each one outside their own policy is a request the +// guardrail refuses a moment later — the empty-or-wrong picker this endpoint +// exists to avoid, just moved one level up. +// +// The client joins the main group only. Both models are enumerated by the same +// provider and both are advertised by the upstream, so a listing that leaked +// the other team's model would visibly contain it. +func TestDiscoveryBoundToCallersPolicies(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpMain, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-disc-mp-main"}) + require.NoError(t, err, "create main group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpMain.Id) }) + + grpOther, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-disc-mp-other"}) + require.NoError(t, err, "create other group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpOther.Id) }) + + ephemeral := false + mkKey := func(name, groupID string) string { + sk, kerr := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: name, + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{groupID}, + Ephemeral: &ephemeral, + }) + require.NoError(t, kerr, "mint setup key %s", name) + require.NotEmpty(t, sk.Key, "setup key plaintext") + return sk.Key + } + // One client per group. The second is what makes the first assertion mean + // something: without a client that DOES see the other team's model, its + // absence from the main client's listing could equally be a policy that + // never propagated. + keyMain := mkKey("e2e-disc-mp-main-client", grpMain.Id) + keyOther := mkKey("e2e-disc-mp-other-client", grpOther.Id) + + // One provider enumerating both models the upstream advertises, so the + // listing is narrowed by policy rather than by what the provider serves. + staticKey := "static-e2e-token" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-disc-mp", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.001}, + {Id: harness.VLLMUnlistedModel, InputPer1k: 0.001, OutputPer1k: 0.001}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + mkGuardrail := func(name, model string) api.AgentNetworkGuardrail { + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g + } + gMain := mkGuardrail("e2e-disc-mp-main", harness.VLLMModel) + gOther := mkGuardrail("e2e-disc-mp-other", harness.VLLMUnlistedModel) + + enabled := true + polMain, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-disc-mp-main", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gMain.Id}, + }) + require.NoError(t, err, "create main policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMain.Id) }) + + // The other team's policy, on the same provider, permitting the model the + // client must never be offered. + polOther, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-disc-mp-other", + Enabled: &enabled, + SourceGroups: []string{grpOther.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gOther.Id}, + }) + require.NoError(t, err, "create other policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOther.Id) }) + + endpoint, proxyIP, clMain, px := connectClient(t, ctx, "disc-mp", keyMain) + clOther := joinClient(t, ctx, px, endpoint, keyOther) + + listing := func(t *testing.T, cl *harness.Client, ip string) string { + t.Helper() + code, body := callUntil(t, func() (int, string, error) { + return cl.Get(ctx, endpoint, ip, "/v1/models?limit=1000", nil) + }, 200) + require.Equal(t, 200, code, "discovery must be served; body: %s", body) + return body + } + + otherIP, err := clOther.ResolveProxyIP(ctx, endpoint) + require.NoError(t, err, "resolve endpoint from the other client") + + // The other team's client first: seeing its own model proves polOther is + // live, so the main client's listing is narrowed by policy scoping rather + // than by the other policy having failed to apply at all. + otherBody := listing(t, clOther, otherIP) + assert.Contains(t, otherBody, harness.VLLMUnlistedModel, + "the other group's policy must be in force, or this test proves nothing") + assert.NotContains(t, otherBody, harness.VLLMModel, + "and it must not be offered the main group's model either — isolation runs both ways") + + mainBody := listing(t, clMain, proxyIP) + assert.Contains(t, mainBody, harness.VLLMModel, + "the model the caller's own policy permits must reach the picker") + assert.NotContains(t, mainBody, harness.VLLMUnlistedModel, + "a model only another group's policy permits must not be offered to this caller") +} + +// joinClient starts a second tunnel client against an already-running proxy, so +// a test can drive the same endpoint as two different group memberships without +// paying for a second proxy. +func joinClient(t *testing.T, ctx context.Context, px *harness.Proxy, endpoint, setupKey string) *harness.Client { + t.Helper() + + cl, err := harness.StartClient(ctx, srv, setupKey) + require.NoError(t, err, "start second client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "second client must connect to management") + _, err = cl.ResolveProxyIP(ctx, endpoint) + require.NoError(t, err, "second client could not resolve the endpoint") + // Guarded rather than passed straight to require: px.Logs pulls the whole + // proxy container log, which is only worth fetching when the wait failed. + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + require.NoError(t, err, "second client did not see the proxy peer\n=== proxy logs ===\n%s", + px.Logs(context.Background())) + } + return cl +} diff --git a/e2e/agentnetwork/gateway_protocol_test.go b/e2e/agentnetwork/gateway_protocol_test.go new file mode 100644 index 000000000..c21a4fc53 --- /dev/null +++ b/e2e/agentnetwork/gateway_protocol_test.go @@ -0,0 +1,455 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// Models each catalog surface is registered with in the matrix below. They +// differ per provider so the router's choice is unambiguous: a request that +// lands on the wrong provider record fails the surface assertion instead of +// passing by coincidence. +const ( + matrixAnthropicModel = "claude-sonnet-5" + matrixBedrockModel = "anthropic.claude-sonnet-5" + // matrixBedrockPathModel is what a Bedrock SDK client puts in the URL: a + // cross-region inference profile with a release date and version suffix. + // The proxy must normalise it back to matrixBedrockModel to route and price. + matrixBedrockPathModel = "us.anthropic.claude-sonnet-5-20250101-v1:0" + // matrixVertexModel differs from the Anthropic record's model on purpose: + // a shared id would leave two routes claiming it and make which one serves + // /v1/messages depend on declaration order. + matrixVertexModel = "claude-haiku-4-5" + matrixVertexProject = "e2e-project" + matrixVertexRegion = "us-east5" +) + +// gatewayEnv is a connected client plus a set of provider records, all pointed +// at one mock upstream, so several wire shapes can be driven over a single +// tunnel. +type gatewayEnv struct { + endpoint string + proxyIP string + client *harness.Client + proxy *harness.Proxy + vllm *harness.VLLM + // providerIDs maps the catalog id to the created provider record id. + providerIDs map[string]string +} + +// provisionGatewayMatrix brings up one mock upstream and one provider record +// per catalog surface, all authorised for the same group by a single policy. +// Sharing one proxy and client keeps the wire-shape cases to one tunnel setup; +// each case still creates its own session id so its access-log row is findable. +func provisionGatewayMatrix(t *testing.T, ctx context.Context) gatewayEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gw-matrix"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-gw-matrix-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // The mock ignores auth, so a dummy credential satisfies each catalog + // entry's auth template. Vertex is the exception: its api_key is a GCP + // service-account keyfile the proxy mints an OAuth token from, and a dummy + // one cannot mint. That is deliberate — the Vertex case below asserts on + // routing, which happens before the token mint. + dummyKey := "sk-gw-e2e" + dummyKeyfile := "keyfile::" + "e2e-not-a-real-service-account-key" + + specs := []struct { + name string + catalogID string + apiKey string + models []api.AgentNetworkProviderModel + }{ + { + name: "openai", catalogID: "openai_api", apiKey: dummyKey, + models: []api.AgentNetworkProviderModel{{Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}}, + }, + { + name: "anthropic", catalogID: "anthropic_api", apiKey: dummyKey, + models: []api.AgentNetworkProviderModel{{Id: matrixAnthropicModel, InputPer1k: 0.003, OutputPer1k: 0.015}}, + }, + { + name: "bedrock", catalogID: "bedrock_api", apiKey: dummyKey, + models: []api.AgentNetworkProviderModel{{Id: matrixBedrockModel, InputPer1k: 0.003, OutputPer1k: 0.015}}, + }, + { + name: "vertex", catalogID: "vertex_ai_api", apiKey: dummyKeyfile, + models: []api.AgentNetworkProviderModel{{Id: matrixVertexModel, InputPer1k: 0.001, OutputPer1k: 0.005}}, + }, + } + + providerIDs := make(map[string]string, len(specs)) + ids := make([]string, 0, len(specs)) + for _, spec := range specs { + key := spec.apiKey + models := spec.models + prov, perr := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-gw-" + spec.name, + ProviderId: spec.catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &key, + Enabled: ptr(true), + Models: &models, + }) + require.NoError(t, perr, "create %s provider", spec.name) + id := prov.Id + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) }) + providerIDs[spec.catalogID] = id + ids = append(ids, id) + } + + // Uncapped token limit: never blocks the handful of tokens driven here, but + // switches on usage metering so consumption and cost land in the row. + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gw-matrix", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: ids, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, "gw-matrix", sk.Key) + return gatewayEnv{ + endpoint: endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + vllm: vllm, + providerIDs: providerIDs, + } +} + +// connectClient starts a proxy and a tunnel client for the shared account and +// waits until the client can reach the proxy peer, returning the endpoint and +// the proxy's tunnel IP to pin requests to. +func connectClient(t *testing.T, ctx context.Context, name, setupKey string) (string, string, *harness.Client, *harness.Proxy) { + t.Helper() + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-"+name+"-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, setupKey) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // The probe resolves the endpoint and its first packet wakes the lazy proxy + // peer, so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + return settings.Endpoint, proxyIP, cl, px +} + +// callUntil retries an HTTP call through the tunnel until it returns one of the +// wanted statuses or the deadline passes, absorbing the DNS and tunnel jitter +// the first call through a fresh tunnel can hit. The last status and body are +// returned either way so the caller can assert with real detail. +func callUntil(t *testing.T, call func() (int, string, error), want ...int) (int, string) { + t.Helper() + wanted := make(map[int]struct{}, len(want)) + for _, w := range want { + wanted[w] = struct{}{} + } + + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, err := call() + if err == nil { + code, body = c, b + if _, ok := wanted[code]; ok { + return code, body + } + } + time.Sleep(5 * time.Second) + } + return code, body +} + +// TestGatewayProtocolProviderMatrix drives one request per wire shape over a +// single tunnel, with a provider record per catalog surface behind it. It is +// the regression net for the routing and parser-selection changes: each case +// asserts the surface the request was metered under and the token counts that +// surface's own usage block carries, so a request parsed by the wrong provider's +// parser meters zero and fails rather than passing on a coincidence. +func TestGatewayProtocolProviderMatrix(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + env := provisionGatewayMatrix(t, ctx) + diag := func() string { + return fmt.Sprintf("\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s", + env.vllm.Logs(context.Background()), env.proxy.Logs(context.Background())) + } + + t.Run("openai chat completions", func(t *testing.T) { + session := "e2e-gw-openai" + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, harness.VLLMModel, "ping", session) + }, 200) + require.Equal(t, 200, code, "openai chat must succeed; body: %s%s", body, diag()) + require.Contains(t, body, "chat.completion", "body must be an OpenAI completion; got: %s", body) + + row := findAccessLogBySession(t, ctx, session) + require.NotNil(t, row.Provider) + assert.Equal(t, "openai", *row.Provider, "the OpenAI chat path must meter under the openai surface") + assert.Equal(t, int64(harness.VLLMChatInputTokens), row.InputTokens, "OpenAI usage block must be read") + assert.Equal(t, int64(harness.VLLMChatOutputTokens), row.OutputTokens) + }) + + t.Run("anthropic messages", func(t *testing.T) { + session := "e2e-gw-anthropic" + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, matrixAnthropicModel, "ping", session) + }, 200) + require.Equal(t, 200, code, "anthropic messages must succeed; body: %s%s", body, diag()) + + row := findAccessLogBySession(t, ctx, session) + require.NotNil(t, row.Provider) + assert.Equal(t, "anthropic", *row.Provider, "the /v1/messages path must meter under the anthropic surface") + // These counts only appear if the Anthropic parser read the response: + // its usage fields are named differently from the OpenAI block. + assert.Equal(t, int64(harness.VLLMMessagesInputTokens), row.InputTokens, + "Anthropic input_tokens must be read; zero here means the wrong parser ran") + assert.Equal(t, int64(harness.VLLMMessagesOutputTokens), row.OutputTokens) + assert.Positive(t, row.CachedInputTokens, "the Anthropic cache-read bucket must be recorded") + assert.Positive(t, row.CostUsd, "a metered request must carry a cost") + require.NotNil(t, row.ResolvedProviderId) + assert.Equal(t, env.providerIDs["anthropic_api"], *row.ResolvedProviderId, + "a vendor-tagged request must not cross to another provider's record") + }) + + t.Run("bedrock invoke normalises the path model", func(t *testing.T) { + session := "e2e-gw-bedrock" + code, body := callUntil(t, func() (int, string, error) { + return env.client.Bedrock(ctx, env.endpoint, env.proxyIP, matrixBedrockPathModel, "ping", session) + }, 200) + require.Equal(t, 200, code, "bedrock invoke must succeed; body: %s%s", body, diag()) + + row := findAccessLogBySession(t, ctx, session) + require.NotNil(t, row.Provider) + assert.Equal(t, "bedrock", *row.Provider, "a native Bedrock path must meter under the bedrock surface") + require.NotNil(t, row.Model) + assert.Equal(t, matrixBedrockModel, *row.Model, + "the inference-profile prefix, release date and version suffix must be normalised away") + assert.Equal(t, int64(harness.VLLMMessagesInputTokens), row.InputTokens) + }) + + t.Run("anthropic token counting", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, "/v1/messages/count_tokens", + fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"ping"}]}`, matrixAnthropicModel), + []string{"anthropic-version: 2023-06-01"}) + }, 200) + assert.Equal(t, 200, code, "token counting must route rather than deny; body: %s%s", body, diag()) + }) + + t.Run("bedrock token counting", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, + "/model/"+matrixBedrockPathModel+"/count-tokens", + `{"input":{"converse":{"messages":[{"role":"user","content":[{"text":"ping"}]}]}}}`, nil) + }, 200) + assert.Equal(t, 200, code, + "the Bedrock count-tokens action must route; denying it pushes counting onto the billable inference path; body: %s%s", + body, diag()) + }) + + t.Run("vertex token counting reaches its provider", func(t *testing.T) { + // The dummy service-account key cannot mint an OAuth token, so the + // request stops at the upstream credential. Both outcomes render as + // 403, so the deny code is what distinguishes them: upstream_auth_failed + // means the path resolved to the Vertex route and only the credential + // failed, while model_not_routable would mean the method segment was + // swallowed into the model id and no route ever claimed it. + path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s/count-tokens:rawPredict", + matrixVertexProject, matrixVertexRegion, matrixVertexModel) + _, body := callUntil(t, func() (int, string, error) { + return env.client.PostJSON(ctx, env.endpoint, env.proxyIP, path, + `{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"ping"}]}`, nil) + }, 403) + assert.NotContains(t, body, "model_not_routable", + "the count-tokens method segment must not be parsed as part of the model id; body: %s%s", body, diag()) + assert.Contains(t, body, "llm_policy.upstream_auth_failed", + "the request must reach the Vertex route and fail only at the credential; body: %s%s", body, diag()) + }) + + t.Run("connection warming probe", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.Get(ctx, env.endpoint, env.proxyIP, "/api/hello", nil) + }, 200) + assert.NotEqual(t, 403, code, + "the warm-up probe carries no model and must not be refused as unroutable; body: %s%s", body, diag()) + }) + + t.Run("unknown model denies in the caller's error shape", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, + "claude-not-a-real-model-9", "ping", "e2e-gw-unknown") + }, 403) + require.Equal(t, 403, code, "a model no provider claims must still be refused; body: %s%s", body, diag()) + + // The NetBird fields stay where they were for existing consumers. + assert.Contains(t, body, "llm_policy.model_not_routable", "the deny code must be preserved") + // And the vendor's own envelope rides alongside, so the client can show + // the reason instead of an unexplained API error. + assert.Contains(t, body, `"type":"error"`, "an Anthropic caller must get the Anthropic error envelope") + assert.Contains(t, body, "permission_error", "403 must map to the vendor's permission error type") + }) +} + +// TestModelDiscoveryWithModelAllowlist covers gateway model discovery on an +// account that restricts models, which is the configuration that broke: the +// listing carries no model, and the per-model allowlist fails closed on an +// undetermined one, so discovery denied for exactly the accounts using the +// feature. It also asserts the allowlist still refuses a model outside it, so +// the exemption cannot be read as a way around the gate. +func TestModelDiscoveryWithModelAllowlist(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gw-discovery"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-gw-discovery-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // One provider enumerating a single model, while the upstream's own listing + // advertises two. The proxy must serve the shorter list. + dummyKey := "sk-discovery-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-gw-discovery", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // The model allowlist is what makes this a regression test: without a + // guardrail enabled, discovery was never gated in the first place. + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-gw-discovery-allowlist" + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{harness.VLLMModel} + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gw-discovery", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, "gw-discovery", sk.Key) + diag := func() string { + return fmt.Sprintf("\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s", + vllm.Logs(context.Background()), px.Logs(context.Background())) + } + + t.Run("listing is served and bounded by policy", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return cl.Get(ctx, endpoint, proxyIP, "/v1/models?limit=1000", nil) + }, 200) + require.Equal(t, 200, code, + "discovery must not be refused because the request carries no model; body: %s%s", body, diag()) + + assert.Contains(t, body, harness.VLLMModel, "the authorised model must reach the picker") + assert.NotContains(t, body, harness.VLLMUnlistedModel, + "a model the policy does not authorise must not be offered; body: %s", body) + }) + + t.Run("allowlist still refuses a model outside it", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return cl.Chat(ctx, endpoint, proxyIP, harness.WireChat, + harness.VLLMUnlistedModel, "ping", "e2e-gw-discovery-blocked") + }, 403) + require.Equal(t, 403, code, + "exempting model-less endpoints must not exempt inference; body: %s%s", body, diag()) + assert.True(t, + strings.Contains(body, "llm_policy.model_blocked") || strings.Contains(body, "llm_policy.model_not_routable"), + "the refusal must name a model policy code; body: %s", body) + }) + + t.Run("allowlisted model still routes", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return cl.Chat(ctx, endpoint, proxyIP, harness.WireChat, + harness.VLLMModel, "ping", "e2e-gw-discovery-allowed") + }, 200) + require.Equal(t, 200, code, "the allowlisted model must still be served; body: %s%s", body, diag()) + }) +} diff --git a/e2e/agentnetwork/gateway_review_test.go b/e2e/agentnetwork/gateway_review_test.go new file mode 100644 index 000000000..556bc4a53 --- /dev/null +++ b/e2e/agentnetwork/gateway_review_test.go @@ -0,0 +1,242 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// The cases in this file cover behaviour that arrived from code review, after +// the gateway-protocol end-to-end tests were written. Each had unit coverage +// only; none needed a new harness capability, which is why they belong here +// rather than on a manual checklist. + +// TestNonInferenceEndpointsAreAuthorised covers the two review findings on the +// endpoints that carry no body: the per-model lookup must be authorised +// against the same allowlist that bounds the listing beside it, and only a read +// method may claim the non-inference exemption that skips the token pre-flight. +func TestNonInferenceEndpointsAreAuthorised(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + env := provisionDiscoveryProvider(t, ctx) + + t.Run("lookup of an authorised model succeeds", func(t *testing.T) { + code, body := callUntil(t, func() (int, string, error) { + return env.client.Get(ctx, env.endpoint, env.proxyIP, "/v1/models/"+harness.VLLMModel, nil) + }, 200) + assert.Equal(t, 200, code, "an allowlisted model must remain reachable; body: %s", body) + }) + + t.Run("lookup of an unauthorised model is refused", func(t *testing.T) { + code, body, err := env.client.Get(ctx, env.endpoint, env.proxyIP, "/v1/models/"+harness.VLLMUnlistedModel, nil) + require.NoError(t, err, "request must reach the proxy") + assert.Equal(t, 403, code, + "a model the policy does not authorise must not be confirmed by the detail lookup; body: %s", body) + }) + + // A write must not claim the exemption that lets the listing skip the token + // pre-flight. The body names no model on purpose: that is what a request + // probing for the exemption looks like, and it is the case the method gate + // exists to refuse. (A POST that does name a model is a different thing — + // it routes and meters as the inference request it is.) + for _, path := range []string{"/v1/models", "/v1/models/" + harness.VLLMModel, "/api/hello"} { + t.Run("write to "+path+" is refused", func(t *testing.T) { + code, body, err := env.client.PostJSON(ctx, env.endpoint, env.proxyIP, path, + `{"messages":[{"role":"user","content":"hi"}]}`, nil) + require.NoError(t, err, "request must reach the proxy") + assert.NotEqual(t, 200, code, + "a write to a non-inference path must not be served unmetered; body: %s", body) + }) + } + + // A request carrying the sub-agent attribution headers must still be served + // and metered normally. Asserting the ids themselves is not possible yet: + // the parser lifts them onto the request's metadata, but nothing persists + // them, so they have no queryable surface to check against. + t.Run("sub-agent headers do not disturb the request", func(t *testing.T) { + sessionID := fmt.Sprintf("e2e-session-agentid-%d", time.Now().UnixNano()) + code, body, err := env.client.PostJSON(ctx, env.endpoint, env.proxyIP, "/v1/chat/completions", + fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"Reply with exactly: pong"}]}`, harness.VLLMModel), + []string{ + "x-session-id: " + sessionID, + "x-claude-code-agent-id: agent-child-7", + "x-claude-code-parent-agent-id: agent-root-1", + }) + require.NoError(t, err, "request must reach the proxy") + require.Equal(t, 200, code, "the request must succeed; body: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + assert.Positive(t, row.InputTokens, "the request must still be metered normally") + }) +} + +// TestDatedModelIdRouting covers both halves of the dated-id rule that review +// tightened: a dated id still reaches an undated registration, but a route +// pinned to one dated build must never serve a different one. +func TestDatedModelIdRouting(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + const ( + undated = "claude-sonnet-9" + datedA = "claude-sonnet-9-20250101" + datedB = "claude-sonnet-9-20250202" + ) + + t.Run("a dated id reaches its undated registration", func(t *testing.T) { + env := provisionModelProvider(t, ctx, "dated-undated", "anthropic_api", undated) + + sessionID := fmt.Sprintf("e2e-session-dated-%d", time.Now().UnixNano()) + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedA, "Reply with exactly: pong", sessionID) + }, 200) + require.Equal(t, 200, code, "a pinned release of a registered family must route; body: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + assert.Positive(t, row.InputTokens, "the dated request must price at the registered rate, not zero") + }) + + t.Run("a route pinned to one dated build refuses another", func(t *testing.T) { + env := provisionModelProvider(t, ctx, "dated-pinned", "anthropic_api", datedA) + + code, body := callUntil(t, func() (int, string, error) { + return env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedA, "Reply with exactly: pong", "") + }, 200) + require.Equal(t, 200, code, "the exact dated id must still route; body: %s", body) + + code, body, err := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireMessages, datedB, "Reply with exactly: pong", "") + require.NoError(t, err, "request must reach the proxy") + assert.Equal(t, 403, code, + "a provider pinned to one dated build must not serve another; body: %s", body) + }) +} + +// TestBedrockInferenceProfilesReachTheUpstream covers the startup lookup a +// Bedrock client makes. The proxy forwards it to the configured upstream rather +// than denying it, so what comes back is the upstream's answer — never a +// NetBird policy rejection. +func TestBedrockInferenceProfilesReachTheUpstream(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + env := provisionModelProvider(t, ctx, "infprofiles", "bedrock_api", "anthropic.claude-sonnet-5") + + code, body := callUntil(t, func() (int, string, error) { + return env.client.Get(ctx, env.endpoint, env.proxyIP, "/inference-profiles", nil) + }, 200) + + assert.Equal(t, 200, code, "the lookup must reach the upstream; body: %s", body) + assert.NotContains(t, body, "llm_policy.", + "the proxy must not answer a control-plane lookup with a policy denial") + assert.Contains(t, body, "inferenceProfileSummaries", + "the upstream's own answer must come back untouched") +} + +// provisionDiscoveryProvider brings up one mock-backed provider enumerating a +// single model, with an allowlist guardrail in effect, plus a connected client. +func provisionDiscoveryProvider(t *testing.T, ctx context.Context) pricedEnv { + t.Helper() + env := provisionModelProvider(t, ctx, "noninference", "openai_api", harness.VLLMModel) + + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-noninference-allowlist-" + fmt.Sprint(time.Now().UnixNano()) + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{harness.VLLMModel} + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + _, err = srv.UpdatePolicy(ctx, env.policyID, api.AgentNetworkPolicyRequest{ + Name: "e2e-noninference", + Enabled: &enabled, + SourceGroups: []string{env.groupID}, + DestinationProviderIds: []string{env.providerID}, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "attach guardrail to policy") + return env +} + +// provisionModelProvider brings up the mock, one provider under the given +// catalog id enumerating exactly one model, an authorising policy, and a +// connected proxy + client. +func provisionModelProvider(t *testing.T, ctx context.Context, name, catalogID, model string) pricedEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + suffix := strings.ToLower(name) + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gwr-" + suffix}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-gwr-" + suffix + "-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + dummyKey := "sk-gwr-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-gwr-" + suffix, + ProviderId: catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: model, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gwr-" + suffix, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, "gwr-"+suffix, sk.Key) + return pricedEnv{ + providerID: prov.Id, + groupID: grp.Id, + policyID: pol.Id, + upstream: vllm.URL, + endpoint: endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + } +} diff --git a/e2e/agentnetwork/guardrail_block_test.go b/e2e/agentnetwork/guardrail_block_test.go new file mode 100644 index 000000000..302b5107e --- /dev/null +++ b/e2e/agentnetwork/guardrail_block_test.go @@ -0,0 +1,208 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// pathRoutedGuardrailCase is one provider's self-contained scenario: its own +// provider, its own guardrail whose allowlist holds ONLY that provider's +// allowed model, and its own policy. Each case runs in isolation (its own +// proxy + client), so the guardrail the proxy enforces contains exactly this +// provider's model — never a mixed cross-provider list. +type pathRoutedGuardrailCase struct { + name string + catalogID string // agent-network catalog provider id + wire string // harness.WireVertex | harness.WireBedrock + allowEntry string // the single model id put on the guardrail allowlist + allowModel string // model id sent that MUST be served (200) + blockModel string // model id sent that MUST be denied (403 model_blocked) +} + +// TestGuardrailBlocksUnselectedModel_PathRouted is the end-to-end regression +// guard for the customer report that a model-allowlist guardrail attached to a +// policy has no effect for PATH-ROUTED providers — where the model travels in +// the URL, not the JSON body: Google Vertex (…/models/{model}:rawPredict) and +// AWS Bedrock (/model/{id}/invoke). +// +// Each provider is tested in isolation with a guardrail allowlisting a single +// model of its own: the allowed model (in the URL path) is served (200) and an +// unselected model (in the URL path) is denied 403 by the guardrail +// (llm_policy.model_blocked) before the upstream. The Vertex case mirrors the +// customer verbatim — allow Sonnet, and the unselected model is the exact +// claude-opus-4-6 they reported reaching the model unblocked. The Bedrock case +// sends a region-prefixed, versioned inference-profile id so URL-path model +// normalization is exercised too. +// +// The provider is catch-all (no models), so the router forwards any model and a +// 403 can only come from the guardrail, never model_not_routable. Only the +// upstream LLM is mocked (the vLLM nginx answers any path with 200); management +// synth/reconcile, the proxy middleware chain (URL-path model extraction, +// router, guardrail) and the tunnel are all real, and the guardrail denies +// before the upstream is dialed so the mock cannot influence the block. A +// static bearer api key is used so the router injects a static Authorization +// header instead of minting a GCP token — the only reason path-routed providers +// normally need live credentials — so the test runs with none and is always on. +func TestGuardrailBlocksUnselectedModel_PathRouted(t *testing.T) { + cases := []pathRoutedGuardrailCase{ + { + name: "vertex", + catalogID: "vertex_ai_api", + wire: harness.WireVertex, + allowEntry: "claude-sonnet-4-5", + allowModel: "claude-sonnet-4-5", + blockModel: "claude-opus-4-6", // the customer-reported model + }, + { + name: "bedrock", + catalogID: "bedrock_api", + wire: harness.WireBedrock, + allowEntry: "anthropic.claude-sonnet-4-5", // normalized catalog id + allowModel: "us.anthropic.claude-sonnet-4-5-v1:0", + blockModel: "us.anthropic.claude-opus-4-8-v1:0", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + runPathRoutedGuardrailCase(t, tc) + }) + } +} + +func runPathRoutedGuardrailCase(t *testing.T, tc pathRoutedGuardrailCase) { + t.Helper() + + const ( + vertexProject = "e2e-project" + vertexRegion = "global" + ) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-guardrail-" + tc.name}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-guardrail-" + tc.name + "-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // Catch-all provider (no models) so the router forwards any model; a static + // bearer key means the router injects a static auth header instead of minting + // a GCP token. + staticKey := "static-e2e-token" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: tc.name, + ProviderId: tc.catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + }) + require.NoError(t, err, "create %s provider", tc.name) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // Guardrail allowlisting ONLY this provider's allowed model. + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-guardrail-" + tc.name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{tc.allowEntry} + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-" + tc.name, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-guardrail-"+tc.name+"-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint and its first packet wakes the + // lazy proxy peer, so WaitProxyPeer then observes it connected. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(model string) (int, string) { + var code int + var body string + var cerr error + switch tc.wire { + case harness.WireVertex: + code, body, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, vertexProject, vertexRegion, model, "Reply with exactly: pong", "") + case harness.WireBedrock: + code, body, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, model, "Reply with exactly: pong", "") + default: + t.Fatalf("unsupported wire %q", tc.wire) + } + require.NoError(t, cerr, "request must reach the proxy for %s", tc.name) + return code, body + } + + // Allowed model (in the URL path) is served. Retry to absorb tunnel/DNS + // jitter on the first call over the freshly warmed tunnel. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(tc.allowModel) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + assert.Equal(t, 200, code, + "allowed %s model (URL path) must be served; body: %s\n=== proxy logs ===\n%s", tc.name, body, px.Logs(context.Background())) + + // Unselected model (in the URL path) must be blocked by the guardrail. + code, body = send(tc.blockModel) + assert.Equal(t, 403, code, + "unselected %s model (URL path) must be denied, not served; body: %s\n=== proxy logs ===\n%s", tc.name, body, px.Logs(context.Background())) + assert.Contains(t, body, "llm_policy.model_blocked", + "%s denial must come from the guardrail allowlist, not routing; body: %s", tc.name, body) +} diff --git a/e2e/agentnetwork/guardrail_groupswitch_test.go b/e2e/agentnetwork/guardrail_groupswitch_test.go new file mode 100644 index 000000000..7108bb587 --- /dev/null +++ b/e2e/agentnetwork/guardrail_groupswitch_test.go @@ -0,0 +1,204 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestGuardrailGroupSwitchTakesEffectAfterTTL proves that moving a peer between +// groups flips its model-allowlist decision once the proxy's tunnel-peer cache +// expires. The peer's groups reach the guardrail via ValidateTunnelPeer, which +// the proxy caches; the switch is invisible until that cache expires. The proxy +// runs with a short NB_PROXY_TUNNEL_CACHE_TTL so the flip happens in seconds +// instead of the 5-minute default. +// +// Setup: one catch-all provider declaring modelA + modelB; polA (grpA -> allow +// modelA) and polB (grpB -> allow modelB). The client starts in grpA. modelA is +// served and modelB denied; after switching the client grpA -> grpB, modelB is +// served and modelA denied. The cross-group deny comes from management's +// per-policy/group CheckLLMPolicyLimits (the proxy backstop carries the union). +func TestGuardrailGroupSwitchTakesEffectAfterTTL(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + modelA = "e2e-model-a" + modelB = "e2e-model-b" + // Short tunnel-cache TTL so a group switch propagates in seconds. + // Exercises the NB_PROXY_TUNNEL_CACHE_TTL override. + cacheTTL = 3 * time.Second + ) + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpA, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gswitch-a"}) + require.NoError(t, err, "create group A") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpA.Id) }) + + grpB, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-gswitch-b"}) + require.NoError(t, err, "create group B") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpB.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-gswitch-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grpA.Id}, // client starts in group A + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "gswitch", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: modelA, InputPer1k: 0.001, OutputPer1k: 0.001}, + {Id: modelB, InputPer1k: 0.001, OutputPer1k: 0.001}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + mkGuard := func(name, model string) api.AgentNetworkGuardrail { + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g + } + gA := mkGuard("e2e-gswitch-a", modelA) + gB := mkGuard("e2e-gswitch-b", modelB) + + enabled := true + polA, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gswitch-a", + Enabled: &enabled, + SourceGroups: []string{grpA.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gA.Id}, + }) + require.NoError(t, err, "create policy A") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polA.Id) }) + + polB, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-gswitch-b", + Enabled: &enabled, + SourceGroups: []string{grpB.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gB.Id}, + }) + require.NoError(t, err, "create policy B") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polB.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-gswitch-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken, map[string]string{ + "NB_PROXY_TUNNEL_CACHE_TTL": cacheTTL.String(), + }) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(model string) (int, string) { + code, body, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, model, "Reply with exactly: pong", "") + require.NoError(t, cerr, "request must reach the proxy") + return code, body + } + sendUntil := func(model string, want int, timeout time.Duration) (int, string) { + var code int + var body string + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + code, body = send(model) + if code == want { + return code, body + } + time.Sleep(2 * time.Second) + } + return code, body + } + + // Phase 1 — client is in group A: modelA served, modelB denied. + code, body := sendUntil(modelA, 200, 90*time.Second) + assert.Equal(t, 200, code, + "group-A model must be served while the client is in group A; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + code, body = send(modelB) + assert.Equal(t, 403, code, + "group-B model must be denied while the client is in group A; body: %s", body) + assert.Contains(t, body, "llm_policy.model_blocked") + + // Switch the client peer from group A to group B. + peerID := clientPeerInGroup(t, ctx, grpA.Id) + _, err = srv.API().Groups.Update(ctx, grpB.Id, api.PutApiGroupsGroupIdJSONRequestBody{ + Name: grpB.Name, + Peers: &[]string{peerID}, + }) + require.NoError(t, err, "add peer to group B") + _, err = srv.API().Groups.Update(ctx, grpA.Id, api.PutApiGroupsGroupIdJSONRequestBody{ + Name: grpA.Name, + Peers: &[]string{}, + }) + require.NoError(t, err, "remove peer from group A") + + // Phase 2 — after the short TTL expires the proxy re-validates the peer, + // sees group B, and the decision flips. Poll to absorb TTL + re-validation. + code, body = sendUntil(modelB, 200, 60*time.Second) + assert.Equal(t, 200, code, + "after the group switch + TTL, the group-B model must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + code, body = send(modelA) + assert.Equal(t, 403, code, + "after the switch, the old group-A model must be denied; body: %s", body) + assert.Contains(t, body, "llm_policy.model_blocked") +} + +// clientPeerInGroup returns the id of the single peer that is a member of the +// given group — the test client. The proxy peer is never added to test groups. +func clientPeerInGroup(t *testing.T, ctx context.Context, groupID string) string { + t.Helper() + peers, err := srv.API().Peers.List(ctx) + require.NoError(t, err, "list peers") + for _, p := range peers { + for _, g := range p.Groups { + if g.Id == groupID { + return p.Id + } + } + } + t.Fatalf("no peer found in group %s", groupID) + return "" +} diff --git a/e2e/agentnetwork/guardrail_multipolicy_test.go b/e2e/agentnetwork/guardrail_multipolicy_test.go new file mode 100644 index 000000000..2bb56b3bc --- /dev/null +++ b/e2e/agentnetwork/guardrail_multipolicy_test.go @@ -0,0 +1,200 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestGuardrailMultiPolicyModelAllowlist: modelSelected served (200), grpOther's +// modelOther denied for the grpMain client (403 model_blocked, no cross-group +// leak), and openModel on the un-guardrailed policy's provider served (200). +func TestGuardrailMultiPolicyModelAllowlist(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + modelSelected = "e2e-selected" + modelOther = "e2e-other" + openModel = "e2e-open" + ) + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpMain, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-guardrail-mp-main"}) + require.NoError(t, err, "create main group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpMain.Id) }) + + grpOther, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-guardrail-mp-other"}) + require.NoError(t, err, "create other group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpOther.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-guardrail-mp-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grpMain.Id}, // client joins grpMain only + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + models := func(ids ...string) *[]api.AgentNetworkProviderModel { + out := make([]api.AgentNetworkProviderModel, 0, len(ids)) + for _, id := range ids { + out = append(out, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.001}) + } + return &out + } + + // pRestricted declares the two guardrailed models so routing is deterministic + // (model -> provider). + pRestricted, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "restricted", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: models(modelSelected, modelOther), + }) + require.NoError(t, err, "create restricted provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), pRestricted.Id) }) + + pOpen, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "open", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: models(openModel), + }) + require.NoError(t, err, "create open provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), pOpen.Id) }) + + mkGuardrail := func(name, model string) api.AgentNetworkGuardrail { + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, gerr := srv.CreateGuardrail(ctx, gr) + require.NoError(t, gerr, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g + } + gMain := mkGuardrail("e2e-guardrail-mp-main", modelSelected) + gOther := mkGuardrail("e2e-guardrail-mp-other", modelOther) + + enabled := true + // polMain: grpMain restricted to modelSelected on pRestricted. + polMain, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-mp-main", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{pRestricted.Id}, + GuardrailIds: &[]string{gMain.Id}, + }) + require.NoError(t, err, "create main policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMain.Id) }) + + // polOther: grpOther restricted to modelOther on the SAME provider. The + // client is not in grpOther, so modelOther must never be usable by it. + polOther, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-mp-other", + Enabled: &enabled, + SourceGroups: []string{grpOther.Id}, + DestinationProviderIds: []string{pRestricted.Id}, + GuardrailIds: &[]string{gOther.Id}, + }) + require.NoError(t, err, "create other policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOther.Id) }) + + // polOpen: grpMain on pOpen with NO guardrail — unrestricted. + polOpen, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-guardrail-mp-open", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{pOpen.Id}, + }) + require.NoError(t, err, "create open policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOpen.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-guardrail-mp-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(model string) (int, string) { + code, body, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, model, "Reply with exactly: pong", "") + require.NoError(t, cerr, "request must reach the proxy") + return code, body + } + // sendUntil200 absorbs first-call tunnel/DNS jitter on the freshly warmed tunnel. + sendUntil200 := func(model string) (int, string) { + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(model) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + return code, body + } + + t.Run("selected model allowed for its group", func(t *testing.T) { + code, body := sendUntil200(modelSelected) + assert.Equal(t, 200, code, + "grpMain's allowlisted model must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + }) + + t.Run("other group's model does not leak", func(t *testing.T) { + // modelOther is allowlisted only for grpOther. The grpMain client must be + // denied by management's per-policy/group check — not waved through by an + // account-wide union. This is the security-critical wrong-ALLOW guard. + code, body := send(modelOther) + assert.Equal(t, 403, code, + "another group's allowlisted model must be denied for this caller; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + assert.Contains(t, body, "llm_policy.model_blocked", + "denial must be a model-allowlist decision; body: %s", body) + }) + + t.Run("unguarded policy leaves its provider unrestricted", func(t *testing.T) { + // polOpen carries no guardrail, so pOpen is unrestricted for grpMain. The + // old account-wide union would have blocked openModel (it is on no + // allowlist); it must now be served — the false-DENY guard. + code, body := sendUntil200(openModel) + assert.Equal(t, 200, code, + "an un-guardrailed policy's provider must not be blocked by another policy's allowlist; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + }) +} diff --git a/e2e/agentnetwork/guardrail_pergroup_providers_test.go b/e2e/agentnetwork/guardrail_pergroup_providers_test.go new file mode 100644 index 000000000..0e98330c4 --- /dev/null +++ b/e2e/agentnetwork/guardrail_pergroup_providers_test.go @@ -0,0 +1,418 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// pergroupCase describes one provider surface for the per-group allowlist matrix. +// selectedReq/otherReq are the model identifiers as they travel in the request +// (URL path for Bedrock/Vertex, body "model" for chat/messages). selectedAllow/ +// otherAllow are the (normalized) forms the guardrail allowlist holds — for +// Bedrock these differ from the request form so path normalization is exercised. +type pergroupCase struct { + name string + catalogID string + wire string // "chat", "messages", "vertex", "bedrock" + models *[]api.AgentNetworkProviderModel + selectedReq string + selectedAllow string + otherReq string + otherAllow string + + providerID string // filled during setup +} + +// TestGuardrailPerGroupAllowlist_AllProviders proves the per-policy/group model +// allowlist end to end across every always-on provider surface, including the +// path-routed ones (Vertex, Bedrock) where the model travels in the URL. +// +// For each provider two policies target it: grpMain (the client) is allowed only +// selectedReq; grpOther (which the client is NOT in) is allowed only otherReq. +// The client must get selectedReq served (200) and otherReq denied (403, +// llm_policy.model_blocked) — the cross-group no-leak property. The deny is the +// authoritative per-policy/group decision from management (the proxy per-provider +// backstop carries the union of both models), so this also confirms management +// receives the correct normalized model for path-routed providers. +func TestGuardrailPerGroupAllowlist_AllProviders(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + const ( + vertexProject = "e2e-project" + vertexRegion = "global" + ) + + priced := func(ids ...string) *[]api.AgentNetworkProviderModel { + out := make([]api.AgentNetworkProviderModel, 0, len(ids)) + for _, id := range ids { + out = append(out, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.001}) + } + return &out + } + + cases := []*pergroupCase{ + { + name: "openai", catalogID: "openai_api", wire: harness.WireChat, + models: priced("oai-model-a", "oai-model-b"), + selectedReq: "oai-model-a", selectedAllow: "oai-model-a", + otherReq: "oai-model-b", otherAllow: "oai-model-b", + }, + { + name: "anthropic", catalogID: "anthropic_api", wire: harness.WireMessages, + models: priced("ant-model-a", "ant-model-b"), + selectedReq: "ant-model-a", selectedAllow: "ant-model-a", + otherReq: "ant-model-b", otherAllow: "ant-model-b", + }, + { + // Vertex catalog ids travel bare in the rawPredict path. + name: "vertex", catalogID: "vertex_ai_api", wire: "vertex", + selectedReq: "claude-sonnet-4-5", selectedAllow: "claude-sonnet-4-5", + otherReq: "claude-opus-4-6", otherAllow: "claude-opus-4-6", + }, + { + // Bedrock request ids are region-prefixed/versioned; the parser + // normalizes them to the catalog key the allowlist holds. + name: "bedrock", catalogID: "bedrock_api", wire: "bedrock", + selectedReq: "us.anthropic.claude-sonnet-4-5-v1:0", selectedAllow: "anthropic.claude-sonnet-4-5", + otherReq: "us.anthropic.claude-opus-4-8-v1:0", otherAllow: "anthropic.claude-opus-4-8", + }, + } + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grpMain, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-pergroup-main"}) + require.NoError(t, err, "create main group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpMain.Id) }) + + grpOther, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-pergroup-other"}) + require.NoError(t, err, "create other group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grpOther.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-pergroup-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grpMain.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + enabled := true + + for _, c := range cases { + req := api.AgentNetworkProviderRequest{ + Name: "e2e-pergroup-" + c.name, + ProviderId: c.catalogID, + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: c.models, + } + prov, perr := srv.CreateProvider(ctx, req) + require.NoError(t, perr, "create provider %s", c.name) + c.providerID = prov.Id + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + gSel := mkAllowGuardrail(t, ctx, "e2e-pergroup-"+c.name+"-sel", c.selectedAllow) + gOth := mkAllowGuardrail(t, ctx, "e2e-pergroup-"+c.name+"-oth", c.otherAllow) + + polMain, merr := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-pergroup-" + c.name + "-main", + Enabled: &enabled, + SourceGroups: []string{grpMain.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gSel.Id}, + }) + require.NoError(t, merr, "create main policy %s", c.name) + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMain.Id) }) + + polOther, oerr := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-pergroup-" + c.name + "-other", + Enabled: &enabled, + SourceGroups: []string{grpOther.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{gOth.Id}, + }) + require.NoError(t, oerr, "create other policy %s", c.name) + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polOther.Id) }) + } + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-pergroup-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(c *pergroupCase, model string) (int, string) { + var code int + var body string + var cerr error + switch c.wire { + case "vertex": + code, body, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, vertexProject, vertexRegion, model, "Reply with exactly: pong", "") + case "bedrock": + code, body, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, model, "Reply with exactly: pong", "") + default: + code, body, cerr = cl.Chat(ctx, settings.Endpoint, proxyIP, c.wire, model, "Reply with exactly: pong", "") + } + require.NoError(t, cerr, "request must reach the proxy for %s", c.name) + return code, body + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // grpMain's own model is served. Retry to absorb tunnel/DNS jitter on + // the first call over the freshly warmed tunnel. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(c, c.selectedReq) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + assert.Equal(t, 200, code, + "%s: grpMain's allowlisted model must be served; body: %s\n=== proxy logs ===\n%s", c.name, body, px.Logs(context.Background())) + + // grpOther's model must NOT leak to the grpMain client. + code, body = send(c, c.otherReq) + assert.Equal(t, 403, code, + "%s: another group's allowlisted model must be denied for this caller; body: %s\n=== proxy logs ===\n%s", c.name, body, px.Logs(context.Background())) + assert.Contains(t, body, "llm_policy.model_blocked", + "%s: denial must be a model-allowlist decision, not routing; body: %s", c.name, body) + }) + } +} + +// TestGuardrailMultiGroupUser proves the per-policy/group decision for a caller +// that belongs to MULTIPLE groups at once. Two scenarios, one shared stack: +// +// - union across the user's groups: the client is in gUX and gUY, each with +// its own policy+guardrail on provider P1 (gUX->union-a, gUY->union-b). The +// client may use BOTH models (the union of its groups' allowlists) while a +// third, un-allowlisted model is denied. +// - an un-guardrailed group lifts the restriction: the client is in gMP and +// gMQ on provider P2, where gMP restricts to mix-a but gMQ's policy carries +// NO guardrail. Because one applicable policy is unrestricted, the client may +// use a model on no allowlist (mix-z) as well as mix-a. +func TestGuardrailMultiGroupUser(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + const ( + unionA = "mg-union-a" + unionB = "mg-union-b" + unionC = "mg-union-c" // allowlisted by neither group + mixA = "mg-mix-a" + mixZ = "mg-mix-z" // on no allowlist; reachable only via the un-guardrailed policy + ) + + priced := func(ids ...string) *[]api.AgentNetworkProviderModel { + out := make([]api.AgentNetworkProviderModel, 0, len(ids)) + for _, id := range ids { + out = append(out, api.AgentNetworkProviderModel{Id: id, InputPer1k: 0.001, OutputPer1k: 0.001}) + } + return &out + } + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + mkGroup := func(name string) *api.Group { + g, gerr := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: name}) + require.NoError(t, gerr, "create group %s", name) + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), g.Id) }) + return g + } + gUX := mkGroup("e2e-mg-union-x") + gUY := mkGroup("e2e-mg-union-y") + gMP := mkGroup("e2e-mg-mix-p") + gMQ := mkGroup("e2e-mg-mix-q") + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-mg-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{gUX.Id, gUY.Id, gMP.Id, gMQ.Id}, // client in all four groups + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + staticKey := "static-e2e-token" + enabled := true + + // P1 — union scenario: two restricting policies, one per group. + p1, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-mg-union", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: priced(unionA, unionB, unionC), + }) + require.NoError(t, err, "create union provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), p1.Id) }) + + polUX, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-union-x", + Enabled: &enabled, + SourceGroups: []string{gUX.Id}, + DestinationProviderIds: []string{p1.Id}, + GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-union-x", unionA).Id}, + }) + require.NoError(t, err, "create union policy X") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polUX.Id) }) + + polUY, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-union-y", + Enabled: &enabled, + SourceGroups: []string{gUY.Id}, + DestinationProviderIds: []string{p1.Id}, + GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-union-y", unionB).Id}, + }) + require.NoError(t, err, "create union policy Y") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polUY.Id) }) + + // P2 — mixed scenario: one restricting policy + one un-guardrailed policy. + p2, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "e2e-mg-mix", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &staticKey, + Enabled: ptr(true), + Models: priced(mixA, mixZ), + }) + require.NoError(t, err, "create mix provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), p2.Id) }) + + polMP, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-mix-p", + Enabled: &enabled, + SourceGroups: []string{gMP.Id}, + DestinationProviderIds: []string{p2.Id}, + GuardrailIds: &[]string{mkAllowGuardrail(t, ctx, "e2e-mg-mix-p", mixA).Id}, + }) + require.NoError(t, err, "create mix policy P") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMP.Id) }) + + polMQ, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-mg-mix-q", + Enabled: &enabled, + SourceGroups: []string{gMQ.Id}, + DestinationProviderIds: []string{p2.Id}, // NO guardrail -> unrestricted + }) + require.NoError(t, err, "create mix policy Q") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), polMQ.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-mg-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + send := func(model string) (int, string) { + code, body, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, model, "Reply with exactly: pong", "") + require.NoError(t, cerr, "request must reach the proxy") + return code, body + } + sendUntil200 := func(model string) (int, string) { + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + code, body = send(model) + if code == 200 { + break + } + time.Sleep(5 * time.Second) + } + return code, body + } + + t.Run("union across the user's groups", func(t *testing.T) { + code, body := sendUntil200(unionA) + assert.Equal(t, 200, code, "model allowed by group X must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + + code, body = sendUntil200(unionB) + assert.Equal(t, 200, code, "model allowed by group Y must also be served (union across the user's groups); body: %s", body) + + code, body = send(unionC) + assert.Equal(t, 403, code, "a model on neither group's allowlist must be denied; body: %s", body) + assert.Contains(t, body, "llm_policy.model_blocked") + }) + + t.Run("an un-guardrailed group lifts the restriction", func(t *testing.T) { + code, body := sendUntil200(mixA) + assert.Equal(t, 200, code, "the restricted group's model must be served; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + + code, body = sendUntil200(mixZ) + assert.Equal(t, 200, code, + "a non-allowlisted model must be served because the user is also in a group whose policy has no guardrail; body: %s\n=== proxy logs ===\n%s", body, px.Logs(context.Background())) + }) +} + +// mkAllowGuardrail creates a guardrail whose model allowlist is enabled and holds +// exactly the given model, registering cleanup. +func mkAllowGuardrail(t *testing.T, ctx context.Context, name, model string) api.AgentNetworkGuardrail { + t.Helper() + var gr api.AgentNetworkGuardrailRequest + gr.Name = name + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{model} + g, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail %s", name) + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), g.Id) }) + return g +} diff --git a/e2e/agentnetwork/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go new file mode 100644 index 000000000..ddc95d62d --- /dev/null +++ b/e2e/agentnetwork/guardrail_test.go @@ -0,0 +1,185 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "regexp" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// bedrockRegionPrefixes and bedrockVersionSuffix mirror the proxy's Bedrock +// model normalization (region/inference-profile prefix + version suffix) so the +// provider is registered under the same catalog key the router matches against. +var ( + bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."} + bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`) +) + +// catalogModel returns the normalized catalog id the proxy stamps for a +// path-routed provider's configured model — the form the router and guardrail +// allowlist compare against (Bedrock region prefix + version stripped, Vertex +// @version stripped). +func catalogModel(pc providerCase) string { + switch pc.kind { + case harness.WireBedrock: + m := pc.model + for _, p := range bedrockRegionPrefixes { + if strings.HasPrefix(m, p) { + m = m[len(p):] + break + } + } + return bedrockVersionSuffix.ReplaceAllString(m, "") + case harness.WireVertex: + return strings.SplitN(pc.model, "@", 2)[0] + default: + return pc.model + } +} + +// disallowedModel returns a valid-shaped model id for the provider that is NOT +// the configured/allowed one, so the guardrail must reject it before the +// request ever reaches the upstream. +func disallowedModel(pc providerCase) string { + switch pc.kind { + case harness.WireBedrock: + // Same profile prefix as the allowed model so only the model name + // differs; the guardrail must deny it before it reaches AWS. + return strings.SplitN(pc.model, ".", 2)[0] + ".anthropic.claude-opus-4-8" + case harness.WireVertex: + return "claude-opus-4-8@20250101" + default: + return "unlisted-model" + } +} + +// sendModel drives one request for the given model through the provider's native +// wire shape and returns the HTTP status. +func sendModel(ctx context.Context, t *testing.T, cl *harness.Client, endpoint, proxyIP string, pc providerCase, model string) int { + t.Helper() + var code int + var err error + switch pc.kind { + case harness.WireBedrock: + code, _, err = cl.Bedrock(ctx, endpoint, proxyIP, model, "Reply with exactly: pong", "") + case harness.WireVertex: + code, _, err = cl.Vertex(ctx, endpoint, proxyIP, pc.project, pc.region, model, "Reply with exactly: pong", "") + default: + code, _, err = cl.ChatPrefixed(ctx, endpoint, proxyIP, pc.pathPrefix, pc.kind, model, "Reply with exactly: pong", "") + } + require.NoError(t, err, "request must reach the proxy for %s", pc.name) + return code +} + +// TestModelAllowlistEnforced provisions a Model Allowlist guardrail limiting each +// path-routed provider (Bedrock, Vertex) to its configured model, then drives +// requests over the tunnel: the allowed model returns 200 while a model outside +// the allowlist is denied 403 by the guardrail before it reaches the upstream. +// This is the coverage missing for #6751 — the model for these providers travels +// in the URL path, and the allowlist must be enforced there. +func TestModelAllowlistEnforced(t *testing.T) { + var providers []providerCase + for _, pc := range availableProviders() { + if pc.kind == harness.WireBedrock || pc.kind == harness.WireVertex { + providers = append(providers, pc) + } + } + if len(providers) == 0 { + t.Skip("no path-routed provider keys set (AWS_BEARER_TOKEN_BEDROCK / GOOGLE_VERTEX_*); source ~/.llm-keys") + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-allowlist"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-allowlist-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + + // Providers with their configured (allowed) models + ids := make([]string, 0, len(providers)) + allowed := make([]string, 0, len(providers)) + for _, pc := range providers { + req := providerRequest(pc) + prov, perr := srv.CreateProvider(ctx, req) + require.NoError(t, perr, "create provider %s", pc.name) + id := prov.Id + ids = append(ids, id) + allowed = append(allowed, catalogModel(pc)) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) }) + } + + // Guardrail allowlisting exactly the configured models. + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-allowlist" + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = allowed + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + t.Cleanup(func() { _ = srv.DeleteGuardrail(context.Background(), guard.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-allowlist", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: ids, + GuardrailIds: &[]string{guard.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings for endpoint") + require.NotEmpty(t, settings.Endpoint, "agent-network endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-proxy-allowlist") + require.NoError(t, err, "mint proxy token via CLI") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve agent-network endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + for _, pc := range providers { + pc := pc + t.Run(pc.name, func(t *testing.T) { + // The admin's allowlisted model is served end to end. + assert.Equal(t, 200, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, pc.model), + "allowlisted model must be permitted for %s", pc.name) + // A model outside the allowlist is rejected by the guardrail (before + // the upstream), regardless of whether it is a real catalog model. + assert.Equal(t, 403, sendModel(ctx, t, cl, settings.Endpoint, proxyIP, pc, disallowedModel(pc)), + "model outside the allowlist must be denied for %s", pc.name) + }) + } +} diff --git a/e2e/agentnetwork/main_test.go b/e2e/agentnetwork/main_test.go new file mode 100644 index 000000000..687af1d4d --- /dev/null +++ b/e2e/agentnetwork/main_test.go @@ -0,0 +1,72 @@ +//go:build e2e + +// Package agentnetwork holds the container-based agent-network e2e suite. A +// single combined server is built and bootstrapped once per package run +// (TestMain) and shared across tests via srv; each test creates and cleans up +// its own resources so order doesn't matter. +package agentnetwork + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// srv is the shared combined server for the package, ready (PAT-authenticated) +// by the time any Test runs. +var srv *harness.Combined + +func TestMain(m *testing.M) { + os.Exit(run(m)) +} + +func run(m *testing.M) int { + // Generous timeout to cover a cold image build on first run. + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + var err error + srv, err = harness.StartCombined(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "e2e: start combined server: %v\n", err) + return 1 + } + defer func() { _ = srv.Terminate(context.Background()) }() + + if _, err := srv.Bootstrap(ctx); err != nil { + fmt.Fprintf(os.Stderr, "e2e: bootstrap admin PAT: %v\n", err) + return 1 + } + + // Bootstrap the account's agent-network endpoint once for the package: + // providers no longer have settings side effects, and every data-plane + // test expects the shared account pinned to the combined proxy cluster. + cluster := harness.AgentNetworkCluster + if _, err := srv.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ProxyAddress: &cluster}); err != nil { + fmt.Fprintf(os.Stderr, "e2e: bootstrap agent-network endpoint: %v\n", err) + return 1 + } + + return m.Run() +} + +// waitBeforeRetry pauses between attempts of a polling loop and reports +// whether the caller should keep going. A cancelled context ends the loop +// where a plain sleep would keep retrying against it: every call fails +// instantly once ctx is done, so the loop would spend its whole remaining +// window sleeping between failures nobody is waiting for any more. +func waitBeforeRetry(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/e2e/agentnetwork/management_test.go b/e2e/agentnetwork/management_test.go new file mode 100644 index 000000000..9e3176d68 --- /dev/null +++ b/e2e/agentnetwork/management_test.go @@ -0,0 +1,245 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/client/rest" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func ptr[T any](v T) *T { return &v } + +// newProvider creates an OpenAI-catalog provider with a dummy key (these tests +// never call the upstream) and registers cleanup. +func newProvider(t *testing.T, ctx context.Context, name string) api.AgentNetworkProvider { + t.Helper() + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: "openai_api", + UpstreamUrl: "https://api.openai.com", + ApiKey: ptr("sk-dummy-e2e-key"), + }) + require.NoError(t, err, "create provider %q", name) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + return prov +} + +// requireClientError asserts err is a REST APIError with a 4xx status. +func requireClientError(t *testing.T, err error) { + t.Helper() + var apiErr *rest.APIError + require.ErrorAs(t, err, &apiErr, "expected a REST APIError") + assert.GreaterOrEqual(t, apiErr.StatusCode, 400, "expected a 4xx status") + assert.Less(t, apiErr.StatusCode, 500, "expected a 4xx status") +} + +// TestProviderLifecycle covers create → get → list → delete → 404 for every +// available real provider catalog (and a synthetic OpenAI provider when no +// provider keys are set), so each catalog's create and field round-trip is +// exercised. Create is offline — no upstream call — so this stays fast and +// burns no provider quota. +func TestProviderLifecycle(t *testing.T) { + ctx := context.Background() + + cases := availableProviders() + if len(cases) == 0 { + cases = []providerCase{{ + name: "openai", catalogID: "openai_api", upstream: "https://api.openai.com", + apiKey: "sk-dummy-e2e-key", model: "gpt-4o-mini", kind: harness.WireChat, + }} + } + + for _, pc := range cases { + pc := pc + t.Run(pc.name, func(t *testing.T) { + req := providerRequest(pc) + req.Name = "lc-" + pc.name + prov, err := srv.CreateProvider(ctx, req) + require.NoError(t, err, "create %s provider", pc.name) + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + assert.NotEmpty(t, prov.Id, "created provider must have an id") + assert.Equal(t, pc.catalogID, prov.ProviderId, "catalog id must round-trip") + assert.Equal(t, req.Name, prov.Name, "name must round-trip") + assert.Equal(t, pc.upstream, prov.UpstreamUrl, "upstream must round-trip") + + got, err := srv.GetProvider(ctx, prov.Id) + require.NoError(t, err, "get provider") + assert.Equal(t, prov.Id, got.Id) + + list, err := srv.ListProviders(ctx) + require.NoError(t, err, "list providers") + var ids []string + for _, p := range list { + ids = append(ids, p.Id) + } + assert.Contains(t, ids, prov.Id, "created provider must appear in the list") + + require.NoError(t, srv.DeleteProvider(ctx, prov.Id), "delete provider") + _, err = srv.GetProvider(ctx, prov.Id) + requireClientError(t, err) + }) + } +} + +// TestProviderValidation exercises the create-time validation rules. These are +// uniform across catalogs (no per-provider required-field rules exist: a +// catalog-specific malformed value such as a Vertex key without the keyfile:: +// prefix is accepted at create and only fails at the proxy), so the cases here +// are catalog-agnostic: missing API key, unknown catalog id, an invalid upstream +// URL, and a blank name. +func TestProviderValidation(t *testing.T) { + ctx := context.Background() + + _, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "No Key", + ProviderId: "openai_api", + UpstreamUrl: "https://api.openai.com", + }) + requireClientError(t, err) + + _, err = srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "Unknown Catalog", + ProviderId: "totally_unknown_provider", + UpstreamUrl: "https://example.com", + ApiKey: ptr("sk-dummy"), + }) + requireClientError(t, err) + + _, err = srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "Bad Upstream", + ProviderId: "openai_api", + UpstreamUrl: "not-a-url", + ApiKey: ptr("sk-dummy"), + }) + requireClientError(t, err) + + _, err = srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: " ", + ProviderId: "openai_api", + UpstreamUrl: "https://api.openai.com", + ApiKey: ptr("sk-dummy"), + }) + requireClientError(t, err) +} + +// TestSettingsRoundTrip flips the collection toggles and confirms the +// endpoint and proxy address stay immutable, then restores the original +// state. A second bootstrap attempt must be rejected as a conflict. +func TestSettingsRoundTrip(t *testing.T) { + ctx := context.Background() + + // The package's TestMain bootstrapped the shared account's endpoint. + before, err := srv.GetSettings(ctx) + require.NoError(t, err, "get settings") + require.NotEmpty(t, before.Endpoint, "settings must carry the bootstrapped endpoint") + require.NotEmpty(t, before.ProxyAddress, "settings must carry the bootstrapped proxy address") + + require.NotNil(t, before.AccessLogRetentionDays, "bootstrapped settings must carry a retention") + beforeRetention := *before.AccessLogRetentionDays + + flipped, err := srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + Endpoint: before.Endpoint, + ProxyAddress: before.ProxyAddress, + EnableLogCollection: !before.EnableLogCollection, + EnablePromptCollection: !before.EnablePromptCollection, + RedactPii: !before.RedactPii, + AccessLogRetentionDays: beforeRetention, + }) + require.NoError(t, err, "update settings") + assert.Equal(t, !before.EnableLogCollection, flipped.EnableLogCollection, "log collection toggle must flip") + assert.Equal(t, !before.EnablePromptCollection, flipped.EnablePromptCollection, "prompt collection toggle must flip") + require.NotNil(t, flipped.AccessLogRetentionDays) + assert.Equal(t, beforeRetention, *flipped.AccessLogRetentionDays, + "retention sent unchanged must round-trip, not reset to the zero value") + assert.Equal(t, before.Endpoint, flipped.Endpoint, "endpoint must be immutable across updates") + assert.Equal(t, before.ProxyAddress, flipped.ProxyAddress, "proxy address must be immutable across updates") + + // The account is already bootstrapped: a second bootstrap is a conflict, + // whatever shape it asks for. + _, err = srv.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ + Endpoint: ptr("attacker.cluster.invalid"), + }) + requireClientError(t, err) + + // The identity fields ride along on the PUT as a required echo: a request + // carrying a different endpoint is rejected without applying anything. + _, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + Endpoint: "other.cluster.invalid", + ProxyAddress: before.ProxyAddress, + EnableLogCollection: before.EnableLogCollection, + EnablePromptCollection: before.EnablePromptCollection, + RedactPii: before.RedactPii, + AccessLogRetentionDays: beforeRetention, + }) + requireClientError(t, err) + + // Restore the original toggles. + _, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + Endpoint: before.Endpoint, + ProxyAddress: before.ProxyAddress, + EnableLogCollection: before.EnableLogCollection, + EnablePromptCollection: before.EnablePromptCollection, + RedactPii: before.RedactPii, + AccessLogRetentionDays: beforeRetention, + }) + require.NoError(t, err, "restore settings") +} + +// TestPolicyWindowFloor rejects an enabled limit below the 60s window floor and +// accepts one at the floor. +func TestPolicyWindowFloor(t *testing.T) { + ctx := context.Background() + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-policy-grp"}) + require.NoError(t, err, "create source group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + prov := newProvider(t, ctx, "Policy Provider") + + limits := func(window int64) *api.AgentNetworkPolicyLimits { + return &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 1000, + UserCap: 1000, + WindowSeconds: window, + }, + } + } + + _, err = srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-below-floor", + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: limits(30), + }) + requireClientError(t, err) + + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-at-floor", + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: limits(60), + }) + require.NoError(t, err, "policy at the 60s floor must be accepted") + assert.NotEmpty(t, pol.Id, "created policy must have an id") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) +} + +// TestConsumptionList confirms the read endpoint always returns an array, never +// a 404/500. +func TestConsumptionList(t *testing.T) { + ctx := context.Background() + + rows, err := srv.ListConsumption(ctx) + require.NoError(t, err, "consumption list must not error") + assert.NotNil(t, rows, "consumption must be a JSON array (possibly empty)") +} diff --git a/e2e/agentnetwork/settings_bootstrap_test.go b/e2e/agentnetwork/settings_bootstrap_test.go new file mode 100644 index 000000000..806ce82f4 --- /dev/null +++ b/e2e/agentnetwork/settings_bootstrap_test.go @@ -0,0 +1,179 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// harnessStartFresh boots a dedicated combined server with its own fresh +// account and registers its teardown on t. Unlike the shared srv, the fresh +// account has NOT had its agent-network endpoint bootstrapped. +func harnessStartFresh(ctx context.Context, t *testing.T) (*harness.Combined, error) { + t.Helper() + fresh, err := harness.StartCombined(ctx) + if err != nil { + return nil, err + } + t.Cleanup(func() { _ = fresh.Terminate(context.Background()) }) + if _, err := fresh.Bootstrap(ctx); err != nil { + return nil, err + } + return fresh, nil +} + +// TestSettingsBootstrapViaPost covers the explicit bootstrap contract on an +// account that has never been bootstrapped: the GET reads as the defaults +// with an empty endpoint/proxy_address, a PUT has no row to update and fails, +// and a POST creates the row and assigns the immutable endpoint — labeled +// beneath a proxy address here, with the toggle overrides from the same +// request applied. The shared srv cannot provide that starting state +// (TestMain bootstraps it), so this boots a dedicated combined server — the +// image is already built and cached by TestMain's StartCombined, so the extra +// cost is one container start. +func TestSettingsBootstrapViaPost(t *testing.T) { + ctx := context.Background() + + fresh, err := harnessStartFresh(ctx, t) + require.NoError(t, err, "start dedicated combined server") + + // Before agent-network bootstrap the settings read as the defaults, not + // as an error and not as a null body. + before, err := fresh.GetSettings(ctx) + require.NoError(t, err, "get settings on a fresh account must succeed") + assert.Empty(t, before.Endpoint, "endpoint must be empty before bootstrap") + assert.Empty(t, before.ProxyAddress, "proxy address must be empty before bootstrap") + assert.False(t, before.Dedicated, "an unbootstrapped account has no serving shape") + assert.True(t, before.EnableLogCollection, "defaults must show log collection on, matching bootstrap") + assert.False(t, before.EnablePromptCollection, "defaults must show prompt collection off") + + // A PUT has no row to update yet — bootstrap is the explicit POST. + _, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + EnableLogCollection: true, + AccessLogRetentionDays: 30, + }) + requireClientError(t, err) + + // A POST with a proxy address bootstraps a labeled endpoint and applies + // the toggles from the same request. Every toggle is set away from its + // bootstrap default so each assertion can actually fail. + const cluster = "e2e.bootstrap.netbird.selfhosted" + bootstrapped, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ + ProxyAddress: ptr(cluster), + EnableLogCollection: ptr(false), + EnablePromptCollection: ptr(true), + RedactPii: ptr(true), + }) + require.NoError(t, err, "bootstrap settings via POST must succeed") + assert.Equal(t, cluster, bootstrapped.ProxyAddress, "proxy address must be pinned from the request") + require.NotEmpty(t, bootstrapped.Endpoint, "endpoint must be assigned at bootstrap") + assert.True(t, strings.HasSuffix(bootstrapped.Endpoint, "."+cluster), + "labeled endpoint must hang one label beneath the proxy address: %s", bootstrapped.Endpoint) + assert.False(t, bootstrapped.Dedicated, "a labeled pin is not dedicated") + assert.False(t, bootstrapped.EnableLogCollection, "log collection from the bootstrap request must override the default") + assert.True(t, bootstrapped.EnablePromptCollection, "prompt collection from the bootstrap request must apply") + assert.True(t, bootstrapped.RedactPii, "redact toggle from the bootstrap request must apply") + + // The row is persisted: an independent read agrees on every field. + after, err := fresh.GetSettings(ctx) + require.NoError(t, err, "get settings after bootstrap must succeed") + assert.Equal(t, bootstrapped.Endpoint, after.Endpoint, "bootstrap must persist across reads") + assert.Equal(t, bootstrapped.EnableLogCollection, after.EnableLogCollection, "log collection must persist") + assert.Equal(t, bootstrapped.EnablePromptCollection, after.EnablePromptCollection, "prompt collection must persist") + assert.Equal(t, bootstrapped.RedactPii, after.RedactPii, "redact toggle must persist") + + // Once bootstrapped, PUT updates the toggles. The identity fields ride + // along as a required echo of the assigned values; a matching echo is + // accepted and never written. + persisted, err := fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + Endpoint: bootstrapped.Endpoint, + ProxyAddress: bootstrapped.ProxyAddress, + EnableLogCollection: true, + EnablePromptCollection: false, + RedactPii: true, + AccessLogRetentionDays: 21, + }) + require.NoError(t, err, "post-bootstrap update must succeed") + require.NotNil(t, persisted.AccessLogRetentionDays) + assert.Equal(t, 21, *persisted.AccessLogRetentionDays, "retention from the update must apply") + assert.Equal(t, bootstrapped.Endpoint, persisted.Endpoint, "endpoint must survive updates untouched") + assert.Equal(t, cluster, persisted.ProxyAddress, "proxy address must survive updates untouched") + assert.True(t, persisted.EnableLogCollection, "post-bootstrap toggle must apply") + assert.False(t, persisted.EnablePromptCollection, "post-bootstrap toggle must apply") + + // The endpoint is immutable: a PUT carrying a different endpoint is + // rejected, and a second bootstrap is rejected as a conflict. Neither + // rejected write may disturb anything. + _, err = fresh.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + Endpoint: "other.cluster.invalid", + ProxyAddress: persisted.ProxyAddress, + EnableLogCollection: persisted.EnableLogCollection, + EnablePromptCollection: persisted.EnablePromptCollection, + RedactPii: persisted.RedactPii, + AccessLogRetentionDays: 21, + }) + requireClientError(t, err) + + _, err = fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ + Endpoint: ptr("other.cluster.invalid"), + }) + requireClientError(t, err) + + final, err := fresh.GetSettings(ctx) + require.NoError(t, err, "get settings after the rejected bootstrap must succeed") + assert.Equal(t, persisted.Endpoint, final.Endpoint, "rejected bootstrap must not change the endpoint") + assert.Equal(t, persisted.ProxyAddress, final.ProxyAddress, "rejected bootstrap must not change the proxy address") + assert.Equal(t, persisted.EnableLogCollection, final.EnableLogCollection, "rejected bootstrap must not apply its toggles") + assert.Equal(t, persisted.EnablePromptCollection, final.EnablePromptCollection, "rejected bootstrap must not apply its toggles") + assert.Equal(t, persisted.RedactPii, final.RedactPii, "rejected bootstrap must not apply its toggles") +} + +// TestSettingsBootstrapSelfAddressed covers the dedicated shape end to end: +// a POST carrying an endpoint claims the hostname verbatim, the proxy address +// equals it, and the pin reads as dedicated — the address-first flow a +// self-hosted operator uses before deploying the proxy that will declare it. +// The tail covers the recovery path the guarded DELETE exists for: with no +// providers and no proxy at the address, the claim can be released and a +// fresh bootstrap succeeds — the fix for a typo'd immutable endpoint. +func TestSettingsBootstrapSelfAddressed(t *testing.T) { + ctx := context.Background() + + fresh, err := harnessStartFresh(ctx, t) + require.NoError(t, err, "start dedicated combined server") + + created, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ + Endpoint: ptr("gw.e2e.netbird.selfhosted"), + }) + require.NoError(t, err, "self-addressed bootstrap must succeed") + assert.Equal(t, "gw.e2e.netbird.selfhosted", created.Endpoint, "endpoint must be claimed verbatim") + assert.Equal(t, created.Endpoint, created.ProxyAddress, "self-addressed: proxy address is the endpoint") + assert.True(t, created.Dedicated, "a self-addressed pin is dedicated") + + // No providers exist and no proxy declares the address, so both delete + // guards are clear: the delete releases the claim and the account reads + // as unbootstrapped defaults again. + require.NoError(t, fresh.DeleteSettings(ctx), "guarded delete with both guards clear must succeed") + + after, err := fresh.GetSettings(ctx) + require.NoError(t, err, "get settings after delete must succeed") + assert.Empty(t, after.Endpoint, "a deleted account must read as unbootstrapped") + + // A second delete has nothing to remove. + requireClientError(t, fresh.DeleteSettings(ctx)) + + // Re-creating is a fresh bootstrap — the released hostname is free to be + // claimed again, or a different one chosen. + recreated, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ + Endpoint: ptr("gw2.e2e.netbird.selfhosted"), + }) + require.NoError(t, err, "bootstrap after delete must succeed") + assert.Equal(t, "gw2.e2e.netbird.selfhosted", recreated.Endpoint, "the fresh bootstrap claims the new hostname") +} diff --git a/e2e/agentnetwork/skiptls_test.go b/e2e/agentnetwork/skiptls_test.go new file mode 100644 index 000000000..85af8c604 --- /dev/null +++ b/e2e/agentnetwork/skiptls_test.go @@ -0,0 +1,139 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestProviderSkipTLSVerification proves skip_tls_verification is per-provider: +// two providers share one self-signed upstream, one skipping TLS verification +// and one not. The skip=true provider's chat reaches the upstream and returns +// 200; the skip=false provider's chat fails at the TLS handshake — same +// upstream, opposite outcome. This is the behaviour a target-level flag could +// not give, since all of an account's providers share one synthesised target. +func TestProviderSkipTLSVerification(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + up, err := harness.StartFakeUpstream(ctx, srv) + require.NoError(t, err, "start self-signed upstream") + t.Cleanup(func() { _ = up.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-skiptls"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-skiptls-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + const ( + insecureModel = "insecure-model" + secureModel = "secure-model" + ) + + // Two providers on the SAME self-signed upstream, distinguished only by their + // skip_tls_verification and a unique model string so the router picks each + // unambiguously. + newReq := func(name, model string, skip bool) api.AgentNetworkProviderRequest { + key := "sk-dummy-e2e" + return api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: "openai_api", + UpstreamUrl: up.URL, + ApiKey: &key, + Enabled: ptr(true), + SkipTlsVerification: ptr(skip), + Models: &[]api.AgentNetworkProviderModel{ + {Id: model, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + } + } + + insecureReq := newReq("skip-tls", insecureModel, true) + insecureProv, err := srv.CreateProvider(ctx, insecureReq) + require.NoError(t, err, "create skip-tls provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), insecureProv.Id) }) + require.True(t, insecureProv.SkipTlsVerification, "response must echo skip_tls_verification=true") + + secureProv, err := srv.CreateProvider(ctx, newReq("verify-tls", secureModel, false)) + require.NoError(t, err, "create verify-tls provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), secureProv.Id) }) + require.False(t, secureProv.SkipTlsVerification, "response must echo skip_tls_verification=false") + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-skiptls-allow", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{insecureProv.Id, secureProv.Id}, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-skiptls-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + // Positive: skip=true reaches the self-signed upstream. Retry to absorb + // tunnel/DNS jitter on the first call; success also proves the path works. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, insecureModel, "Reply with exactly: pong", "e2e-skiptls-insecure") + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + time.Sleep(5 * time.Second) + } + require.Equal(t, 200, code, + "skip_tls_verification=true must reach the self-signed upstream; body: %s\n=== upstream logs ===\n%s\n=== proxy logs ===\n%s", + body, up.Logs(context.Background()), px.Logs(context.Background())) + + // Negative: skip=false must fail the TLS handshake to the SAME upstream. The + // path is already proven working, so a non-200 here is the cert rejection. + secureCode, secureBody, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, secureModel, "Reply with exactly: pong", "e2e-skiptls-secure") + require.NoError(t, cerr, "the chat call itself must complete (proxy returns an error status, not a transport error)") + require.NotEqual(t, 200, secureCode, + "skip_tls_verification=false must NOT reach the self-signed upstream; got %d, body: %s", secureCode, secureBody) + require.GreaterOrEqual(t, secureCode, 500, + "a TLS verification failure should surface as a 5xx from the proxy; got %d, body: %s", secureCode, secureBody) +} diff --git a/e2e/agentnetwork/streaming_test.go b/e2e/agentnetwork/streaming_test.go new file mode 100644 index 000000000..a5fa8df3f --- /dev/null +++ b/e2e/agentnetwork/streaming_test.go @@ -0,0 +1,209 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// streamedModel is priced high enough that a mis-metered request is obvious in +// the recorded cost, and named so it cannot collide with another test's route. +const streamedModel = "e2e-streamed-model" + +const ( + streamInRate = 0.010 + streamOutRate = 0.020 + // The cache-read bucket is priced separately from input, so a run that + // folded the two together fails the per-bucket assertions below. + streamCacheReadRate = 0.001 +) + +// TestStreamingResponseMetersInputTokens is the end-to-end guard for the +// metering bug this endpoint's gateway-protocol work fixed. +// +// On a streamed answer the input-token count exists only in the opening +// message_start event; every later frame reports output. A response read with +// the wrong vendor's parser — the shape a gateway record produces when it names +// one API surface and serves another — never looks at that event, so input +// metered as zero and the bulk of the bill silently vanished. Nothing in the +// suite sent stream: true before this test, so the whole branch went unrun. +// +// The provider points at the mock's streaming listener, which answers every +// request as SSE with token counts that differ from the buffered surface. That +// difference is the point: passing these assertions is only possible if the +// stream accumulator ran. +func TestStreamingResponseMetersInputTokens(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + env := provisionStreamingProvider(t, ctx, "anthropic_api") + + sessionID := fmt.Sprintf("e2e-session-stream-%d", time.Now().UnixNano()) + code, body := chatStreamUntil(t, ctx, env, harness.WireMessages, streamedModel, sessionID) + require.Equal(t, 200, code, "streamed chat must succeed; body: %s", body) + assert.Contains(t, body, "message_start", + "the client must receive the event stream itself, not a buffered rewrite of it") + + row := findAccessLogBySession(t, ctx, sessionID) + + assert.Equal(t, harness.VLLMStreamInputTokens, int(row.InputTokens), + "input tokens live in message_start; zero here is the bug this test exists for") + assert.Equal(t, harness.VLLMStreamOutputTokens, int(row.OutputTokens), + "output tokens ride message_delta and supersede the message_start seed") + assert.Equal(t, harness.VLLMStreamCacheReadTokens, int(row.CachedInputTokens), + "the Anthropic cache bucket rides message_start too, and only its own parser reads it") + + // The Anthropic surface bills cache reads additively, so the input bucket + // prices the full input count rather than a remainder. + wantInput := float64(harness.VLLMStreamInputTokens) / 1000 * streamInRate + wantOutput := float64(harness.VLLMStreamOutputTokens) / 1000 * streamOutRate + wantCacheRead := float64(harness.VLLMStreamCacheReadTokens) / 1000 * streamCacheReadRate + assert.InDelta(t, wantInput, row.InputCostUsd, 1e-6, "input cost must price the streamed input tokens") + assert.InDelta(t, wantOutput, row.OutputCostUsd, 1e-6, "output cost must price the streamed output tokens") + // The total, not merely a positive number: input and output alone are + // positive, so a cache bucket parsed and then never billed would pass any + // weaker assertion. The gap is 7e-6, well outside the delta. + assert.InDelta(t, wantInput+wantOutput+wantCacheRead, row.CostUsd, 1e-6, + "the recorded cost must be every bucket the surface bills, cache reads included") +} + +// TestStreamingOnGatewayTypedProvider drives the same streamed Anthropic call +// through a provider record whose catalog id names the OpenAI surface — the +// exact misconfiguration that hid the bug, since gateway records commonly pin +// one parser while the upstream serves another shape entirely. +// +// The router must choose the parser from the request path rather than the +// record's provider id, or the Anthropic usage block goes unread and input +// meters at zero all over again. +func TestStreamingOnGatewayTypedProvider(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + env := provisionStreamingProvider(t, ctx, "openai_api") + + sessionID := fmt.Sprintf("e2e-session-stream-gw-%d", time.Now().UnixNano()) + code, body := chatStreamUntil(t, ctx, env, harness.WireMessages, streamedModel, sessionID) + require.Equal(t, 200, code, "streamed chat through a gateway record must succeed; body: %s", body) + + row := findAccessLogBySession(t, ctx, sessionID) + + assert.Equal(t, harness.VLLMStreamInputTokens, int(row.InputTokens), + "a record typed openai_api must still read the Anthropic usage block it is actually serving") + assert.Equal(t, harness.VLLMStreamOutputTokens, int(row.OutputTokens), + "output tokens must survive the surface mismatch too") + assert.InDelta(t, float64(harness.VLLMStreamInputTokens)/1000*streamInRate, row.InputCostUsd, 1e-6, + "the request must be priced on the surface it spoke, not the one the record names") +} + +// provisionStreamingProvider brings up the mock, one provider pointed at its +// streaming listener under the given catalog id, a policy authorising it, and a +// connected proxy + client. +func provisionStreamingProvider(t *testing.T, ctx context.Context, catalogID string) pricedEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + name := "stream-" + catalogID + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-" + name}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-" + name + "-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + // Deleting the group does not delete the key it auto-joins, so the key + // needs a cleanup of its own. + t.Cleanup(func() { _ = srv.API().SetupKeys.Delete(context.Background(), sk.Id) }) + require.NotEmpty(t, sk.Key, "setup key plaintext") + + dummyKey := "sk-stream-e2e" + cacheRead := streamCacheReadRate + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: catalogID, + UpstreamUrl: vllm.StreamURL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{{ + Id: streamedModel, + InputPer1k: streamInRate, + OutputPer1k: streamOutRate, + CacheReadPer1k: &cacheRead, + }}, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-" + name, + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + endpoint, proxyIP, cl, px := connectClient(t, ctx, name, sk.Key) + return pricedEnv{ + providerID: prov.Id, + groupID: grp.Id, + policyID: pol.Id, + upstream: vllm.StreamURL, + endpoint: endpoint, + proxyIP: proxyIP, + client: cl, + proxy: px, + } +} + +// chatStreamUntil drives one streamed chat, retrying to absorb the tunnel and +// DNS jitter a first call through a fresh peer can hit. +func chatStreamUntil(t *testing.T, ctx context.Context, env pricedEnv, kind, model, sessionID string) (int, string) { + t.Helper() + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, cerr := env.client.ChatStream(ctx, env.endpoint, env.proxyIP, kind, model, "Reply with exactly: pong", sessionID) + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + if !waitBeforeRetry(ctx, 5*time.Second) { + break + } + } + if code != 200 { + t.Logf("=== proxy logs ===\n%s", env.proxy.Logs(context.Background())) + } + return code, body +} diff --git a/e2e/agentnetwork/vllm_test.go b/e2e/agentnetwork/vllm_test.go new file mode 100644 index 000000000..6f5117b2f --- /dev/null +++ b/e2e/agentnetwork/vllm_test.go @@ -0,0 +1,171 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestVLLMProvider proves the proxy supports a self-hosted vLLM backend. vLLM is +// OpenAI-compatible, so it uses the "vllm" catalog entry (KindCustom) and is +// reached over plain HTTP — no TLS anywhere on the path: +// +// client --tunnel--> netbird proxy --http--> vllm (:8000, OpenAI-compatible) +// +// The mock vLLM server answers /v1/chat/completions with an OpenAI-shaped +// completion carrying a non-zero usage block. The test asserts the chat returns +// 200 with the completion, that the request is recorded in the access log by its +// session id, and that vLLM's usage block is metered into a consumption row — +// which together prove request routing, response parsing, and token accounting +// all work for a self-hosted OpenAI-compatible provider. +// +// It needs no external credentials (the mock ignores auth), so it always runs. +func TestVLLMProvider(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock vLLM server") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-vllm"}) + require.NoError(t, err, "create group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) }) + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-vllm-client", + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + require.NotEmpty(t, sk.Key, "setup key plaintext") + + // vLLM provider pointed at the mock over plain HTTP. The mock ignores auth, + // so a dummy key satisfies the "Bearer ${API_KEY}" template. The served model + // is enumerated so the router dispatches this model string to this provider. + dummyKey := "sk-vllm-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "vllm", + ProviderId: "vllm", + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + }) + require.NoError(t, err, "create vllm provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // Token limit far above the handful of tokens this test drives, so it never + // blocks but switches on usage metering — the switch that makes consumption + // rows get recorded. + enabled := true + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-vllm-allow", + Enabled: &enabled, + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-vllm-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + // Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts. + proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + before, _ := srv.ListAccessLogs(ctx) + sessionID := "e2e-session-vllm" + + // Retry to absorb tunnel/DNS jitter on the first call. + var code int + var body string + deadline := time.Now().Add(90 * time.Second) + for time.Now().Before(deadline) { + c, b, cerr := cl.Chat(ctx, settings.Endpoint, proxyIP, harness.WireChat, harness.VLLMModel, "Reply with exactly: pong", sessionID) + if cerr == nil { + code, body = c, b + if code == 200 { + break + } + } + time.Sleep(5 * time.Second) + } + require.Equal(t, 200, code, + "chat through the vLLM provider must return 200; body: %s\n=== vllm logs ===\n%s\n=== proxy logs ===\n%s", + body, vllm.Logs(context.Background()), px.Logs(context.Background())) + require.True(t, strings.Contains(body, "chat.completion"), + "body should be an OpenAI-compatible chat completion; got: %s", body) + + // The request must surface as an access-log row carrying our session id. + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + return lerr == nil && logs.TotalRecords > before.TotalRecords + }, 30*time.Second, 2*time.Second, "an access-log row should be ingested for the vLLM provider") + + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + if lerr != nil { + return false + } + for _, r := range logs.Data { + if r.SessionId != nil && *r.SessionId == sessionID { + return true + } + } + return false + }, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row", sessionID) + + // vLLM's usage block (prompt_tokens=11, completion_tokens=2) must be parsed + // and metered into a consumption row with positive token counts. + require.Eventually(t, func() bool { + rows, lerr := srv.ListConsumption(ctx) + if lerr != nil { + return false + } + for _, r := range rows { + if r.TokensInput > 0 && r.TokensOutput > 0 { + return true + } + } + return false + }, 60*time.Second, 3*time.Second, "vLLM usage must be metered into a consumption row") +} diff --git a/e2e/harness/Dockerfile.client b/e2e/harness/Dockerfile.client new file mode 100644 index 000000000..74a3ec245 --- /dev/null +++ b/e2e/harness/Dockerfile.client @@ -0,0 +1,28 @@ +# Multistage build for the NetBird client used in e2e tests. The repo has no +# source-building client Dockerfile (client/Dockerfile packages a goreleaser +# artifact), so this mirrors its alpine runtime + entrypoint while compiling the +# CGO-free client inline. BuildKit cache mounts keep rebuilds incremental. + +FROM golang:1.25-bookworm AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -o /out/netbird ./client + +FROM alpine:3.24 +RUN apk add --no-cache bash ca-certificates ip6tables iproute2 iptables +ENV NETBIRD_BIN="/usr/local/bin/netbird" \ + NB_LOG_FILE="console,/var/log/netbird/client.log" \ + NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \ + NB_ENABLE_CAPTURE="false" \ + NB_ENTRYPOINT_SERVICE_TIMEOUT="30" +ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] +# --chmod because the build context is not always a git checkout. A suite in +# another module builds from this module's extracted copy in the module cache, +# where every file is 0444 — the cache drops the executable bit git records — and +# a bare COPY then produces an entrypoint the runtime cannot exec. +COPY --chmod=0755 client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh +COPY --from=builder /out/netbird /usr/local/bin/netbird diff --git a/e2e/harness/agentnetwork.go b/e2e/harness/agentnetwork.go new file mode 100644 index 000000000..e85475dff --- /dev/null +++ b/e2e/harness/agentnetwork.go @@ -0,0 +1,178 @@ +//go:build e2e + +package harness + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// The shared REST client doesn't (yet) expose typed agent-network methods, so +// these helpers drive the /api/agent-network/* endpoints through the client's +// NewRequest primitive — reusing its auth, error handling (rest.APIError on +// non-2xx), and transport — while still speaking the generated api types. + +// anRequest issues an agent-network API call and decodes the JSON response into +// T. A non-2xx response surfaces as a *rest.APIError from the client, which +// tests inspect for negative-path status assertions. +func anRequest[T any](ctx context.Context, c *Combined, method, path string, body any) (T, error) { + var out T + var reader io.Reader + if body != nil { + bs, err := json.Marshal(body) + if err != nil { + return out, fmt.Errorf("marshal %s %s: %w", method, path, err) + } + reader = bytes.NewReader(bs) + } + + resp, err := c.api.NewRequest(ctx, method, path, reader, nil) + if err != nil { + return out, err + } + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return out, fmt.Errorf("decode %s %s response: %w", method, path, err) + } + return out, nil +} + +// anDelete issues a DELETE and discards the (empty-object) body. +func anDelete(ctx context.Context, c *Combined, path string) error { + resp, err := c.api.NewRequest(ctx, http.MethodDelete, path, nil, nil) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +// CreateProvider creates an agent-network provider. +func (c *Combined) CreateProvider(ctx context.Context, req api.AgentNetworkProviderRequest) (api.AgentNetworkProvider, error) { + return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodPost, "/api/agent-network/providers", req) +} + +// GetProvider fetches a provider by id. +func (c *Combined) GetProvider(ctx context.Context, id string) (api.AgentNetworkProvider, error) { + return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodGet, "/api/agent-network/providers/"+id, nil) +} + +// ListProviders returns all providers for the account. +func (c *Combined) ListProviders(ctx context.Context) ([]api.AgentNetworkProvider, error) { + return anRequest[[]api.AgentNetworkProvider](ctx, c, http.MethodGet, "/api/agent-network/providers", nil) +} + +// DeleteProvider removes a provider by id. +func (c *Combined) DeleteProvider(ctx context.Context, id string) error { + return anDelete(ctx, c, "/api/agent-network/providers/"+id) +} + +// UpdateProvider replaces a provider by id (PUT). The API key may be omitted on +// the request to keep the stored one; Models replaces the enumerated list, so +// this is the path a test uses to change a model's price mid-run. +func (c *Combined) UpdateProvider(ctx context.Context, id string, req api.AgentNetworkProviderRequest) (api.AgentNetworkProvider, error) { + return anRequest[api.AgentNetworkProvider](ctx, c, http.MethodPut, "/api/agent-network/providers/"+id, req) +} + +// SetProviderEnabled toggles a provider's enabled flag, preserving its other +// fields (the API key is omitted, which keeps the stored one). Used to run one +// provider at a time so model→provider routing is unambiguous. +func (c *Combined) SetProviderEnabled(ctx context.Context, id string, enabled bool) error { + p, err := c.GetProvider(ctx, id) + if err != nil { + return err + } + _, err = anRequest[api.AgentNetworkProvider](ctx, c, http.MethodPut, "/api/agent-network/providers/"+id, api.AgentNetworkProviderRequest{ + Name: p.Name, + ProviderId: p.ProviderId, + UpstreamUrl: p.UpstreamUrl, + Enabled: &enabled, + Models: &p.Models, + }) + return err +} + +// CreatePolicy creates an agent-network policy. +func (c *Combined) CreatePolicy(ctx context.Context, req api.AgentNetworkPolicyRequest) (api.AgentNetworkPolicy, error) { + return anRequest[api.AgentNetworkPolicy](ctx, c, http.MethodPost, "/api/agent-network/policies", req) +} + +// UpdatePolicy replaces a policy by id. +func (c *Combined) UpdatePolicy(ctx context.Context, id string, req api.AgentNetworkPolicyRequest) (api.AgentNetworkPolicy, error) { + return anRequest[api.AgentNetworkPolicy](ctx, c, http.MethodPut, "/api/agent-network/policies/"+id, req) +} + +// DeletePolicy removes a policy by id. +func (c *Combined) DeletePolicy(ctx context.Context, id string) error { + return anDelete(ctx, c, "/api/agent-network/policies/"+id) +} + +// CreateGuardrail creates an agent-network guardrail (e.g. a model allowlist) +// that can then be attached to a policy via its GuardrailIds. +func (c *Combined) CreateGuardrail(ctx context.Context, req api.AgentNetworkGuardrailRequest) (api.AgentNetworkGuardrail, error) { + return anRequest[api.AgentNetworkGuardrail](ctx, c, http.MethodPost, "/api/agent-network/guardrails", req) +} + +// DeleteGuardrail removes a guardrail by id. +func (c *Combined) DeleteGuardrail(ctx context.Context, id string) error { + return anDelete(ctx, c, "/api/agent-network/guardrails/"+id) +} + +// CreateSettings bootstraps the account's agent-network settings row, +// assigning the immutable endpoint. Exactly one of req.ProxyAddress (labeled +// endpoint beneath that cluster) and req.Endpoint (self-addressed dedicated +// endpoint) must be set; a second bootstrap returns a conflict. +func (c *Combined) CreateSettings(ctx context.Context, req api.AgentNetworkSettingsCreateRequest) (api.AgentNetworkSettings, error) { + return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPost, "/api/agent-network/settings", req) +} + +// GetSettings returns the account's agent-network settings row. Before the +// CreateSettings bootstrap it reads as the defaults with an empty endpoint. +func (c *Combined) GetSettings(ctx context.Context) (api.AgentNetworkSettings, error) { + return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodGet, "/api/agent-network/settings", nil) +} + +// UpdateSettings applies the mutable collection toggles. The request must +// echo the assigned endpoint and proxy address unchanged — the server rejects +// a PUT that tries to change them. +func (c *Combined) UpdateSettings(ctx context.Context, req api.AgentNetworkSettingsRequest) (api.AgentNetworkSettings, error) { + return anRequest[api.AgentNetworkSettings](ctx, c, http.MethodPut, "/api/agent-network/settings", req) +} + +// DeleteSettings removes the account's settings row, releasing the endpoint. +// Refused while providers exist or a proxy is actively serving the endpoint. +func (c *Combined) DeleteSettings(ctx context.Context) error { + return anDelete(ctx, c, "/api/agent-network/settings") +} + +// ListConsumption returns the account's consumption rows (possibly empty). +func (c *Combined) ListConsumption(ctx context.Context) ([]api.AgentNetworkConsumption, error) { + return anRequest[[]api.AgentNetworkConsumption](ctx, c, http.MethodGet, "/api/agent-network/consumption", nil) +} + +// ListAccessLogs returns the account's agent-network access-log page (the +// flattened per-request rows the proxy ships and management ingests). +func (c *Combined) ListAccessLogs(ctx context.Context) (api.AgentNetworkAccessLogsResponse, error) { + return anRequest[api.AgentNetworkAccessLogsResponse](ctx, c, http.MethodGet, "/api/agent-network/access-logs", nil) +} + +// ListAccessLogsFiltered returns the access-log page narrowed by the given +// query parameters (e.g. model=..., session_id=..., provider_id=...). This +// exercises management's server-side filtering rather than filtering client +// side, so a row that is ingested but not indexed under the filtered column +// surfaces as an empty page. +func (c *Combined) ListAccessLogsFiltered(ctx context.Context, query url.Values) (api.AgentNetworkAccessLogsResponse, error) { + path := "/api/agent-network/access-logs" + if encoded := query.Encode(); encoded != "" { + path += "?" + encoded + } + return anRequest[api.AgentNetworkAccessLogsResponse](ctx, c, http.MethodGet, path, nil) +} diff --git a/e2e/harness/bootstrap.go b/e2e/harness/bootstrap.go new file mode 100644 index 000000000..defa03c14 --- /dev/null +++ b/e2e/harness/bootstrap.go @@ -0,0 +1,47 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + + "github.com/netbirdio/netbird/shared/management/client/rest" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// Bootstrap creates the initial admin owner through the unauthenticated +// /api/setup endpoint and returns the plaintext admin PAT. It also wires an +// authenticated REST client on the Combined (see API). create_pat requires the +// server to run with NB_SETUP_PAT_ENABLED=true, which the harness sets. A +// second call returns an error (the server reports setup already completed). +func (c *Combined) Bootstrap(ctx context.Context) (string, error) { + // The setup endpoint is unauthenticated; use a tokenless client. + setupClient := rest.NewWithOptions(rest.WithManagementURL(c.BaseURL)) + + createPAT := true + expireDays := 1 + resp, err := setupClient.Instance.Setup(ctx, api.PostApiSetupJSONRequestBody{ //nolint:gosec // static throwaway test credentials + Email: "admin@netbird.test", + Password: "Netbird-e2e-Passw0rd!", + Name: "E2E Admin", + CreatePat: &createPAT, + PatExpireIn: &expireDays, + }) + if err != nil { + return "", fmt.Errorf("instance setup: %w", err) + } + if resp.PersonalAccessToken == nil || *resp.PersonalAccessToken == "" { + return "", fmt.Errorf("setup succeeded but no PAT returned (is NB_SETUP_PAT_ENABLED set?)") + } + + c.PAT = *resp.PersonalAccessToken + c.api = rest.New(c.BaseURL, c.PAT) + return c.PAT, nil +} + +// API returns the PAT-authenticated management REST client. It is nil until +// Bootstrap runs. +func (c *Combined) API() *rest.Client { + return c.api +} diff --git a/e2e/harness/cert.go b/e2e/harness/cert.go new file mode 100644 index 000000000..c8a28e470 --- /dev/null +++ b/e2e/harness/cert.go @@ -0,0 +1,66 @@ +//go:build e2e + +package harness + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "time" +) + +// writeSelfSignedCert generates a self-signed TLS cert/key pair covering the +// given DNS names and writes them as tls.crt / tls.key in dir. The proxy serves +// this for the agent-network endpoint; the client curls with -k, so validity +// chains don't matter — the proxy just needs a usable cert to present. +func writeSelfSignedCert(dir string, dnsNames []string) error { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("generate key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return fmt.Errorf("generate serial: %w", err) + } + + tmpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: dnsNames[0]}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: dnsNames, + BasicConstraintsValid: true, + } + + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + if err != nil { + return fmt.Errorf("create certificate: %w", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + if err := os.WriteFile(filepath.Join(dir, "tls.crt"), certPEM, 0o644); err != nil { //nolint:gosec // public cert, bind-mounted and read by the proxy container + return fmt.Errorf("write cert: %w", err) + } + + keyDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return fmt.Errorf("marshal key: %w", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + // World-readable so the (non-root) proxy container can read the bind-mounted + // key on Linux CI runners; this is a throwaway self-signed e2e key. + if err := os.WriteFile(filepath.Join(dir, "tls.key"), keyPEM, 0o644); err != nil { //nolint:gosec // throwaway self-signed e2e key, must be readable by the proxy container uid + return fmt.Errorf("write key: %w", err) + } + return nil +} diff --git a/e2e/harness/client.go b/e2e/harness/client.go new file mode 100644 index 000000000..73931027d --- /dev/null +++ b/e2e/harness/client.go @@ -0,0 +1,441 @@ +//go:build e2e + +package harness + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/testcontainers/testcontainers-go" + tcexec "github.com/testcontainers/testcontainers-go/exec" +) + +const ( + clientDockerfile = "e2e/harness/Dockerfile.client" + // defaultClientImage is the local tag the client is built under from + // clientDockerfile. Override with NB_E2E_CLIENT_IMAGE: a value with a "/" is + // pulled as a published image; a bare tag is built under that name. + defaultClientImage = "netbird-client:e2e" + clientAlias = "client" + curlImage = "curlimages/curl:latest" +) + +// Client is a running NetBird client container joined to the combined server. +type Client struct { + container testcontainers.Container +} + +// clientOptions is what the ClientOption values assemble. +type clientOptions struct { + name string +} + +// ClientOption adjusts how StartClient runs the agent. +type ClientOption func(*clientOptions) + +// WithClientName names the agent, which sets both its network alias and its +// container hostname. The hostname matters beyond addressing: the agent reports +// it to management at registration, so it is the name the peer appears under in +// the API. +// +// Required to run more than one agent against the same server — the default name +// is shared, and two containers cannot hold the same alias on one network. +func WithClientName(name string) ClientOption { + return func(o *clientOptions) { o.name = name } +} + +// StartClient builds the client image and runs it on the combined server's +// network, joining via the given setup key. The image entrypoint brings the +// daemon up automatically; callers wait for connectivity with WaitConnected / +// WaitProxyPeer. +func StartClient(ctx context.Context, c *Combined, setupKey string, opts ...ClientOption) (*Client, error) { + o := clientOptions{name: clientAlias} + for _, opt := range opts { + opt(&o) + } + + root, err := repoRoot(ctx) + if err != nil { + return nil, err + } + clientImage, err := resolveImage(ctx, root, "NB_E2E_CLIENT_IMAGE", defaultClientImage, clientDockerfile) + if err != nil { + return nil, err + } + + req := testcontainers.ContainerRequest{ + Image: clientImage, + // The agent reports the container's hostname to management, so this is + // the name the peer is addressable by in the API as well as on the + // network. The entrypoint takes no hostname flag of its own. + Hostname: o.name, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {o.name}}, + Env: map[string]string{ + "NB_MANAGEMENT_URL": combinedExposedURL, + "NB_SETUP_KEY": setupKey, + "NB_LOG_LEVEL": "info", + // Match the proxy: the combined relay is WebSocket-only, so the + // client must use WS transport to keep a stable relay link to it. + "NB_RELAY_TRANSPORT": "ws", + }, + HostConfigModifier: func(hc *container.HostConfig) { + hc.CapAdd = append(hc.CapAdd, "NET_ADMIN", "SYS_ADMIN", "SYS_RESOURCE") + }, + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return nil, fmt.Errorf("start client container: %w", err) + } + return &Client{container: ctr}, nil +} + +// Restart bounces the client connection (netbird down/up) so it pulls a fresh +// network map — the documented workaround for a freshly-joined client not yet +// seeing a synthesized agent-network service. +func (cl *Client) Restart(ctx context.Context) error { + if _, _, err := cl.container.Exec(ctx, []string{"netbird", "down"}, tcexec.Multiplexed()); err != nil { + return fmt.Errorf("netbird down: %w", err) + } + time.Sleep(2 * time.Second) + code, reader, err := cl.container.Exec(ctx, []string{"netbird", "up"}, tcexec.Multiplexed()) + if err != nil { + return fmt.Errorf("netbird up: %w", err) + } + if code != 0 { + out, _ := io.ReadAll(reader) + return fmt.Errorf("netbird up exited %d: %s", code, string(out)) + } + return nil +} + +// Status returns `netbird status` output from inside the client. +func (cl *Client) Status(ctx context.Context) (string, error) { + code, reader, err := cl.container.Exec(ctx, []string{"netbird", "status"}, tcexec.Multiplexed()) + if err != nil { + return "", err + } + out, _ := io.ReadAll(reader) + if code != 0 { + return string(out), fmt.Errorf("netbird status exited %d", code) + } + return string(out), nil +} + +// WaitConnected polls until the client reports Management: Connected. +func (cl *Client) WaitConnected(ctx context.Context, timeout time.Duration) error { + return cl.pollStatus(ctx, timeout, "Management: Connected") +} + +// WaitProxyPeer polls until the client sees at least one connected peer — the +// proxy serving the agent-network endpoint. It requires ">=1 connected" rather +// than an exact "1/1" because proxy peers from earlier tests linger in the +// account as disconnected (each proxy container registers a fresh WireGuard key +// and the peer is not removed on teardown), so the count is e.g. "1/2". Only the +// live proxy can be connected, and the caller's subsequent chat is the real +// end-to-end assertion. +func (cl *Client) WaitProxyPeer(ctx context.Context, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + var last string + for time.Now().Before(deadline) { + out, _ := cl.Status(ctx) + last = out + if connectedPeers(out) >= 1 { + return nil + } + time.Sleep(3 * time.Second) + } + return fmt.Errorf("timed out waiting for a connected proxy peer; last status:\n%s", last) +} + +// connectedPeers parses the "Peers count: X/Y Connected" line from `netbird +// status` and returns X (the connected count), or 0 when absent/unparseable. +func connectedPeers(status string) int { + for _, line := range strings.Split(status, "\n") { + line = strings.TrimSpace(line) + rest, ok := strings.CutPrefix(line, "Peers count:") + if !ok { + continue + } + rest = strings.TrimSpace(rest) + slash := strings.IndexByte(rest, '/') + if slash <= 0 { + return 0 + } + n, err := strconv.Atoi(strings.TrimSpace(rest[:slash])) + if err != nil { + return 0 + } + return n + } + return 0 +} + +func (cl *Client) pollStatus(ctx context.Context, timeout time.Duration, want string) error { + deadline := time.Now().Add(timeout) + var last string + for time.Now().Before(deadline) { + out, _ := cl.Status(ctx) + last = out + if strings.Contains(out, want) { + return nil + } + time.Sleep(3 * time.Second) + } + return fmt.Errorf("timed out waiting for %q; last status:\n%s", want, last) +} + +const ( + // curlExitCouldNotResolve is curl's exit code for a DNS resolution failure, distinct from connection-level failures. + curlExitCouldNotResolve = 6 + // curlExitCouldNotConnect is curl's exit code for a connection that never + // established. The probe exists to WAKE the lazy proxy peer, so the first + // attempt legitimately arrives before WireGuard has brought the tunnel up + // and fails here — which is propagation, exactly like an early NXDOMAIN, + // and belongs inside the retry window rather than failing the test outright. + curlExitCouldNotConnect = 7 + // endpointProbeRetryWindow bounds retries of the transient failures above: the synthesized zone and the tunnel both land a beat after management connects. Still failing after this window is a real failure. + endpointProbeRetryWindow = 30 * time.Second + endpointProbeRetryInterval = 2 * time.Second +) + +// ResolveProxyIP GETs https:/// from the client's netns: any HTTP status proves DNS + tunnel and wakes the lazy proxy peer; DNS and connect failures retry, within endpointProbeRetryWindow. Returns the connected IP for --resolve pinning. +func (cl *Client) ResolveProxyIP(ctx context.Context, endpoint string) (string, error) { + args := []string{ + "run", "--rm", + "--network", "container:" + cl.container.GetContainerID(), + curlImage, + "-ksS", "-o", "/dev/null", + "--connect-timeout", "30", "--max-time", "60", + "-w", "%{remote_ip}", + "https://" + endpoint + "/", + } + deadline := time.Now().Add(endpointProbeRetryWindow) + for { + cmd := exec.CommandContext(ctx, "docker", args...) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + ip := strings.TrimSpace(stdout.String()) + if ip == "" { + return "", fmt.Errorf("got an HTTP response from %s but no remote IP", endpoint) + } + return ip, nil + } + + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || !isTransientProbeExit(exitErr.ExitCode()) { + return "", fmt.Errorf("no HTTP response from %s: %w (%s)", endpoint, err, strings.TrimSpace(stderr.String())) + } + probeErr := fmt.Errorf("endpoint %s not reachable yet: %s", endpoint, strings.TrimSpace(stderr.String())) + if time.Until(deadline) < endpointProbeRetryInterval { + return "", probeErr + } + select { + case <-ctx.Done(): + return "", fmt.Errorf("%w (%w)", probeErr, ctx.Err()) + case <-time.After(endpointProbeRetryInterval): + } + } +} + +// isTransientProbeExit reports whether a curl exit code describes a state the +// endpoint is expected to pass THROUGH on its way up, rather than a settled +// failure. Anything else — TLS refusal, a protocol error, a bad argument — +// would still be failing after the retry window, so it fails immediately. +func isTransientProbeExit(code int) bool { + return code == curlExitCouldNotResolve || code == curlExitCouldNotConnect +} + +// Wire shapes for Chat. +const ( + // WireChat is the OpenAI-compatible /v1/chat/completions shape. + WireChat = "chat" + // WireMessages is the Anthropic /v1/messages shape. + WireMessages = "messages" + // WireVertex is the Anthropic-on-Vertex rawPredict shape: the client posts + // the full Vertex model path and the proxy mints the SA OAuth token. + WireVertex = "vertex" + // WireBedrock is the native AWS Bedrock InvokeModel shape: the model id + // travels in the URL path (/model/{id}/invoke), not the body, so the proxy + // routes by path. This is what a Bedrock SDK client sends and the shape the + // model-allowlist guardrail must enforce. + WireBedrock = "bedrock" +) + +// Chat issues a chat-completion POST to the agent-network endpoint over the +// client's tunnel, returning the HTTP status and response body. kind selects +// the wire shape: WireChat (OpenAI) or WireMessages (Anthropic). A non-empty +// sessionID is sent as the universal x-session-id header the proxy records. +func (cl *Client) Chat(ctx context.Context, endpoint, proxyIP, kind, model, prompt, sessionID string) (int, string, error) { + return cl.ChatPrefixed(ctx, endpoint, proxyIP, "", kind, model, prompt, sessionID) +} + +// ChatPrefixed is Chat with a base-URL path prefix prepended to the wire +// path, mirroring agents whose base URL carries a shape-selecting prefix that +// rides through to the upstream — e.g. Claude Code against a Kimi provider +// sets ANTHROPIC_BASE_URL=https:///anthropic so the proxy forwards +// /anthropic/v1/messages to Moonshot's Anthropic surface while the provider's +// upstream URL stays the bare https://api.moonshot.ai. Empty prefix is plain +// Chat. +func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefix, kind, model, prompt, sessionID string) (int, string, error) { + var path, body string + var headers []string + switch kind { + case WireMessages: + path = "/v1/messages" + headers = []string{"anthropic-version: 2023-06-01"} + body = fmt.Sprintf(`{"model":%q,"max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, model, prompt) + default: + path = "/v1/chat/completions" + body = fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":%q}]}`, model, prompt) + } + return cl.post(ctx, endpoint, proxyIP, pathPrefix+path, body, withSessionID(headers, sessionID)) +} + +// ChatStream is Chat with "stream": true in the request body, so the proxy's +// request parser marks the call as streaming and its response parser takes the +// SSE accumulator rather than the buffered-body path. Pair it with a provider +// pointed at VLLM.StreamURL, which answers every request as an event stream. +func (cl *Client) ChatStream(ctx context.Context, endpoint, proxyIP, kind, model, prompt, sessionID string) (int, string, error) { + var path, body string + var headers []string + switch kind { + case WireMessages: + path = "/v1/messages" + headers = []string{"anthropic-version: 2023-06-01"} + body = fmt.Sprintf(`{"model":%q,"max_tokens":2048,"stream":true,"messages":[{"role":"user","content":%q}]}`, model, prompt) + default: + path = "/v1/chat/completions" + // include_usage is what makes a real OpenAI stream emit its final usage + // frame; without it the last chunk carries no tokens at all. + body = fmt.Sprintf(`{"model":%q,"stream":true,"stream_options":{"include_usage":true},"messages":[{"role":"user","content":%q}]}`, model, prompt) + } + return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(headers, sessionID)) +} + +// Vertex issues an Anthropic-on-Vertex rawPredict POST over the tunnel. Unlike +// Chat, the model is carried in the request path (project/region/model), so the +// proxy routes by path and mints the service-account OAuth token; the body uses +// the Vertex anthropic_version rather than a model field. A non-empty sessionID +// is sent as the universal x-session-id header the proxy records. +func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region, model, prompt, sessionID string) (int, string, error) { + path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:rawPredict", project, region, model) + body := fmt.Sprintf(`{"anthropic_version":"vertex-2023-10-16","max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, prompt) + return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID)) +} + +// Bedrock issues a native AWS Bedrock InvokeModel POST over the tunnel. The +// model id is carried in the request path (/model/{id}/invoke), so the proxy +// routes by path; the body uses the bedrock anthropic_version rather than a +// model field. A non-empty sessionID is sent as the universal x-session-id +// header the proxy records. +func (cl *Client) Bedrock(ctx context.Context, endpoint, proxyIP, model, prompt, sessionID string) (int, string, error) { + path := "/model/" + model + "/invoke" + body := fmt.Sprintf(`{"anthropic_version":"bedrock-2023-05-31","max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, prompt) + return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID)) +} + +// withSessionID appends the x-session-id header when sessionID is non-empty. +func withSessionID(headers []string, sessionID string) []string { + if sessionID == "" { + return headers + } + return append(headers, "x-session-id: "+sessionID) +} + +// Get issues a GET to the agent-network endpoint over the client's tunnel. +// Model discovery and the connection-warming probe are read-only endpoints +// that carry no body, so they can't go through the chat helpers. +func (cl *Client) Get(ctx context.Context, endpoint, proxyIP, path string, extraHeaders []string) (int, string, error) { + return cl.do(ctx, http.MethodGet, endpoint, proxyIP, path, "", extraHeaders) +} + +// PostJSON issues an arbitrary JSON POST over the client's tunnel, for wire +// shapes the typed helpers don't cover (token counting, say). +func (cl *Client) PostJSON(ctx context.Context, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) { + return cl.do(ctx, http.MethodPost, endpoint, proxyIP, path, body, extraHeaders) +} + +// post issues a JSON POST. Retained as the shorthand the chat helpers use. +func (cl *Client) post(ctx context.Context, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) { + return cl.do(ctx, http.MethodPost, endpoint, proxyIP, path, body, extraHeaders) +} + +// do runs curl in a throwaway container sharing the client's network +// namespace so the request traverses the WireGuard tunnel, pinning the endpoint +// to the proxy IP. It returns the HTTP status and response body. An empty body +// sends no payload, which is what a GET needs. +func (cl *Client) do(ctx context.Context, method, endpoint, proxyIP, path, body string, extraHeaders []string) (int, string, error) { + url := "https://" + endpoint + path + args := []string{ + "run", "--rm", + "--network", "container:" + cl.container.GetContainerID(), + curlImage, + "-sk", "--connect-timeout", "5", "--max-time", "90", + "--resolve", endpoint + ":443:" + proxyIP, + "-o", "/dev/stderr", "-w", "%{http_code}", + "-X", method, url, + "-H", "Content-Type: application/json", + } + for _, h := range extraHeaders { + args = append(args, "-H", h) + } + if body != "" { + args = append(args, "--data", body) + } + cmd := exec.CommandContext(ctx, "docker", args...) + // -w writes the status code to stdout; -o /dev/stderr writes the body to + // stderr so we can capture both separately. + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return 0, stderr.String(), fmt.Errorf("curl through tunnel: %w", err) + } + + code := 0 + _, _ = fmt.Sscanf(strings.TrimSpace(stdout.String()), "%d", &code) + return code, stderr.String(), nil +} + +// Logs returns the client container logs, for diagnostics on failure. +func (cl *Client) Logs(ctx context.Context) string { + return containerLogs(ctx, cl.container) +} + +// Terminate stops the client container. +func (cl *Client) Terminate(ctx context.Context) error { + if cl.container == nil { + return nil + } + return cl.container.Terminate(ctx) +} + +// containerLogs reads up to 4 MiB of a container's logs for diagnostics — enough for a whole provider-matrix run. +func containerLogs(ctx context.Context, c testcontainers.Container) string { + if c == nil { + return "" + } + r, err := c.Logs(ctx) + if err != nil { + return fmt.Sprintf("", err) + } + defer r.Close() + b, _ := io.ReadAll(io.LimitReader(r, 4<<20)) + return string(b) +} diff --git a/e2e/harness/combined.go b/e2e/harness/combined.go new file mode 100644 index 000000000..e03f9f256 --- /dev/null +++ b/e2e/harness/combined.go @@ -0,0 +1,327 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" + "github.com/testcontainers/testcontainers-go" + tcexec "github.com/testcontainers/testcontainers-go/exec" + "github.com/testcontainers/testcontainers-go/network" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/netbirdio/netbird/shared/management/client/rest" +) + +const ( + combinedDockerfile = "combined/Dockerfile.multistage" + // defaultCombinedImage is the local tag the combined server is built under + // from combinedDockerfile, so the e2e exercises this branch's code. Override + // with NB_E2E_COMBINED_IMAGE: a value containing a "/" is pulled as a + // published image; a bare tag is built under that name instead. + defaultCombinedImage = "netbird-combined:e2e" + combinedHTTPPort = "8080/tcp" + + // combinedAlias is the combined server's network alias AND the deployment + // domain. The working manual setup uses a single NETBIRD_DOMAIN for the + // management exposed address, the proxy domain, and the agent-network + // cluster — so we mirror that: peers reach management/signal/relay at this + // name, the proxy registers this as its cluster, and the agent-network + // endpoint is .. + combinedAlias = "netbird.local" + combinedExposedURL = "http://" + combinedAlias + ":8080" + + // containerIssuer is the embedded IdP issuer, used only for internal JWT + // validation (peers authenticate with setup keys / proxy tokens, not OIDC), + // so the in-container localhost address is fine. + containerIssuer = "http://localhost:8080/oauth2" +) + +// Combined is a running combined NetBird server (management + signal + relay + +// STUN + embedded IdP) plus the connection details tests need. It owns the +// shared docker network that the proxy and client containers join. +type Combined struct { + container testcontainers.Container + network *testcontainers.DockerNetwork + // BaseURL is the host-reachable management API root, e.g. http://127.0.0.1:51234. + BaseURL string + // PAT is the admin Personal Access Token minted via Bootstrap. + PAT string + + api *rest.Client + workDir string +} + +// combinedOptions is what the CombinedOption values assemble. +type combinedOptions struct { + geolocation bool + env map[string]string +} + +// CombinedOption adjusts how StartCombined boots the server. The defaults suit a +// suite that only drives the API; the options exist for the ones that need more +// of the product than that. +type CombinedOption func(*combinedOptions) + +// WithGeolocation leaves the GeoLite database download enabled. It is off by +// default because the download adds startup latency that most suites get nothing +// for. A suite asserting on location-based posture checks needs it: management +// evaluates those rules against the database, and without it the rule fails +// instead of passing without having been checked. +func WithGeolocation() CombinedOption { + return func(o *combinedOptions) { o.geolocation = true } +} + +// WithServerEnv adds environment variables to the combined container, overriding +// the defaults on a key collision. For settings this harness does not model +// directly, so a suite needing one does not have to fork the harness to get it. +func WithServerEnv(env map[string]string) CombinedOption { + return func(o *combinedOptions) { + if o.env == nil { + o.env = map[string]string{} + } + for k, v := range env { + o.env[k] = v + } + } +} + +// combinedEnv is the combined container's environment: setup-PAT enabled so the +// caller can mint an admin token through /api/setup, geolocation off unless the +// suite asked for it, and whatever the suite added on top. +func combinedEnv(o combinedOptions) map[string]string { + env := map[string]string{ + "NB_SETUP_PAT_ENABLED": "true", + } + if !o.geolocation { + // Skip the GeoLite DB download — it blocks startup and agent-network + // ingest doesn't use geolocation. + env["NB_DISABLE_GEOLOCATION"] = "true" + } + for k, v := range o.env { + env[k] = v + } + return env +} + +// StartCombined builds the combined server from its multistage Dockerfile and +// boots it with setup-PAT enabled on a fresh shared network, returning once the +// API is serving. The caller still owns minting the admin PAT via Bootstrap. +func StartCombined(ctx context.Context, opts ...CombinedOption) (*Combined, error) { + var o combinedOptions + for _, opt := range opts { + opt(&o) + } + + root, err := repoRoot(ctx) + if err != nil { + return nil, err + } + + combinedImage, err := resolveImage(ctx, root, "NB_E2E_COMBINED_IMAGE", defaultCombinedImage, combinedDockerfile) + if err != nil { + return nil, err + } + + net, err := network.New(ctx) + if err != nil { + return nil, fmt.Errorf("create shared network: %w", err) + } + + // Work dir under /tmp so Docker Desktop file sharing (which excludes + // macOS's /var/folders TMPDIR) can bind-mount it. + workDir, err := os.MkdirTemp("/tmp", "nb-e2e-combined-*") + if err != nil { + _ = net.Remove(ctx) + return nil, fmt.Errorf("create work dir: %w", err) + } + + cfg := fmt.Sprintf(combinedConfigYAML, combinedExposedURL, !o.geolocation, containerIssuer) + if err := os.WriteFile(filepath.Join(workDir, "config.yaml"), []byte(cfg), 0o644); err != nil { //nolint:gosec // non-secret config, bind-mounted and read by the container + _ = net.Remove(ctx) + return nil, fmt.Errorf("write combined config: %w", err) + } + dataDir := filepath.Join(workDir, "data") + if err := os.MkdirAll(dataDir, 0o755); err != nil { + _ = net.Remove(ctx) + return nil, fmt.Errorf("create datadir: %w", err) + } + // The config's agentNetwork.pricingDefaultsFile is a bare filename, so the + // server resolves it against the datadir; write it there. It is an explicitly + // configured path, so a failure to load fails the server's startup — which + // surfaces here as the /api/instance readiness wait timing out. + if err := os.WriteFile(filepath.Join(dataDir, PricingDefaultsFileName), []byte(pricingDefaultsYAML), 0o644); err != nil { //nolint:gosec // non-secret config, bind-mounted and read by the container + _ = net.Remove(ctx) + return nil, fmt.Errorf("write pricing defaults: %w", err) + } + + req := testcontainers.ContainerRequest{ + Image: combinedImage, + ExposedPorts: []string{combinedHTTPPort}, + Networks: []string{net.Name}, + NetworkAliases: map[string][]string{net.Name: {combinedAlias}}, + Env: combinedEnv(o), + Cmd: []string{"--config", "/nb/config.yaml"}, + HostConfigModifier: func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, workDir+":/nb") + }, + WaitingFor: wait.ForHTTP("/api/instance"). + WithPort(combinedHTTPPort). + WithStatusCodeMatcher(func(status int) bool { return status == 200 }). + WithStartupTimeout(120 * time.Second), + } + + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + _ = net.Remove(ctx) + return nil, fmt.Errorf("start combined container: %w", err) + } + + host, err := c.Host(ctx) + if err != nil { + _ = c.Terminate(ctx) + _ = net.Remove(ctx) + return nil, fmt.Errorf("container host: %w", err) + } + mapped, err := c.MappedPort(ctx, nat.Port(combinedHTTPPort)) + if err != nil { + _ = c.Terminate(ctx) + _ = net.Remove(ctx) + return nil, fmt.Errorf("mapped port: %w", err) + } + + return &Combined{ + container: c, + network: net, + BaseURL: fmt.Sprintf("http://%s:%s", host, mapped.Port()), + workDir: workDir, + }, nil +} + +// resolveImage returns the image to run for a component. By default it builds +// the image from the repo Dockerfile under localTag, so the e2e exercises the +// branch's code. The env override changes this: a value containing a "/" is a +// registry reference that testcontainers pulls (e.g. to test a published +// release); a bare tag is built under that name instead. +func resolveImage(ctx context.Context, root, envKey, localTag, dockerfile string) (string, error) { + if v := os.Getenv(envKey); v != "" { + if strings.Contains(v, "/") { + return v, nil + } + localTag = v + } + if err := buildImage(ctx, root, dockerfile, localTag); err != nil { + return "", err + } + return localTag, nil +} + +// buildImage builds an image from a repo Dockerfile via buildx with BuildKit, so +// the Dockerfile cache mounts are honored and unchanged layers are reused. The +// result is loaded into the docker image store so testcontainers runs it by tag. +// When NB_E2E_BUILDX_CACHE names a directory (CI, with a container-driver +// builder from docker/setup-buildx-action), layer cache is read from and written +// to it as a local cache so actions/cache can persist it across runs; the Go +// compile itself still re-runs, as BuildKit mount caches can't be exported. +func buildImage(ctx context.Context, root, dockerfile, tag string) error { + args := []string{"buildx", "build", "-f", dockerfile, "-t", tag, "--load"} + if dir := os.Getenv("NB_E2E_BUILDX_CACHE"); dir != "" { + args = append(args, + "--cache-from", "type=local,src="+dir, + "--cache-to", "type=local,dest="+dir+",mode=max", + ) + } + args = append(args, ".") + + cmd := exec.CommandContext(ctx, "docker", args...) + cmd.Dir = root + cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("build image %s: %w\n%s", tag, err, string(out)) + } + return nil +} + +// CreateProxyTokenCLI mints a proxy access token via the server's `token +// create` CLI inside the container — the same path the manual install uses. +// This yields a GLOBAL (account-less) token, so the proxy serves the whole +// cluster (SynthesizeServicesForCluster); an account-scoped REST token instead +// drives the per-account path. Returns the plaintext token. +func (c *Combined) CreateProxyTokenCLI(ctx context.Context, name string) (string, error) { + code, reader, err := c.container.Exec(ctx, + []string{"/go/bin/netbird-server", "token", "create", "--name", name, "--config", "/nb/config.yaml"}, + tcexec.Multiplexed()) + if err != nil { + return "", fmt.Errorf("exec token create: %w", err) + } + out, _ := io.ReadAll(reader) + if code != 0 { + return "", fmt.Errorf("token create exited %d: %s", code, string(out)) + } + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Token:") { + tok := strings.TrimSpace(strings.TrimPrefix(line, "Token:")) + if tok != "" { + return tok, nil + } + } + } + return "", fmt.Errorf("token not found in CLI output: %s", string(out)) +} + +// SnapshotStoreDB copies the management sqlite store (with WAL/SHM sidecars) out of the bind-mounted +// data dir into dstDir and returns the copy's path; reading a copy avoids locking against live writes. +func (c *Combined) SnapshotStoreDB(dstDir string) (string, error) { + src := filepath.Join(c.workDir, "data", "store.db") + if _, err := os.Stat(src); err != nil { + return "", fmt.Errorf("management store not found at %s: %w", src, err) + } + dst := filepath.Join(dstDir, "store.db") + for _, suffix := range []string{"", "-wal", "-shm"} { + data, err := os.ReadFile(src + suffix) + if err != nil { + if os.IsNotExist(err) && suffix != "" { + continue // sidecar only exists in WAL mode + } + return "", fmt.Errorf("read %s: %w", src+suffix, err) + } + if err := os.WriteFile(dst+suffix, data, 0o600); err != nil { + return "", fmt.Errorf("write %s: %w", dst+suffix, err) + } + } + return dst, nil +} + +// Logs returns the combined server container logs, for diagnostics. +func (c *Combined) Logs(ctx context.Context) string { + return containerLogs(ctx, c.container) +} + +// Terminate stops the container, removes the shared network, and cleans the +// work dir. +func (c *Combined) Terminate(ctx context.Context) error { + var err error + if c.container != nil { + err = c.container.Terminate(ctx) + } + if c.network != nil { + _ = c.network.Remove(ctx) + } + if c.workDir != "" { + _ = os.RemoveAll(c.workDir) + } + return err +} diff --git a/e2e/harness/config.go b/e2e/harness/config.go new file mode 100644 index 000000000..f0952b18c --- /dev/null +++ b/e2e/harness/config.go @@ -0,0 +1,70 @@ +//go:build e2e + +package harness + +// combinedConfigYAML is a minimal combined-server config for tests: plain HTTP +// on :8080 (no TLS cert configured → the server serves HTTP and expects to sit +// behind a reverse proxy, which is exactly what we want for in-cluster tests), +// embedded IdP, local signal/relay/STUN, and a sqlite store under the mounted +// data dir. exposedAddress is the address peers use to reach this container; it +// is overridden per-run so the value matches the container's network alias. +// +// pricingDefaultsFile is deliberately a BARE FILENAME, not an absolute path: it +// must resolve against dataDir (→ /nb/data/), which is the resolution rule +// the combined server applies. It is also an EXPLICITLY configured path, so the +// server is required to load it — a broken path or malformed file fails startup +// rather than silently falling back to the compiled-in rates, and TestMain then +// fails with the container logs. +// +// disableGeoliteUpdate is a parameter rather than a fixed true because a suite +// that exercises geolocation needs the database: management can only evaluate a +// location rule with GeoLite loaded, and a rule it cannot evaluate fails rather +// than passing vacuously. See WithGeolocation. +const combinedConfigYAML = `server: + listenAddress: ":8080" + exposedAddress: "%s" + healthcheckAddress: ":9000" + metricsPort: 9090 + logLevel: "info" + logFile: "console" + authSecret: "e2e-relay-secret" + dataDir: "/nb/data" + disableAnonymousMetrics: true + disableGeoliteUpdate: %t + auth: + issuer: "%s" + store: + engine: "sqlite" + agentNetwork: + pricingDefaultsFile: "` + PricingDefaultsFileName + `" +` + +const ( + // PricingDefaultsFileName is the basename of the operator-supplied LLM + // pricing defaults file the combined server is configured to load. Written + // into the bind-mounted datadir by StartCombined. + PricingDefaultsFileName = "e2e_llm_pricing.yaml" + + // PricedDefaultModel is a real catalog model (openai surface) whose rates the + // defaults file below REPLACES. Tests drive it against the mock vLLM upstream + // and assert the file's rates were billed, which is only true if the file + // travelled: config → LoadFile → DefaultTable → synthesizer → the proxy's + // cost_meter defaults table. + PricedDefaultModel = "gpt-4.1-mini" + // PricedDefaultInputPer1k / PricedDefaultOutputPer1k are deliberately odd + // values that no compiled-in catalog entry carries (gpt-4.1-mini ships as + // 0.0004 / 0.0016), so a test asserting them cannot pass on the built-in + // table. + PricedDefaultInputPer1k = 0.0123 + PricedDefaultOutputPer1k = 0.0456 +) + +// pricingDefaultsYAML is the operator-supplied pricing defaults file. Its schema +// is surface -> model -> per-1k rates. Entries replace the compiled-in entry for +// the same surface+model whole; every other model keeps its built-in rates, so +// this file overriding one model must not disturb the rest of the table. +const pricingDefaultsYAML = `openai: + gpt-4.1-mini: + input_per_1k: 0.0123 + output_per_1k: 0.0456 +` diff --git a/e2e/harness/doc.go b/e2e/harness/doc.go new file mode 100644 index 000000000..937d8e664 --- /dev/null +++ b/e2e/harness/doc.go @@ -0,0 +1,13 @@ +//go:build e2e + +// Package harness provides a self-contained, OIDC-free way to stand up NetBird +// components in containers for end-to-end tests. It is feature-agnostic: any +// suite can ask for a live management server (with an admin PAT minted through +// the unauthenticated /api/setup bootstrap) and, later, a proxy and client. +// +// The harness compiles each component once in a cached builder container and +// mounts the resulting binary into a slim runtime container, so iterating on a +// branch doesn't pay a full image rebuild per run. Everything is gated behind +// the `e2e` build tag so normal builds and unit tests never pull in +// testcontainers. +package harness diff --git a/e2e/harness/options_test.go b/e2e/harness/options_test.go new file mode 100644 index 000000000..8a5557a83 --- /dev/null +++ b/e2e/harness/options_test.go @@ -0,0 +1,161 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The options exist so a suite can ask for a deployment this harness would not +// otherwise give it. What they configure is a container environment and a config +// file, both assembled before anything is started, so they are checkable without +// Docker — which is the point: a wiring mistake here would otherwise only show up +// as a puzzling failure minutes into a container run. + +func TestCombinedEnvGeolocation(t *testing.T) { + var off combinedOptions + assert.Equal(t, "true", combinedEnv(off)["NB_DISABLE_GEOLOCATION"], + "geolocation should be off by default") + + var on combinedOptions + WithGeolocation()(&on) + assert.NotContains(t, combinedEnv(on), "NB_DISABLE_GEOLOCATION", + "WithGeolocation must leave NB_DISABLE_GEOLOCATION unset, so the server downloads the database") + assert.Equal(t, "true", combinedEnv(on)["NB_SETUP_PAT_ENABLED"], + "the setup PAT must stay enabled whatever else is configured; Bootstrap depends on it") +} + +// The config file carries the same decision as the environment variable, and the +// server needs both to agree: disableGeoliteUpdate suppresses the download even +// when geolocation itself is enabled. +func TestCombinedConfigGeolocation(t *testing.T) { + for _, tc := range []struct { + name string + opts []CombinedOption + want string + }{ + {name: "default", want: "disableGeoliteUpdate: true"}, + {name: "with geolocation", opts: []CombinedOption{WithGeolocation()}, want: "disableGeoliteUpdate: false"}, + } { + t.Run(tc.name, func(t *testing.T) { + var o combinedOptions + for _, opt := range tc.opts { + opt(&o) + } + cfg := fmt.Sprintf(combinedConfigYAML, combinedExposedURL, !o.geolocation, containerIssuer) + assert.Contains(t, cfg, tc.want, "geolocation not rendered as expected") + // The issuer is the last verb; a mis-ordered argument list would put + // the boolean here instead and the server would fail to start. + assert.Contains(t, cfg, `issuer: "`+containerIssuer+`"`, "issuer not rendered") + }) + } +} + +func TestWithServerEnvOverrides(t *testing.T) { + var o combinedOptions + WithServerEnv(map[string]string{"NB_LOG_LEVEL": "debug"})(&o) + WithServerEnv(map[string]string{"NB_SETUP_PAT_ENABLED": "false"})(&o) + + env := combinedEnv(o) + assert.Equal(t, "debug", env["NB_LOG_LEVEL"], "added variable missing") + assert.Equal(t, "false", env["NB_SETUP_PAT_ENABLED"], "a suite must be able to override a default") +} + +// Two agents on one network cannot share an alias, so the name has to reach both +// the alias and the hostname. The hostname is the one management records, so it is +// also what the peer is addressable by through the API. +func TestWithClientName(t *testing.T) { + o := clientOptions{name: clientAlias} + require.Equal(t, "client", o.name, "unexpected default client name") + + WithClientName("peer2")(&o) + assert.Equal(t, "peer2", o.name, "WithClientName did not take") +} + +// repoRoot has to recognise this module rather than merely finding a go.mod, or a +// suite in another module gets its own root and a build context without the +// component Dockerfiles in it. +func TestIsModule(t *testing.T) { + dir := t.TempDir() + + other := filepath.Join(dir, "go.mod") + require.NoError(t, os.WriteFile(other, []byte("module example.com/other\n\ngo 1.25\n"), 0o600)) + assert.False(t, isModule(other, modulePath), "another module's go.mod must not be taken for this repo") + + ours := filepath.Join(dir, "ours.mod") + require.NoError(t, os.WriteFile(ours, []byte("// a comment\n\nmodule "+modulePath+"\n\ngo 1.25\n"), 0o600)) + assert.True(t, isModule(ours, modulePath), "this repo's go.mod was not recognised") + + assert.False(t, isModule(filepath.Join(dir, "absent.mod"), modulePath), + "a missing go.mod must not report a match") +} + +// Running from inside the repo, repoRoot finds it by walking up — the module +// lookup is only the fallback, and this asserts the walk still wins so an in-repo +// run never depends on the module cache. +func TestRepoRootFindsThisRepo(t *testing.T) { + root, err := repoRoot(context.Background()) + require.NoError(t, err) + assert.True(t, isModule(filepath.Join(root, "go.mod"), modulePath), + "repoRoot returned %s, which is not this module", root) + + for _, f := range []string{combinedDockerfile, clientDockerfile} { + _, err := os.Stat(filepath.Join(root, f)) + assert.NoError(t, err, "%s is not present under the reported root %s", f, root) + } +} + +// A caller that vendors its dependencies puts the go command in automatic vendor +// mode, where `go list -m -f {{.Dir}}` succeeds and reports an EMPTY directory: +// vendor/ holds packages, not module source. Without -mod=readonly the lookup +// would come back empty and the harness would report a missing module for a +// dependency that is present. +func TestModuleDirResolvesUnderVendorMode(t *testing.T) { + if _, err := exec.LookPath("go"); err != nil { + t.Skip("no go tool on PATH") + } + ctx := context.Background() + + base := t.TempDir() + dep := filepath.Join(base, "dep") + main := filepath.Join(base, "main") + require.NoError(t, os.MkdirAll(dep, 0o750)) + require.NoError(t, os.MkdirAll(main, 0o750)) + + // A local replacement rather than a real dependency, so this needs no network. + require.NoError(t, os.WriteFile(filepath.Join(dep, "go.mod"), + []byte("module example.com/dep\n\ngo 1.25\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dep, "dep.go"), + []byte("package dep\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(main, "go.mod"), + []byte("module example.com/main\n\ngo 1.25\n\nrequire example.com/dep v0.0.0\n\nreplace example.com/dep v0.0.0 => ../dep\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(main, "main.go"), + []byte("package main\n\nimport _ \"example.com/dep\"\n\nfunc main() {}\n"), 0o600)) + + t.Chdir(main) + vendor := exec.CommandContext(ctx, "go", "mod", "vendor") + out, err := vendor.CombinedOutput() + require.NoError(t, err, "go mod vendor: %s", out) + + dir, err := moduleDir(ctx, "example.com/dep") + require.NoError(t, err, "the module must still resolve with a vendor directory present") + assert.Equal(t, dep, dir, "resolved the wrong directory") +} + +// A cancelled context has to stop the lookup rather than leaving the caller +// waiting on a subprocess it has already given up on. +func TestModuleDirHonoursContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := moduleDir(ctx, modulePath) + assert.ErrorIs(t, err, context.Canceled, "a cancelled context must stop the lookup") +} diff --git a/e2e/harness/paths.go b/e2e/harness/paths.go new file mode 100644 index 000000000..569c32efc --- /dev/null +++ b/e2e/harness/paths.go @@ -0,0 +1,84 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// modulePath is this module, used both to recognise the repo when walking up +// from the working directory and to locate it when the suite lives elsewhere. +const modulePath = "github.com/netbirdio/netbird" + +// repoRoot returns the directory the component Dockerfiles are built from. +// +// Walking up from the working directory finds it for any test inside this repo, +// no matter which package it runs from. A suite in another module gets a +// different answer that way — its own module root, where combined/Dockerfile +// does not exist — so the ancestor has to be this module and not merely some +// module. When it is not, the build context is the extracted module directory of +// whichever version that suite depends on, which is the right one: the server it +// tests against is then built from the same revision as the client library it +// was compiled with. +func repoRoot(ctx context.Context) (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if isModule(filepath.Join(dir, "go.mod"), modulePath) { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return moduleDir(ctx, modulePath) +} + +// isModule reports whether the go.mod at path declares the given module. +func isModule(path, want string) bool { + b, err := os.ReadFile(path) + if err != nil { + return false + } + for _, line := range strings.Split(string(b), "\n") { + if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "module "); ok { + return strings.TrimSpace(rest) == want + } + } + return false +} + +// moduleDir asks the go tool where a module's source is, which for a dependent +// module is its extracted copy in the module cache. The cache is read-only, and +// a Docker build context is only ever read. +// +// -mod=readonly is required rather than cosmetic. A caller that vendors its +// dependencies puts the go command in automatic vendor mode, where this lookup +// succeeds with an EMPTY directory — vendor/ holds packages, not module source, +// so there is nothing to report. Asking in readonly mode resolves against the +// module graph instead, which answers for both a cached module and a local +// replacement, and neither writes to go.mod. +func moduleDir(ctx context.Context, module string) (string, error) { + cmd := exec.CommandContext(ctx, "go", "list", "-mod=readonly", "-m", "-f", "{{.Dir}}", module) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("locate %s: %w", module, err) + } + dir := strings.TrimSpace(string(out)) + if dir == "" { + return "", fmt.Errorf("locate %s: the go tool reported no directory; run `go mod download %s`", module, module) + } + if _, err := os.Stat(dir); err != nil { + return "", fmt.Errorf("locate %s: %w", module, err) + } + return dir, nil +} diff --git a/e2e/harness/proxy.go b/e2e/harness/proxy.go new file mode 100644 index 000000000..3d709b439 --- /dev/null +++ b/e2e/harness/proxy.go @@ -0,0 +1,132 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + proxyDockerfile = "proxy/Dockerfile.multistage" + // defaultProxyImage is the local tag the reverse proxy is built under from + // proxyDockerfile. Override with NB_E2E_PROXY_IMAGE: a value with a "/" is + // pulled as a published image; a bare tag is built under that name. + defaultProxyImage = "netbird-reverse-proxy:e2e" + proxyAlias = "proxy" + + // AgentNetworkCluster is the proxy cluster the e2e provider bootstraps and + // the proxy serves. It must equal the management's exposed domain + // (combinedAlias) — the working manual setup uses one NETBIRD_DOMAIN for + // both. The agent-network endpoint is .. + AgentNetworkCluster = combinedAlias +) + +// Proxy is a running agent-network gateway (netbird proxy) container. +type Proxy struct { + container testcontainers.Container + workDir string +} + +// StartProxy builds the proxy image and runs it on the combined server's +// network, registered via the given account proxy token and serving the +// AgentNetworkCluster over a self-signed wildcard cert. It does not wait for +// peer connectivity — callers poll management for the proxy peer. +// StartProxy launches the reverse-proxy container. Optional envOverrides are +// merged into the container environment after the defaults, so callers can set +// or override any NB_PROXY_* var (e.g. NB_PROXY_TUNNEL_CACHE_TTL for tests that +// need a short authorization-cache window). +func StartProxy(ctx context.Context, c *Combined, proxyToken string, envOverrides ...map[string]string) (*Proxy, error) { + root, err := repoRoot(ctx) + if err != nil { + return nil, err + } + proxyImage, err := resolveImage(ctx, root, "NB_E2E_PROXY_IMAGE", defaultProxyImage, proxyDockerfile) + if err != nil { + return nil, err + } + + workDir, err := os.MkdirTemp("/tmp", "nb-e2e-proxy-*") + if err != nil { + return nil, fmt.Errorf("create proxy work dir: %w", err) + } + // MkdirTemp creates the dir 0700; widen it so the non-root proxy container + // can traverse the bind-mounted cert dir on Linux CI runners. + if err := os.Chmod(workDir, 0o755); err != nil { //nolint:gosec // throwaway e2e cert dir, must be traversable by the proxy container uid + return nil, fmt.Errorf("chmod proxy cert dir: %w", err) + } + if err := writeSelfSignedCert(workDir, []string{"*." + AgentNetworkCluster, AgentNetworkCluster}); err != nil { + return nil, err + } + + req := testcontainers.ContainerRequest{ + Image: proxyImage, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {proxyAlias}}, + Env: map[string]string{ + "NB_PROXY_TOKEN": proxyToken, + "NB_PROXY_MANAGEMENT_ADDRESS": combinedExposedURL, + "NB_PROXY_DOMAIN": AgentNetworkCluster, + "NB_PROXY_ADDRESS": ":443", + "NB_PROXY_CERTIFICATE_DIRECTORY": "/certs", + "NB_PROXY_HEALTH_ADDRESS": ":8081", + "NB_PROXY_LOG_LEVEL": "debug", + "NB_PROXY_PRIVATE": "true", + // Management is plain HTTP in-cluster, so allow the proxy token to + // ride a non-TLS gRPC connection. + "NB_PROXY_ALLOW_INSECURE": "true", + // The combined server multiplexes the relay over WebSocket on :8080 + // (no QUIC listener). The proxy's embedded relay client defaults to + // QUIC, which fails here and flaps the relay link, churning the + // proxy peer so it never stably registers. Force WS transport. + "NB_RELAY_TRANSPORT": "ws", + // Trace the embedded client (relay / signal / handshake) so + // peer-registration issues are visible in the proxy logs. + "NB_PROXY_CLIENT_LOG_LEVEL": "trace", + }, + HostConfigModifier: func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, workDir+":/certs") + hc.CapAdd = append(hc.CapAdd, "NET_ADMIN", "SYS_ADMIN", "SYS_RESOURCE", "NET_BIND_SERVICE") + }, + WaitingFor: wait.ForLog("Initial mapping sync complete").WithStartupTimeout(90 * time.Second), + } + + for _, ov := range envOverrides { + for k, v := range ov { + req.Env[k] = v + } + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return nil, fmt.Errorf("start proxy container: %w", err) + } + + return &Proxy{container: ctr, workDir: workDir}, nil +} + +// Logs returns the proxy container logs, for diagnostics on failure. +func (p *Proxy) Logs(ctx context.Context) string { + return containerLogs(ctx, p.container) +} + +// Terminate stops the proxy container and cleans its work dir. +func (p *Proxy) Terminate(ctx context.Context) error { + var err error + if p.container != nil { + err = p.container.Terminate(ctx) + } + if p.workDir != "" { + _ = os.RemoveAll(p.workDir) + } + return err +} diff --git a/e2e/harness/upstream.go b/e2e/harness/upstream.go new file mode 100644 index 000000000..cdffe63b9 --- /dev/null +++ b/e2e/harness/upstream.go @@ -0,0 +1,107 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + fakeUpstreamImage = "nginx:alpine" + fakeUpstreamAlias = "fakeupstream" + fakeUpstreamPort = "443/tcp" +) + +// fakeUpstreamNginxConf serves a canned OpenAI-shaped chat completion for any +// path over a self-signed certificate, so the proxy reaches it only when the +// provider opts into skipping TLS verification. +const fakeUpstreamNginxConf = `pid /tmp/nginx.pid; +events {} +http { + server { + listen 443 ssl; + ssl_certificate /certs/tls.crt; + ssl_certificate_key /certs/tls.key; + location / { + default_type application/json; + return 200 '{"id":"chatcmpl-e2e","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}'; + } + } +} +` + +// FakeUpstream is a self-signed HTTPS server on the combined server's network, +// used to exercise provider skip_tls_verification: a proxy that verifies the +// certificate rejects it, one that skips verification reaches it. +type FakeUpstream struct { + container testcontainers.Container + workDir string + // URL is the upstream URL providers point at (https://). + URL string +} + +// StartFakeUpstream runs the self-signed upstream on the shared network. +func StartFakeUpstream(ctx context.Context, c *Combined) (*FakeUpstream, error) { + workDir, err := os.MkdirTemp("/tmp", "nb-e2e-upstream-*") + if err != nil { + return nil, fmt.Errorf("create upstream work dir: %w", err) + } + // Widen so the (non-root worker) nginx container can traverse the bind mount. + if err := os.Chmod(workDir, 0o755); err != nil { //nolint:gosec // throwaway e2e cert dir + return nil, fmt.Errorf("chmod upstream dir: %w", err) + } + if err := writeSelfSignedCert(workDir, []string{fakeUpstreamAlias}); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(workDir, "nginx.conf"), []byte(fakeUpstreamNginxConf), 0o644); err != nil { //nolint:gosec // non-secret e2e config + return nil, fmt.Errorf("write nginx conf: %w", err) + } + + req := testcontainers.ContainerRequest{ + Image: fakeUpstreamImage, + ExposedPorts: []string{fakeUpstreamPort}, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {fakeUpstreamAlias}}, + Cmd: []string{"nginx", "-c", "/certs/nginx.conf", "-g", "daemon off;"}, + HostConfigModifier: func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, workDir+":/certs:ro") + }, + WaitingFor: wait.ForListeningPort(fakeUpstreamPort).WithStartupTimeout(60 * time.Second), + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + _ = os.RemoveAll(workDir) + return nil, fmt.Errorf("start fake upstream container: %w", err) + } + + return &FakeUpstream{container: ctr, workDir: workDir, URL: "https://" + fakeUpstreamAlias}, nil +} + +// Logs returns the upstream container logs, for diagnostics on failure. +func (u *FakeUpstream) Logs(ctx context.Context) string { + return containerLogs(ctx, u.container) +} + +// Terminate stops the upstream container and cleans its work dir. +func (u *FakeUpstream) Terminate(ctx context.Context) error { + var err error + if u.container != nil { + err = u.container.Terminate(ctx) + } + if u.workDir != "" { + _ = os.RemoveAll(u.workDir) + } + return err +} diff --git a/e2e/harness/vllm.go b/e2e/harness/vllm.go new file mode 100644 index 000000000..cf9316325 --- /dev/null +++ b/e2e/harness/vllm.go @@ -0,0 +1,232 @@ +//go:build e2e + +package harness + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + vllmImage = "nginx:alpine" + vllmAlias = "vllm" + vllmPort = "8000/tcp" + // vllmStreamPort serves the same wire shapes as an SSE stream. See the + // nginx config for why streaming lives on its own listener. + vllmStreamPort = "8001/tcp" + // VLLMModel is the served model id the mock advertises and echoes back. It + // matches a real small model commonly served by vLLM so the provider's + // enumerated model and the client's request line up. + VLLMModel = "Qwen/Qwen2.5-0.5B-Instruct" + // VLLMUnlistedModel is a second id the mock's model listing advertises but + // no test provider enumerates, so a filtered listing is observably shorter + // than the upstream's own. + VLLMUnlistedModel = "Qwen/Qwen2.5-7B-Instruct" +) + +// Token counts the mock reports per wire shape. Tests assert on these rather +// than on "> 0" so a response parsed with the wrong provider's parser (which +// would read a different field, or none) fails loudly instead of passing on +// a coincidental non-zero. +const ( + // VLLMChatInputTokens / VLLMChatOutputTokens ride the OpenAI usage block. + VLLMChatInputTokens = 11 + VLLMChatOutputTokens = 2 + // VLLMMessagesInputTokens / VLLMMessagesOutputTokens ride the Anthropic + // usage block, whose field names the OpenAI parser cannot read. + VLLMMessagesInputTokens = 17 + VLLMMessagesOutputTokens = 3 +) + +// Token counts the streaming surface reports. They differ from the +// non-streaming ones on purpose: a test that asserts these numbers proves the +// SSE accumulator ran, rather than a buffered JSON body having been parsed. +// +// Input and cache-read arrive on message_start; output arrives on +// message_delta and supersedes the seed value message_start carries. Any +// parser that cannot read message_start reports zero input tokens — which is +// exactly the bug these counts exist to catch. +const ( + VLLMStreamInputTokens = 29 + VLLMStreamOutputTokens = 5 + VLLMStreamCacheReadTokens = 7 +) + +// vllmNginxConf emulates a vLLM OpenAI-compatible server over plain HTTP (vLLM's +// default: no TLS, port 8000), and additionally answers the wire shapes the +// other catalog surfaces speak so one mock can stand in for every provider the +// proxy routes to. Running actual vLLM in CI is infeasible (GPU + multi-GB model +// download), so this stands in for the wire contract the proxy depends on. +// +// Each shape answers with its own vendor's usage block, so a response parsed +// under the wrong surface meters zero rather than passing by accident: +// +// - /v1/chat/completions (and any unmatched path): OpenAI chat completion. +// - /v1/messages: Anthropic Messages, snake_case usage plus a cache bucket. +// - /model/{id}/invoke: Bedrock InvokeModel, which carries the Anthropic body. +// - the token-counting endpoints: a count, with no usage block at all. +// +// The model listing advertises two models so a policy that authorises one +// produces an observably shorter list than the upstream's own. +const vllmNginxConf = `pid /tmp/nginx.pid; +events {} +http { + server { + listen 8000; + location = /v1/models { + default_type application/json; + return 200 '{"object":"list","data":[{"id":"Qwen/Qwen2.5-0.5B-Instruct","object":"model","owned_by":"vllm"},{"id":"Qwen/Qwen2.5-7B-Instruct","object":"model","owned_by":"vllm"}]}'; + } + location = /v1/messages { + default_type application/json; + return 200 '{"id":"msg_e2e","type":"message","role":"assistant","model":"claude-sonnet-5","content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":17,"output_tokens":3,"cache_read_input_tokens":5}}'; + } + location = /v1/messages/count_tokens { + default_type application/json; + return 200 '{"input_tokens":7}'; + } + location ~ ^/model/.+/invoke$ { + default_type application/json; + return 200 '{"id":"msg_e2e_bedrock","type":"message","role":"assistant","content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":17,"output_tokens":3,"cache_read_input_tokens":5}}'; + } + location ~ ^/model/.+/count-tokens$ { + default_type application/json; + return 200 '{"inputTokens":9}'; + } + location = /api/hello { + return 200; + } + location = /inference-profiles { + default_type application/json; + return 200 '{"inferenceProfileSummaries":[{"inferenceProfileId":"us.anthropic.claude-sonnet-5","status":"ACTIVE"}]}'; + } + location / { + default_type application/json; + return 200 '{"id":"chatcmpl-e2e-vllm","object":"chat.completion","created":1700000000,"model":"Qwen/Qwen2.5-0.5B-Instruct","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":11,"completion_tokens":2,"total_tokens":13}}'; + } + } + + # The streaming surface, on its own port so the response content type is a + # property of the listener rather than of a per-request branch: nginx sets + # Content-Type from default_type, which cannot be varied inside an "if", and + # a second Content-Type via add_header would leave the proxy reading the + # wrong one. A provider record pointed at this port streams every answer. + # + # Input and cache-read tokens ride message_start, output rides message_delta + # — the split that makes a stream different from a buffered body, and the + # reason a parser that ignores message_start meters input as zero. + server { + listen 8001; + location = /v1/messages { + default_type text/event-stream; + return 200 'event: message_start +data: {"type":"message_start","message":{"id":"msg_e2e_stream","type":"message","role":"assistant","model":"claude-sonnet-5","content":[],"usage":{"input_tokens":29,"output_tokens":1,"cache_read_input_tokens":7}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}} + +event: message_stop +data: {"type":"message_stop"} + +'; + } + location / { + default_type text/event-stream; + return 200 'data: {"choices":[{"delta":{"content":"pong"}}]} + +data: {"choices":[],"usage":{"prompt_tokens":29,"completion_tokens":5,"total_tokens":34}} + +data: [DONE] + +'; + } + } +} +` + +// VLLM is a mock vLLM OpenAI-compatible server on the combined server's network, +// reachable at http://vllm:8000. A "vllm" provider points at it to exercise the +// proxy's support for self-hosted OpenAI-compatible backends. +type VLLM struct { + container testcontainers.Container + workDir string + // URL is the upstream URL the vllm provider points at (http://:8000). + URL string + // StreamURL is the same mock's streaming listener. A provider pointed here + // answers every request as SSE, so the proxy's streaming accumulator runs + // instead of its buffered-body parser. + StreamURL string +} + +// StartVLLM runs the mock vLLM server on the shared network over plain HTTP. +func StartVLLM(ctx context.Context, c *Combined) (*VLLM, error) { + workDir, err := os.MkdirTemp("/tmp", "nb-e2e-vllm-*") + if err != nil { + return nil, fmt.Errorf("create vllm work dir: %w", err) + } + // Widen so the (non-root worker) nginx container can traverse the bind mount. + if err := os.Chmod(workDir, 0o755); err != nil { //nolint:gosec // throwaway e2e config dir + return nil, fmt.Errorf("chmod vllm dir: %w", err) + } + if err := os.WriteFile(filepath.Join(workDir, "nginx.conf"), []byte(vllmNginxConf), 0o644); err != nil { //nolint:gosec // non-secret e2e config + return nil, fmt.Errorf("write nginx conf: %w", err) + } + + req := testcontainers.ContainerRequest{ + Image: vllmImage, + ExposedPorts: []string{vllmPort, vllmStreamPort}, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {vllmAlias}}, + Cmd: []string{"nginx", "-c", "/conf/nginx.conf", "-g", "daemon off;"}, + HostConfigModifier: func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, workDir+":/conf:ro") + }, + WaitingFor: wait.ForAll( + wait.ForListeningPort(vllmPort), + wait.ForListeningPort(vllmStreamPort), + ).WithStartupTimeout(60 * time.Second), + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + _ = os.RemoveAll(workDir) + return nil, fmt.Errorf("start vllm container: %w", err) + } + + return &VLLM{ + container: ctr, + workDir: workDir, + URL: "http://" + vllmAlias + ":8000", + StreamURL: "http://" + vllmAlias + ":8001", + }, nil +} + +// Logs returns the vLLM container logs, for diagnostics on failure. +func (v *VLLM) Logs(ctx context.Context) string { + return containerLogs(ctx, v.container) +} + +// Terminate stops the vLLM container and cleans its work dir. +func (v *VLLM) Terminate(ctx context.Context) error { + var err error + if v.container != nil { + err = v.container.Terminate(ctx) + } + if v.workDir != "" { + _ = os.RemoveAll(v.workDir) + } + return err +} diff --git a/flow/client/client.go b/flow/client/client.go index 180a4b441..fc07db833 100644 --- a/flow/client/client.go +++ b/flow/client/client.go @@ -109,7 +109,7 @@ func (c *GRPCClient) Close() error { func (c *GRPCClient) Send(event *proto.FlowEvent) error { c.mu.Lock() stream := c.stream - c.mu.Unlock() + defer c.mu.Unlock() // stream.Send() is not safe to call concurrently from multiple goroutines if stream == nil { return errors.New("stream not initialized") @@ -146,11 +146,14 @@ func (c *GRPCClient) Receive(ctx context.Context, interval time.Duration, msgHan streamStart := time.Now() - if err := c.receive(stream, msgHandler); err != nil { + // receive always returns a non-nil error once the stream breaks; + // handleRetryableError decides between reconnecting and exiting + // permanently on local context cancellation + err = c.receive(stream, msgHandler) + if !isContextDone(err) { log.Errorf("receive failed: %v", err) - return c.handleRetryableError(err, streamStart, backOff) } - return nil + return c.handleRetryableError(err, streamStart, backOff) } if err := backoff.Retry(operation, backOff); err != nil { diff --git a/flow/proto/flow.pb.go b/flow/proto/flow.pb.go index 04e6e3792..710024f0e 100644 --- a/flow/proto/flow.pb.go +++ b/flow/proto/flow.pb.go @@ -134,9 +134,11 @@ type FlowEvent struct { // When the event occurred Timestamp *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // Public key of the sending peer - PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` - FlowFields *FlowFields `protobuf:"bytes,4,opt,name=flow_fields,json=flowFields,proto3" json:"flow_fields,omitempty"` - IsInitiator bool `protobuf:"varint,5,opt,name=isInitiator,proto3" json:"isInitiator,omitempty"` + PublicKey []byte `protobuf:"bytes,3,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + FlowFields *FlowFields `protobuf:"bytes,4,opt,name=flow_fields,json=flowFields,proto3" json:"flow_fields,omitempty"` + IsInitiator bool `protobuf:"varint,5,opt,name=isInitiator,proto3" json:"isInitiator,omitempty"` + WindowStart *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=window_start,json=windowStart,proto3" json:"window_start,omitempty"` + WindowEnd *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=window_end,json=windowEnd,proto3" json:"window_end,omitempty"` } func (x *FlowEvent) Reset() { @@ -206,6 +208,20 @@ func (x *FlowEvent) GetIsInitiator() bool { return false } +func (x *FlowEvent) GetWindowStart() *timestamppb.Timestamp { + if x != nil { + return x.WindowStart + } + return nil +} + +func (x *FlowEvent) GetWindowEnd() *timestamppb.Timestamp { + if x != nil { + return x.WindowEnd + } + return nil +} + type FlowEventAck struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -284,7 +300,6 @@ type FlowFields struct { // Layer 4 -specific information // // Types that are assignable to ConnectionInfo: - // // *FlowFields_PortInfo // *FlowFields_IcmpInfo ConnectionInfo isFlowFields_ConnectionInfo `protobuf_oneof:"connection_info"` @@ -297,6 +312,9 @@ type FlowFields struct { // Resource ID SourceResourceId []byte `protobuf:"bytes,14,opt,name=source_resource_id,json=sourceResourceId,proto3" json:"source_resource_id,omitempty"` DestResourceId []byte `protobuf:"bytes,15,opt,name=dest_resource_id,json=destResourceId,proto3" json:"dest_resource_id,omitempty"` + NumOfStarts uint64 `protobuf:"varint,16,opt,name=num_of_starts,json=numOfStarts,proto3" json:"num_of_starts,omitempty"` + NumOfEnds uint64 `protobuf:"varint,17,opt,name=num_of_ends,json=numOfEnds,proto3" json:"num_of_ends,omitempty"` + NumOfDrops uint64 `protobuf:"varint,18,opt,name=num_of_drops,json=numOfDrops,proto3" json:"num_of_drops,omitempty"` } func (x *FlowFields) Reset() { @@ -443,6 +461,27 @@ func (x *FlowFields) GetDestResourceId() []byte { return nil } +func (x *FlowFields) GetNumOfStarts() uint64 { + if x != nil { + return x.NumOfStarts + } + return 0 +} + +func (x *FlowFields) GetNumOfEnds() uint64 { + if x != nil { + return x.NumOfEnds + } + return 0 +} + +func (x *FlowFields) GetNumOfDrops() uint64 { + if x != nil { + return x.NumOfDrops + } + return 0 +} + type isFlowFields_ConnectionInfo interface { isFlowFields_ConnectionInfo() } @@ -579,7 +618,7 @@ var file_flow_proto_rawDesc = []byte{ 0x0a, 0x0a, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x22, 0xd4, 0x01, 0x0a, 0x09, 0x46, 0x6c, 0x6f, 0x77, 0x45, 0x76, 0x65, 0x6e, + 0x6f, 0x74, 0x6f, 0x22, 0xce, 0x02, 0x0a, 0x09, 0x46, 0x6c, 0x6f, 0x77, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, @@ -592,45 +631,59 @@ var file_flow_proto_rawDesc = []byte{ 0x77, 0x2e, 0x46, 0x6c, 0x6f, 0x77, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x52, 0x0a, 0x66, 0x6c, 0x6f, 0x77, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x69, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, - 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x22, 0x4b, 0x0a, 0x0c, 0x46, 0x6c, - 0x6f, 0x77, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x41, 0x63, 0x6b, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x76, - 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x65, 0x76, - 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x20, 0x0a, 0x0b, 0x69, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, - 0x61, 0x74, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x49, 0x6e, - 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x22, 0x9c, 0x04, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, - 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x17, 0x0a, 0x07, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x66, 0x6c, 0x6f, 0x77, 0x49, 0x64, 0x12, - 0x1e, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0a, 0x2e, - 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, - 0x17, 0x0a, 0x07, 0x72, 0x75, 0x6c, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x06, 0x72, 0x75, 0x6c, 0x65, 0x49, 0x64, 0x12, 0x2d, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, - 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0f, 0x2e, 0x66, 0x6c, - 0x6f, 0x77, 0x2e, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x64, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x63, 0x6f, 0x6c, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, - 0x12, 0x17, 0x0a, 0x07, 0x64, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x70, 0x18, 0x07, 0x20, 0x01, 0x28, - 0x0c, 0x52, 0x06, 0x64, 0x65, 0x73, 0x74, 0x49, 0x70, 0x12, 0x2d, 0x0a, 0x09, 0x70, 0x6f, 0x72, - 0x74, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x66, - 0x6c, 0x6f, 0x77, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x08, - 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x2d, 0x0a, 0x09, 0x69, 0x63, 0x6d, 0x70, - 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x66, 0x6c, - 0x6f, 0x77, 0x2e, 0x49, 0x43, 0x4d, 0x50, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x08, 0x69, - 0x63, 0x6d, 0x70, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x78, 0x5f, 0x70, 0x61, - 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x72, 0x78, 0x50, - 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x78, 0x5f, 0x70, 0x61, 0x63, - 0x6b, 0x65, 0x74, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x74, 0x78, 0x50, 0x61, - 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x72, 0x78, 0x5f, 0x62, 0x79, 0x74, 0x65, - 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x72, 0x78, 0x42, 0x79, 0x74, 0x65, 0x73, - 0x12, 0x19, 0x0a, 0x08, 0x74, 0x78, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x0d, 0x20, 0x01, - 0x28, 0x04, 0x52, 0x07, 0x74, 0x78, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, - 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x10, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x64, 0x65, 0x73, - 0x74, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x0f, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x64, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x49, 0x64, 0x42, 0x11, 0x0a, 0x0f, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x3d, 0x0a, 0x0c, 0x77, 0x69, + 0x6e, 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0b, 0x77, 0x69, + 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x77, 0x69, 0x6e, + 0x64, 0x6f, 0x77, 0x5f, 0x65, 0x6e, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, + 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x77, 0x69, 0x6e, 0x64, 0x6f, + 0x77, 0x45, 0x6e, 0x64, 0x22, 0x4b, 0x0a, 0x0c, 0x46, 0x6c, 0x6f, 0x77, 0x45, 0x76, 0x65, 0x6e, + 0x74, 0x41, 0x63, 0x6b, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, + 0x20, 0x0a, 0x0b, 0x69, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x74, 0x6f, + 0x72, 0x22, 0x82, 0x05, 0x0a, 0x0a, 0x46, 0x6c, 0x6f, 0x77, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, + 0x12, 0x17, 0x0a, 0x07, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x06, 0x66, 0x6c, 0x6f, 0x77, 0x49, 0x64, 0x12, 0x1e, 0x0a, 0x04, 0x74, 0x79, 0x70, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0a, 0x2e, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x54, + 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x75, 0x6c, + 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x72, 0x75, 0x6c, 0x65, + 0x49, 0x64, 0x12, 0x2d, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0f, 0x2e, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x44, 0x69, 0x72, + 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x0d, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1b, 0x0a, + 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x65, + 0x73, 0x74, 0x5f, 0x69, 0x70, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x64, 0x65, 0x73, + 0x74, 0x49, 0x70, 0x12, 0x2d, 0x0a, 0x09, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x69, 0x6e, 0x66, 0x6f, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x50, 0x6f, + 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, + 0x66, 0x6f, 0x12, 0x2d, 0x0a, 0x09, 0x69, 0x63, 0x6d, 0x70, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18, + 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x66, 0x6c, 0x6f, 0x77, 0x2e, 0x49, 0x43, 0x4d, + 0x50, 0x49, 0x6e, 0x66, 0x6f, 0x48, 0x00, 0x52, 0x08, 0x69, 0x63, 0x6d, 0x70, 0x49, 0x6e, 0x66, + 0x6f, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x78, 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, + 0x0a, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x72, 0x78, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, + 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x78, 0x5f, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x18, 0x0b, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x74, 0x78, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x74, 0x73, 0x12, + 0x19, 0x0a, 0x08, 0x72, 0x78, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, + 0x04, 0x52, 0x07, 0x72, 0x78, 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x74, 0x78, + 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x74, 0x78, + 0x42, 0x79, 0x74, 0x65, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, + 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x10, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x49, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x64, 0x65, 0x73, 0x74, 0x5f, 0x72, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x64, + 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x12, 0x22, 0x0a, + 0x0d, 0x6e, 0x75, 0x6d, 0x5f, 0x6f, 0x66, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x73, 0x18, 0x10, + 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x6e, 0x75, 0x6d, 0x4f, 0x66, 0x53, 0x74, 0x61, 0x72, 0x74, + 0x73, 0x12, 0x1e, 0x0a, 0x0b, 0x6e, 0x75, 0x6d, 0x5f, 0x6f, 0x66, 0x5f, 0x65, 0x6e, 0x64, 0x73, + 0x18, 0x11, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x6e, 0x75, 0x6d, 0x4f, 0x66, 0x45, 0x6e, 0x64, + 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x6e, 0x75, 0x6d, 0x5f, 0x6f, 0x66, 0x5f, 0x64, 0x72, 0x6f, 0x70, + 0x73, 0x18, 0x12, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x6e, 0x75, 0x6d, 0x4f, 0x66, 0x44, 0x72, + 0x6f, 0x70, 0x73, 0x42, 0x11, 0x0a, 0x0f, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x22, 0x48, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, @@ -683,17 +736,19 @@ var file_flow_proto_goTypes = []interface{}{ var file_flow_proto_depIdxs = []int32{ 7, // 0: flow.FlowEvent.timestamp:type_name -> google.protobuf.Timestamp 4, // 1: flow.FlowEvent.flow_fields:type_name -> flow.FlowFields - 0, // 2: flow.FlowFields.type:type_name -> flow.Type - 1, // 3: flow.FlowFields.direction:type_name -> flow.Direction - 5, // 4: flow.FlowFields.port_info:type_name -> flow.PortInfo - 6, // 5: flow.FlowFields.icmp_info:type_name -> flow.ICMPInfo - 2, // 6: flow.FlowService.Events:input_type -> flow.FlowEvent - 3, // 7: flow.FlowService.Events:output_type -> flow.FlowEventAck - 7, // [7:8] is the sub-list for method output_type - 6, // [6:7] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 7, // 2: flow.FlowEvent.window_start:type_name -> google.protobuf.Timestamp + 7, // 3: flow.FlowEvent.window_end:type_name -> google.protobuf.Timestamp + 0, // 4: flow.FlowFields.type:type_name -> flow.Type + 1, // 5: flow.FlowFields.direction:type_name -> flow.Direction + 5, // 6: flow.FlowFields.port_info:type_name -> flow.PortInfo + 6, // 7: flow.FlowFields.icmp_info:type_name -> flow.ICMPInfo + 2, // 8: flow.FlowService.Events:input_type -> flow.FlowEvent + 3, // 9: flow.FlowService.Events:output_type -> flow.FlowEventAck + 9, // [9:10] is the sub-list for method output_type + 8, // [8:9] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_flow_proto_init() } diff --git a/flow/proto/flow.proto b/flow/proto/flow.proto index ff5c50282..1c9e728d2 100644 --- a/flow/proto/flow.proto +++ b/flow/proto/flow.proto @@ -24,6 +24,9 @@ message FlowEvent { FlowFields flow_fields = 4; bool isInitiator = 5; + + google.protobuf.Timestamp window_start = 6; + google.protobuf.Timestamp window_end = 7; } message FlowEventAck { @@ -75,6 +78,9 @@ message FlowFields { bytes source_resource_id = 14; bytes dest_resource_id = 15; + uint64 num_of_starts = 16; + uint64 num_of_ends = 17; + uint64 num_of_drops = 18; } // Flow event types diff --git a/flow/proto/flow_grpc.pb.go b/flow/proto/flow_grpc.pb.go index b790f86a2..9ae9702a5 100644 --- a/flow/proto/flow_grpc.pb.go +++ b/flow/proto/flow_grpc.pb.go @@ -1,4 +1,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v3.21.9 +// source: flow.proto package proto @@ -11,15 +15,19 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + FlowService_Events_FullMethodName = "/flow.FlowService/Events" +) // FlowServiceClient is the client API for FlowService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type FlowServiceClient interface { // Client to receiver streams of events and acknowledgements - Events(ctx context.Context, opts ...grpc.CallOption) (FlowService_EventsClient, error) + Events(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[FlowEvent, FlowEventAck], error) } type flowServiceClient struct { @@ -30,54 +38,40 @@ func NewFlowServiceClient(cc grpc.ClientConnInterface) FlowServiceClient { return &flowServiceClient{cc} } -func (c *flowServiceClient) Events(ctx context.Context, opts ...grpc.CallOption) (FlowService_EventsClient, error) { - stream, err := c.cc.NewStream(ctx, &FlowService_ServiceDesc.Streams[0], "/flow.FlowService/Events", opts...) +func (c *flowServiceClient) Events(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[FlowEvent, FlowEventAck], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &FlowService_ServiceDesc.Streams[0], FlowService_Events_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &flowServiceEventsClient{stream} + x := &grpc.GenericClientStream[FlowEvent, FlowEventAck]{ClientStream: stream} return x, nil } -type FlowService_EventsClient interface { - Send(*FlowEvent) error - Recv() (*FlowEventAck, error) - grpc.ClientStream -} - -type flowServiceEventsClient struct { - grpc.ClientStream -} - -func (x *flowServiceEventsClient) Send(m *FlowEvent) error { - return x.ClientStream.SendMsg(m) -} - -func (x *flowServiceEventsClient) Recv() (*FlowEventAck, error) { - m := new(FlowEventAck) - if err := x.ClientStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type FlowService_EventsClient = grpc.BidiStreamingClient[FlowEvent, FlowEventAck] // FlowServiceServer is the server API for FlowService service. // All implementations must embed UnimplementedFlowServiceServer -// for forward compatibility +// for forward compatibility. type FlowServiceServer interface { // Client to receiver streams of events and acknowledgements - Events(FlowService_EventsServer) error + Events(grpc.BidiStreamingServer[FlowEvent, FlowEventAck]) error mustEmbedUnimplementedFlowServiceServer() } -// UnimplementedFlowServiceServer must be embedded to have forward compatible implementations. -type UnimplementedFlowServiceServer struct { -} +// UnimplementedFlowServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedFlowServiceServer struct{} -func (UnimplementedFlowServiceServer) Events(FlowService_EventsServer) error { - return status.Errorf(codes.Unimplemented, "method Events not implemented") +func (UnimplementedFlowServiceServer) Events(grpc.BidiStreamingServer[FlowEvent, FlowEventAck]) error { + return status.Error(codes.Unimplemented, "method Events not implemented") } func (UnimplementedFlowServiceServer) mustEmbedUnimplementedFlowServiceServer() {} +func (UnimplementedFlowServiceServer) testEmbeddedByValue() {} // UnsafeFlowServiceServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to FlowServiceServer will @@ -87,34 +81,22 @@ type UnsafeFlowServiceServer interface { } func RegisterFlowServiceServer(s grpc.ServiceRegistrar, srv FlowServiceServer) { + // If the following call panics, it indicates UnimplementedFlowServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&FlowService_ServiceDesc, srv) } func _FlowService_Events_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(FlowServiceServer).Events(&flowServiceEventsServer{stream}) + return srv.(FlowServiceServer).Events(&grpc.GenericServerStream[FlowEvent, FlowEventAck]{ServerStream: stream}) } -type FlowService_EventsServer interface { - Send(*FlowEventAck) error - Recv() (*FlowEvent, error) - grpc.ServerStream -} - -type flowServiceEventsServer struct { - grpc.ServerStream -} - -func (x *flowServiceEventsServer) Send(m *FlowEventAck) error { - return x.ServerStream.SendMsg(m) -} - -func (x *flowServiceEventsServer) Recv() (*FlowEvent, error) { - m := new(FlowEvent) - if err := x.ServerStream.RecvMsg(m); err != nil { - return nil, err - } - return m, nil -} +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type FlowService_EventsServer = grpc.BidiStreamingServer[FlowEvent, FlowEventAck] // FlowService_ServiceDesc is the grpc.ServiceDesc for FlowService service. // It's only intended for direct use with grpc.RegisterService, diff --git a/funding.json b/funding.json index 6b509a992..34ee9fe46 100644 --- a/funding.json +++ b/funding.json @@ -6,7 +6,7 @@ "name": "NetBird GmbH", "email": "hello@netbird.io", "phone": "", - "description": "NetBird GmbH is a Berlin-based software company specializing in the development of open-source network security solutions. Network security is utterly complex and expensive, accessible only to companies with multi-million dollar IT budgets. In contrast, there are millions of companies left behind. Our mission is to create an advanced network and cybersecurity platform that is both easy-to-use and affordable for teams of all sizes and budgets. By leveraging the open-source strategy and technological advancements, NetBird aims to set the industry standard for connecting and securing IT infrastructure.", + "description": "NetBird GmbH is a Berlin-based software company specializing in the development of open source network security solutions. Network security is utterly complex and expensive, accessible only to companies with multi-million dollar IT budgets. In contrast, there are millions of companies left behind. Our mission is to create an advanced network and cybersecurity platform that is both easy-to-use and affordable for teams of all sizes and budgets. By leveraging the open source strategy and technological advancements, NetBird aims to set the industry standard for connecting and securing IT infrastructure.", "webpageUrl": { "url": "https://github.com/netbirdio" } @@ -15,7 +15,7 @@ { "guid": "netbird", "name": "NetBird", - "description": "NetBird is a configuration-free peer-to-peer private network and a centralized access control system combined in a single open-source platform. It makes it easy to create secure WireGuard-based private networks for your organization or home.", + "description": "NetBird is a configuration-free peer-to-peer private network and a centralized access control system combined in a single open source platform. It makes it easy to create secure WireGuard-based private networks for your organization or home.", "webpageUrl": { "url": "https://github.com/netbirdio/netbird" }, @@ -59,7 +59,7 @@ "guid": "support-yearly", "status": "active", "name": "Support Open Source Development and Maintenance - Yearly", - "description": "This will help us partially cover the yearly cost of maintaining the open-source NetBird project.", + "description": "This will help us partially cover the yearly cost of maintaining the open source NetBird project.", "amount": 100000, "currency": "USD", "frequency": "yearly", @@ -72,7 +72,7 @@ "guid": "support-one-time-year", "status": "active", "name": "Support Open Source Development and Maintenance - One Year", - "description": "This will help us partially cover the yearly cost of maintaining the open-source NetBird project.", + "description": "This will help us partially cover the yearly cost of maintaining the open source NetBird project.", "amount": 100000, "currency": "USD", "frequency": "one-time", @@ -85,7 +85,7 @@ "guid": "support-one-time-monthly", "status": "active", "name": "Support Open Source Development and Maintenance - Monthly", - "description": "This will help us partially cover the monthly cost of maintaining the open-source NetBird project.", + "description": "This will help us partially cover the monthly cost of maintaining the open source NetBird project.", "amount": 10000, "currency": "USD", "frequency": "monthly", @@ -98,7 +98,7 @@ "guid": "support-monthly", "status": "active", "name": "Support Open Source Development and Maintenance - One Month", - "description": "This will help us partially cover the monthly cost of maintaining the open-source NetBird project.", + "description": "This will help us partially cover the monthly cost of maintaining the open source NetBird project.", "amount": 10000, "currency": "USD", "frequency": "monthly", diff --git a/go.mod b/go.mod index 2858d2044..265cd962f 100644 --- a/go.mod +++ b/go.mod @@ -2,25 +2,25 @@ module github.com/netbirdio/netbird go 1.25.5 -toolchain go1.25.11 +toolchain go1.25.12 require ( cunicu.li/go-rosenpass v0.5.42 github.com/cenkalti/backoff/v4 v4.3.0 - github.com/cloudflare/circl v1.3.3 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/golang/protobuf v1.5.4 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/kardianos/service v1.2.3-0.20240613133416-becf2eb62b83 github.com/onsi/ginkgo v1.16.5 - github.com/onsi/gomega v1.27.6 + github.com/onsi/gomega v1.34.1 github.com/rs/cors v1.8.0 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 - github.com/spf13/pflag v1.0.9 + github.com/spf13/pflag v1.0.10 github.com/vishvananda/netlink v1.3.1 - golang.org/x/crypto v0.50.0 - golang.org/x/sys v0.43.0 + golang.org/x/crypto v0.55.0 + golang.org/x/sys v0.47.0 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/windows v0.5.3 @@ -29,12 +29,11 @@ require ( ) require ( - fyne.io/fyne/v2 v2.7.0 - fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9 - git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 github.com/DeRuina/timberjack v1.4.2 + github.com/Microsoft/go-winio v0.6.2 github.com/awnumar/memguard v0.23.0 github.com/aws/aws-sdk-go-v2 v1.38.3 + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 github.com/aws/aws-sdk-go-v2/config v1.31.6 github.com/aws/aws-sdk-go-v2/credentials v1.18.10 github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 @@ -49,39 +48,44 @@ require ( github.com/crowdsecurity/go-cs-bouncer v0.0.21 github.com/dexidp/dex v2.13.0+incompatible github.com/dexidp/dex/api/v2 v2.4.0 - github.com/ebitengine/purego v0.8.4 + github.com/docker/docker v28.0.1+incompatible + github.com/docker/go-connections v0.6.0 + github.com/ebitengine/purego v0.9.1 github.com/eko/gocache/lib/v4 v4.2.0 github.com/eko/gocache/store/go_cache/v4 v4.2.2 github.com/eko/gocache/store/redis/v4 v4.2.2 github.com/fsnotify/fsnotify v1.9.0 github.com/gliderlabs/ssh v0.3.8 github.com/go-jose/go-jose/v4 v4.1.4 + github.com/go-ole/go-ole v1.3.0 github.com/gobwas/ws v1.4.0 github.com/goccy/go-yaml v1.18.0 - github.com/godbus/dbus/v5 v5.1.0 + github.com/godbus/dbus/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.3.1 - github.com/golang/mock v1.6.0 github.com/google/go-cmp v0.7.0 github.com/google/gopacket v1.1.19 github.com/google/nftables v0.3.0 github.com/gopacket/gopacket v1.4.0 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 github.com/hashicorp/go-version v1.7.0 github.com/jackc/pgx/v5 v5.5.5 github.com/libdns/route53 v1.5.0 - github.com/libp2p/go-nat v0.2.0 github.com/libp2p/go-netroute v0.4.0 github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 github.com/mdlayher/socket v0.5.1 github.com/mdp/qrterminal/v3 v3.2.1 github.com/miekg/dns v1.1.72 github.com/mitchellh/hashstructure/v2 v2.0.2 + github.com/moby/moby/api v1.54.1 + github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 github.com/oapi-codegen/runtime v1.1.2 github.com/okta/okta-sdk-golang/v2 v2.18.0 + github.com/ory/dockertest/v4 v4.0.0 github.com/oschwald/maxminddb-golang v1.12.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 @@ -95,10 +99,10 @@ require ( github.com/pires/go-proxyproto v0.11.0 github.com/pkg/sftp v1.13.9 github.com/prometheus/client_golang v1.23.2 - github.com/quic-go/quic-go v0.55.0 + github.com/quic-go/quic-go v0.59.1 github.com/redis/go-redis/v9 v9.7.3 github.com/rs/xid v1.3.0 - github.com/shirou/gopsutil/v3 v3.24.4 + github.com/shirou/gopsutil/v4 v4.25.8 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/stretchr/testify v1.11.1 @@ -110,6 +114,7 @@ require ( github.com/ti-mo/conntrack v0.5.1 github.com/ti-mo/netfilter v0.5.2 github.com/vmihailenco/msgpack/v5 v5.4.1 + github.com/wailsapp/wails/v3 v3.0.0-beta.3 github.com/yusufpapurcu/wmi v1.2.4 github.com/zcalusic/sysinfo v1.1.3 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 @@ -120,43 +125,44 @@ require ( go.uber.org/mock v0.6.0 go.uber.org/zap v1.27.0 goauthentik.io/api/v3 v3.2023051.3 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b - golang.org/x/mobile v0.0.0-20251113184115-a159579294ab - golang.org/x/mod v0.34.0 - golang.org/x/net v0.53.0 + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f + golang.org/x/mobile v0.0.0-20260816165457-f98cc9b3c733 + golang.org/x/mod v0.39.0 + golang.org/x/net v0.58.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sync v0.20.0 - golang.org/x/term v0.42.0 + golang.org/x/sync v0.22.0 + golang.org/x/term v0.45.0 golang.org/x/time v0.15.0 google.golang.org/api v0.276.0 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.5.7 gorm.io/driver/postgres v1.5.7 gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 - howett.net/plist v1.0.1 + howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 ) require ( cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - dario.cat/mergo v1.0.1 // indirect + dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.1 // indirect + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/AppsFlyer/go-sundheit v0.6.0 // indirect - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-ntlmssp v0.1.0 // indirect - github.com/BurntSushi/toml v1.5.0 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/adrg/xdg v0.5.3 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/awnumar/memcall v0.4.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect @@ -177,6 +183,8 @@ require ( github.com/caddyserver/zerossl v0.1.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect @@ -184,23 +192,13 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v28.0.1+incompatible // indirect - github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fredbi/uri v1.1.1 // indirect github.com/fxamacker/cbor/v2 v2.9.1 // indirect - github.com/fyne-io/gl-js v0.2.0 // indirect - github.com/fyne-io/glfw-js v0.3.0 // indirect - github.com/fyne-io/image v0.1.1 // indirect - github.com/fyne-io/oksvg v0.2.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect - github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect - github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect github.com/go-ldap/ldap/v3 v3.4.13 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/analysis v0.23.0 // indirect github.com/go-openapi/errors v0.22.2 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect @@ -211,8 +209,6 @@ require ( github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/validate v0.24.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect - github.com/go-text/render v0.2.0 // indirect - github.com/go-text/typesetting v0.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/webauthn v0.16.4 // indirect github.com/go-webauthn/x v0.2.3 // indirect @@ -220,6 +216,7 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/mock v1.6.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-tpm v0.9.8 // indirect @@ -227,8 +224,6 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.21.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect - github.com/hack-pad/go-indexeddb v0.3.2 // indirect - github.com/hack-pad/safejs v0.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect @@ -244,16 +239,14 @@ require ( github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jackpal/go-nat-pmp v1.0.2 // indirect - github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect - github.com/klauspost/compress v1.18.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/koron/go-ssdp v0.0.4 // indirect github.com/kr/fs v0.1.0 // indirect github.com/lib/pq v1.12.3 // indirect @@ -262,6 +255,8 @@ require ( github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.42 // indirect github.com/mdelapenya/tlscert v0.2.0 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect @@ -271,18 +266,16 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.5.0 // indirect github.com/moby/sys/user v0.3.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect - github.com/moby/term v0.5.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect - github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/nxadm/tail v1.4.11 // indirect github.com/oklog/ulid v1.3.1 // indirect - github.com/onsi/ginkgo/v2 v2.9.5 // indirect github.com/openbao/openbao/api/v2 v2.5.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect @@ -302,13 +295,8 @@ require ( github.com/prometheus/procfs v0.19.2 // indirect github.com/russellhaering/goxmldsig v1.6.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect - github.com/rymdport/portal v0.4.2 // indirect - github.com/shirou/gopsutil/v4 v4.25.8 // indirect - github.com/shoenig/go-m1cpu v0.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/spf13/cast v1.7.0 // indirect - github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect - github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tinylib/msgp v1.6.3 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect @@ -317,7 +305,6 @@ require ( github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect - github.com/yuin/goldmark v1.7.8 // indirect github.com/zeebo/blake3 v0.2.3 // indirect go.mongodb.org/mongo-driver v1.17.9 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -326,11 +313,10 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/image v0.33.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/tools v0.49.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect @@ -341,14 +327,18 @@ replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-2024 replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949 -replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f +replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 replace github.com/pion/ice/v4 => github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 -replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1 +replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5 replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0 + +replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db + +tool go.uber.org/mock/mockgen diff --git a/go.sum b/go.sum index 1768ee069..d9d880ede 100644 --- a/go.sum +++ b/go.sum @@ -9,37 +9,35 @@ codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 h1:b8xUw3004wk+3ipB codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6/go.mod h1:+CauBF6R70Jqcyl8N2hC8pAXYbWkGIezuSbuGLtRhnw= cunicu.li/go-rosenpass v0.5.42 h1:fRDsGwCxd7DhDgZI1Pxeo8GtNyq8BESZJ7w2/BGGJtU= cunicu.li/go-rosenpass v0.5.42/go.mod h1:YRBeyKOe/gWpSX2kpDUec5p9t0XOLsshTguId5gTGVg= -dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= -dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -fyne.io/fyne/v2 v2.7.0 h1:GvZSpE3X0liU/fqstInVvRsaboIVpIWQ4/sfjDGIGGQ= -fyne.io/fyne/v2 v2.7.0/go.mod h1:xClVlrhxl7D+LT+BWYmcrW4Nf+dJTvkhnPgji7spAwE= -fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9 h1:829+77I4TaMrcg9B3wf+gHhdSgoCVEgH2czlPXPbfj4= -fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AppsFlyer/go-sundheit v0.6.0 h1:d2hBvCjBSb2lUsEWGfPigr4MCOt04sxB+Rppl0yUMSk= github.com/AppsFlyer/go-sundheit v0.6.0/go.mod h1:LDdBHD6tQBtmHsdW+i1GwdTt6Wqc0qazf5ZEJVTbTME= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DeRuina/timberjack v1.4.2 h1:4bKlzhKdsR+2oNkgef9mqb4n11ICow8VK88RfzJPzN8= github.com/DeRuina/timberjack v1.4.2/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= -github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -117,6 +115,10 @@ github.com/cilium/ebpf v0.19.0 h1:Ro/rE64RmFBeA9FGjcTc+KmCeY6jXmryu6FfnzPRIao= github.com/cilium/ebpf v0.19.0/go.mod h1:fLCgMo3l8tZmAdM3B2XqdFzXBpwkcSTroaVqN08OWVY= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= @@ -150,8 +152,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= -github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/eko/gocache/lib/v4 v4.2.0 h1:MNykyi5Xw+5Wu3+PUrvtOCaKSZM1nUSVftbzmeC7Yuw= github.com/eko/gocache/lib/v4 v4.2.0/go.mod h1:7ViVmbU+CzDHzRpmB4SXKyyzyuJ8A3UW3/cszpcqB4M= github.com/eko/gocache/store/go_cache/v4 v4.2.2 h1:tAI9nl6TLoJyKG1ujF0CS0n/IgTEMl+NivxtR5R3/hw= @@ -160,16 +162,12 @@ github.com/eko/gocache/store/redis/v4 v4.2.2 h1:Thw31fzGuH3WzJywsdbMivOmP550D6JS github.com/eko/gocache/store/redis/v4 v4.2.2/go.mod h1:LaTxLKx9TG/YUEybQvPMij++D7PBTIJ4+pzvk0ykz0w= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= -github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko= -github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= @@ -177,26 +175,16 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs= -github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= -github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= -github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= -github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= -github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= -github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= -github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= -github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -232,17 +220,12 @@ github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lG github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= -github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= -github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= -github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= -github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= -github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-webauthn/webauthn v0.16.4 h1:R9jqR/cYZa7hRquFF7Za/8qoH/K/TIs1/Q/4CyGN+1Q= @@ -257,8 +240,8 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= @@ -288,9 +271,7 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -305,8 +286,8 @@ github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg= github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM= -github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= -github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg= +github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -325,10 +306,6 @@ github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f2 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357/go.mod h1:w9Y7gY31krpLmrVU5ZPG9H7l9fZuRu5/3R3S3FMtVQ4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= -github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= -github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= -github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= -github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -384,8 +361,6 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= -github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= -github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= @@ -402,18 +377,16 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM= github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= -github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= @@ -434,17 +407,16 @@ github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s= github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/libdns/route53 v1.5.0 h1:2SKdpPFl/qgWsXQvsLNJJAoX7rSxlk7zgoL4jnWdXVA= github.com/libdns/route53 v1.5.0/go.mod h1:joT4hKmaTNKHEwb7GmZ65eoDz1whTu7KKYPS8ZqIh6Q= -github.com/libp2p/go-nat v0.2.0 h1:Tyz+bUFAYqGyJ/ppPPymMGbIgNRH+WqC5QrT5fKrrGk= -github.com/libp2p/go-nat v0.2.0/go.mod h1:3MJr+GRpRkyT65EpVPBstXLvOlAPzUVlG6Pwg9ohLJk= github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT7/Q= github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 h1:J56rFEfUTFT9j9CiRXhi1r8lUJ4W5idG3CiaBZGojNU= github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81/go.mod h1:RD8ML/YdXctQ7qbcizZkw5mZ6l8Ogrl1dodBzVJduwI= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -480,6 +452,10 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= @@ -488,20 +464,22 @@ github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1 h1:4TaYr9O4xX0D2kszeOLclTiCbA3eHq3xWV+9ILJbIYs= -github.com/netbirdio/dex v0.244.1-0.20260512110716-8d70ad8647c1/go.mod h1:IHH+H8vK2GfqtIt5u/5OdPh18yk0oDHuj2vz5+Goetg= +github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5 h1:3PwQv8aR46qN2u16+Dv6udnH3sbVKX5KrGwF35CKSI0= +github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5/go.mod h1:IHH+H8vK2GfqtIt5u/5OdPh18yk0oDHuj2vz5+Goetg= github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 h1:neE7z+FPUkldl3faK/Jt+hJK2L+1XfQ1W33TQhU9m88= github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1/go.mod h1:awuTyT29CYALpEyET0S307EgNlPWrc7fFKRAyhsO45M= github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6Aus= github.com/netbirdio/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVUND//5j1kelYlO57x5IrRviNF0+0iA= +github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8= github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 h1:F3zS5fT9xzD1OFLfcdAE+3FfyiwjGukF1hvj0jErgs8= @@ -510,12 +488,10 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= -github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f h1:ff2D57RBjWtyQ2wVwJOxOgXAXOe/J2lJWtSX0Bz/BRk= -github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= -github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= +github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db h1:gBOE2r4AW1soSmpYJC5/n9/1L8UQ8+HLjed8CY/TzZY= +github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260810103952-24e716aea4db/go.mod h1:bsdahLwBQxXjlmdPPeQyrTcDJfcqAr/ymFj0RXhwtWI= +github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw= +github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= @@ -530,18 +506,20 @@ github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q= -github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= +github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= +github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= -github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/openbao/openbao/api/v2 v2.5.1 h1:Br79D6L20SbAa5P7xqENxmvv8LyI4HoKosPy7klhn4o= github.com/openbao/openbao/api/v2 v2.5.1/go.mod h1:Dh5un77tqGgMbmlVEqjqN+8/dMyUohnkaQVg/wXW0Ig= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME= +github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8= github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs= github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= @@ -583,14 +561,11 @@ github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= -github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= @@ -605,8 +580,8 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= -github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -620,18 +595,8 @@ github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBe github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= -github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= -github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= -github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU= -github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4= -github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= -github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= -github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -640,17 +605,14 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= -github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= -github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= -github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= -github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= -github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= -github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -666,7 +628,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= @@ -685,10 +646,8 @@ github.com/ti-mo/netfilter v0.5.2 h1:CTjOwFuNNeZ9QPdRXt1MZFLFUf84cKtiQutNauHWd40 github.com/ti-mo/netfilter v0.5.2/go.mod h1:Btx3AtFiOVdHReTDmP9AE+hlkOcvIy403u7BXXbWZKo= github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= @@ -709,8 +668,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= -github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zcalusic/sysinfo v1.1.3 h1:u/AVENkuoikKuIZ4sUEJ6iibpmQP6YpGD8SSMCrqAF0= @@ -771,15 +728,13 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= -golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20251113184115-a159579294ab h1:Iqyc+2zr7aGyLuEadIm0KRJP0Wwt+fhlXLa51Fxf1+Q= -golang.org/x/mobile v0.0.0-20251113184115-a159579294ab/go.mod h1:Eq3Nh/5pFSWug2ohiudJ1iyU59SO78QFuh4qTTN++I0= +golang.org/x/mobile v0.0.0-20260816165457-f98cc9b3c733 h1:XKMObIaAElmkdO+4SQh1iCfzwciZHJi1OblnX9BED9k= +golang.org/x/mobile v0.0.0-20260816165457-f98cc9b3c733/go.mod h1:jMwjxoDSx9jqhNaZqPnr6nnKzb7cs+Dy1Czk7wdX+R8= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -789,8 +744,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -809,8 +764,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -825,8 +780,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -859,11 +814,10 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -876,8 +830,8 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -889,8 +843,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -904,8 +858,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -954,7 +908,6 @@ gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -973,11 +926,13 @@ gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDa gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA= gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= -howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= -howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 h1:eeH1AIcPvSc0Z25ThsYF+Xoqbn0CI/YnXVYoTLFdGQw= +howett.net/plist v1.0.2-0.20250314012144-ee69052608d9/go.mod h1:fyFX5Hj5tP1Mpk8obqA9MZgXT416Q5711SDT7dQLTLk= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/idp/dex/config.go b/idp/dex/config.go index 56ed998c2..00b5ce745 100644 --- a/idp/dex/config.go +++ b/idp/dex/config.go @@ -308,7 +308,7 @@ func (s *Storage) OpenStorage(logger *slog.Logger) (storage.Storage, error) { if file == "" { return nil, fmt.Errorf("sqlite3 storage requires 'file' config") } - return (&sql.SQLite3{File: file}).Open(logger) + return newSQLite3(file).Open(logger) case "postgres": dsn, _ := s.Config["dsn"].(string) if dsn == "" { @@ -613,6 +613,10 @@ func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) s cfg.SupportedResponseTypes = c.OAuth2.ResponseTypes } + if len(c.OAuth2.GrantTypes) > 0 { + cfg.AllowedGrantTypes = c.OAuth2.GrantTypes + } + // Apply expiry settings if c.Expiry.IDTokens != "" { if d, err := parseDuration(c.Expiry.IDTokens); err == nil { diff --git a/idp/dex/provider.go b/idp/dex/provider.go index 67aeb995f..f40b96a58 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -20,8 +20,7 @@ import ( "github.com/dexidp/dex/server" "github.com/dexidp/dex/server/signer" "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/sql" - jose "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "golang.org/x/crypto/bcrypt" @@ -41,7 +40,7 @@ type Config struct { GRPCAddr string } -const localConnectorID = "local" +const LocalConnectorID = "local" // Provider wraps a Dex server type Provider struct { @@ -79,7 +78,7 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) { // Initialize SQLite storage dbPath := filepath.Join(config.DataDir, "oidc.db") - sqliteConfig := &sql.SQLite3{File: dbPath} + sqliteConfig := newSQLite3(dbPath) stor, err := sqliteConfig.Open(logger) if err != nil { return nil, fmt.Errorf("failed to open storage: %w", err) @@ -495,18 +494,60 @@ func (p *Provider) Storage() storage.Storage { return p.storage } +// SetClientsMFAChain updates the MFAChain field on OAuth2 clients in Dex storage. +// Pass a non-empty slice (e.g. []string{"default-totp"}) to enable MFA, or nil to disable it. +func SetClientsMFAChain(ctx context.Context, st storage.Storage, clientIDs []string, mfaChain []string) error { + previousChains := make(map[string][]string, len(clientIDs)) + for _, clientID := range clientIDs { + client, err := st.GetClient(ctx, clientID) + if err != nil { + return fmt.Errorf("failed to get client %s before MFA chain update: %w", clientID, err) + } + previousChains[clientID] = cloneMFAChain(client.MFAChain) + } + + updatedClientIDs := make([]string, 0, len(clientIDs)) + for _, clientID := range clientIDs { + if err := st.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) { + old.MFAChain = cloneMFAChain(mfaChain) + return old, nil + }); err != nil { + if rollbackErr := rollbackClientsMFAChain(ctx, st, updatedClientIDs, previousChains); rollbackErr != nil { + return fmt.Errorf("failed to update MFA chain on client %s: %w (also failed to roll back previous MFA chains: %v)", clientID, err, rollbackErr) + } + return fmt.Errorf("failed to update MFA chain on client %s: %w", clientID, err) + } + updatedClientIDs = append(updatedClientIDs, clientID) + } + return nil +} + +func rollbackClientsMFAChain(ctx context.Context, st storage.Storage, clientIDs []string, previousChains map[string][]string) error { + var rollbackErrs []error + for i := len(clientIDs) - 1; i >= 0; i-- { + clientID := clientIDs[i] + previousChain := cloneMFAChain(previousChains[clientID]) + if err := st.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) { + old.MFAChain = previousChain + return old, nil + }); err != nil { + rollbackErrs = append(rollbackErrs, fmt.Errorf("client %s: %w", clientID, err)) + } + } + return errors.Join(rollbackErrs...) +} + +func cloneMFAChain(chain []string) []string { + if chain == nil { + return nil + } + return append([]string(nil), chain...) +} + // SetClientsMFAChain updates the MFAChain field on the dashboard and CLI OAuth2 clients. // Pass a non-empty slice (e.g. []string{"default-totp"}) to enable MFA, or nil to disable it. func (p *Provider) SetClientsMFAChain(ctx context.Context, clientIDs []string, mfaChain []string) error { - for _, clientID := range clientIDs { - if err := p.storage.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) { - old.MFAChain = mfaChain - return old, nil - }); err != nil { - return fmt.Errorf("failed to update MFA chain on client %s: %w", clientID, err) - } - } - return nil + return SetClientsMFAChain(ctx, p.storage, clientIDs, mfaChain) } // Handler returns the Dex server as an http.Handler for embedding in another server. @@ -546,7 +587,7 @@ func (p *Provider) CreateUser(ctx context.Context, email, username, password str // Encode the user ID in Dex's format: base64(protobuf{user_id, connector_id}) // This matches the format Dex uses in JWT tokens - encodedID := EncodeDexUserID(userID, localConnectorID) + encodedID := EncodeDexUserID(userID, LocalConnectorID) return encodedID, nil } @@ -625,7 +666,7 @@ func DecodeDexUserID(encodedID string) (userID, connectorID string, err error) { // local password connector. func IsLocalUserID(encodedID string) bool { _, connectorID, err := DecodeDexUserID(encodedID) - return err == nil && connectorID == localConnectorID + return err == nil && connectorID == LocalConnectorID } // GetUser returns a user by email diff --git a/idp/dex/provider_test.go b/idp/dex/provider_test.go index 3eb29db97..5e132d544 100644 --- a/idp/dex/provider_test.go +++ b/idp/dex/provider_test.go @@ -3,6 +3,8 @@ package dex import ( "context" "encoding/json" + "errors" + "io" "log/slog" "net/http" "net/http/httptest" @@ -11,11 +13,44 @@ import ( "testing" "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" sqllib "github.com/dexidp/dex/storage/sql" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +type updateFailingStorage struct { + storage.Storage + failClientID string +} + +func (s *updateFailingStorage) UpdateClient(ctx context.Context, id string, updater func(storage.Client) (storage.Client, error)) error { + if id == s.failClientID { + return errors.New("forced update failure") + } + return s.Storage.UpdateClient(ctx, id, updater) +} + +func TestSetClientsMFAChainRollsBackUpdatedClients(t *testing.T) { + ctx := context.Background() + st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + + require.NoError(t, st.CreateClient(ctx, storage.Client{ID: "client-1", MFAChain: []string{"old-1"}})) + require.NoError(t, st.CreateClient(ctx, storage.Client{ID: "client-2", MFAChain: []string{"old-2"}})) + + err := SetClientsMFAChain(ctx, &updateFailingStorage{Storage: st, failClientID: "client-2"}, []string{"client-1", "client-2"}, []string{"new"}) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to update MFA chain on client client-2") + + client1, err := st.GetClient(ctx, "client-1") + require.NoError(t, err) + require.Equal(t, []string{"old-1"}, client1.MFAChain) + + client2, err := st.GetClient(ctx, "client-2") + require.NoError(t, err) + require.Equal(t, []string{"old-2"}, client2.MFAChain) +} + func TestUserCreationFlow(t *testing.T) { ctx := context.Background() @@ -595,3 +630,90 @@ enablePasswordDB: true assert.True(t, cfg.ContinueOnConnectorFailure, "buildDexConfig must set ContinueOnConnectorFailure to true so management starts even if an external IdP is down") } + +func TestToServerConfig_WiresGrantTypes(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dex-grants-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + stor := openTestStorage(t, tmpDir) + defer stor.Close() + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + + grants := []string{"authorization_code", "refresh_token"} + cfg := &YAMLConfig{Issuer: "http://localhost:5599/oauth2", OAuth2: OAuth2{GrantTypes: grants}} + assert.Equal(t, grants, cfg.ToServerConfig(stor, logger).AllowedGrantTypes) + + empty := &YAMLConfig{Issuer: "http://localhost:5599/oauth2"} + assert.Empty(t, empty.ToServerConfig(stor, logger).AllowedGrantTypes) +} + +func newDeviceGuardProvider(t *testing.T, grantTypesYAML string) *Provider { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "dex-devguard-*") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) + + yamlContent := ` +issuer: http://localhost:5599/oauth2 +storage: + type: sqlite3 + config: + file: ` + filepath.Join(tmpDir, "dex.db") + ` +web: + http: 127.0.0.1:5599 +enablePasswordDB: true +` + grantTypesYAML + + configPath := filepath.Join(tmpDir, "config.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(yamlContent), 0644)) + + yamlConfig, err := LoadConfig(configPath) + require.NoError(t, err) + + provider, err := NewProviderFromYAML(context.Background(), yamlConfig) + require.NoError(t, err) + t.Cleanup(func() { _ = provider.Stop(context.Background()) }) + return provider +} + +func TestHandler_BlocksDeviceEndpointsWhenDeviceGrantDisabled(t *testing.T) { + provider := newDeviceGuardProvider(t, ` +oauth2: + grantTypes: + - authorization_code + - refresh_token +`) + + devicePaths := []string{ + "/oauth2/device", + "/oauth2/device/code", + "/oauth2/device/token", + "/oauth2/device/auth/verify_code", + "/oauth2/device/callback", + } + for _, path := range devicePaths { + for _, method := range []string{http.MethodGet, http.MethodPost} { + req := httptest.NewRequest(method, path, nil) + rec := httptest.NewRecorder() + provider.Handler().ServeHTTP(rec, req) + assert.Equal(t, http.StatusNotFound, rec.Code, "%s %s must be blocked", method, path) + } + } + + req := httptest.NewRequest(http.MethodGet, "/oauth2/.well-known/openid-configuration", nil) + rec := httptest.NewRecorder() + provider.Handler().ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) +} + +func TestHandler_AllowsDeviceEndpointsWhenGrantsDefault(t *testing.T) { + provider := newDeviceGuardProvider(t, "") + + req := httptest.NewRequest(http.MethodPost, "/oauth2/device/code", nil) + rec := httptest.NewRecorder() + provider.Handler().ServeHTTP(rec, req) + assert.NotEqual(t, http.StatusNotFound, rec.Code) +} diff --git a/idp/dex/sqlite_cgo.go b/idp/dex/sqlite_cgo.go new file mode 100644 index 000000000..5de66f647 --- /dev/null +++ b/idp/dex/sqlite_cgo.go @@ -0,0 +1,15 @@ +//go:build cgo + +package dex + +import ( + sql "github.com/dexidp/dex/storage/sql" +) + +// newSQLite3 builds the dex SQLite3 config. CGO builds use the upstream +// struct that takes a File path. Non-CGO builds get an empty stub whose +// Open() returns the dex "SQLite not available" error — correct behaviour +// for binaries that can't link sqlite3 (e.g. cross-compiled ARM targets). +func newSQLite3(file string) *sql.SQLite3 { + return &sql.SQLite3{File: file} +} diff --git a/idp/dex/sqlite_nocgo.go b/idp/dex/sqlite_nocgo.go new file mode 100644 index 000000000..4def12143 --- /dev/null +++ b/idp/dex/sqlite_nocgo.go @@ -0,0 +1,15 @@ +//go:build !cgo + +package dex + +import ( + sql "github.com/dexidp/dex/storage/sql" +) + +// newSQLite3 for non-CGO builds. The dex SQLite3 stub has no fields and its +// Open() returns an error documenting the missing CGO support — correct +// behaviour for cross-compiled artefacts that never actually run the +// embedded IdP. The `file` argument is ignored. +func newSQLite3(_ string) *sql.SQLite3 { + return &sql.SQLite3{} +} diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh new file mode 100755 index 000000000..7418cb8e8 --- /dev/null +++ b/infrastructure_files/getting-started-enterprise.sh @@ -0,0 +1,738 @@ +#!/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' + +NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" + +# Static IP for Traefik inside the compose bridge network. The management +# server trusts X-Forwarded-* headers from this address only. +TRAEFIK_IP="172.30.0.10" + +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 "Traefik 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_letsencrypt_email() { + if [[ -n "${NETBIRD_LETSENCRYPT_EMAIL:-}" ]]; then + echo "$NETBIRD_LETSENCRYPT_EMAIL" + return + fi + local value="" + echo "Enter your email for Let's Encrypt certificate notifications." > /dev/stderr + echo -n "Email address: " > /dev/stderr + read -r value < /dev/tty + if [[ -z "$value" ]]; then + echo "Email is required for Let's Encrypt." > /dev/stderr + read_letsencrypt_email + return + 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 +} + +# Gate the install on explicit acceptance of the NetBird On-Premise EULA. +require_eula_acceptance() { + cat > /dev/stderr < /dev/stderr + return 0 + fi + + local ans="" + echo -n 'Type "accept" to agree, or anything else to abort: ' > /dev/stderr + read -r ans < /dev/tty + if [[ "$ans" != "accept" ]]; then + echo "" > /dev/stderr + echo "EULA not accepted. Aborting installation." > /dev/stderr + exit 1 + fi + echo "" > /dev/stderr +} + +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 ]]; 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 config.yaml" + echo "Be aware this will remove all data from the database." + exit 1 + fi + + require_eula_acceptance + NETBIRD_EULA_ACCEPTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) + + 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_LETSENCRYPT_EMAIL=$(read_letsencrypt_email) + + echo "" + + NETBIRD_LICENSE_KEY=$(read_secret "Enter license key (input hidden)") + + POSTGRES_USER="netbird" + POSTGRES_DB="netbird" + POSTGRES_PASSWORD=$(rand_secret) + NETBIRD_ENCRYPTION_KEY=$(rand_b64_key) + NETBIRD_SESSION_COOKIE_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 " ACME email: ${NETBIRD_LETSENCRYPT_EMAIL}" + 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 + install -m 600 /dev/null config.yaml + render_config_yaml >> config.yaml + + 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 traefik" +} + +# ------------------------------------------------------------------ +# Renderers +# ------------------------------------------------------------------ + +render_env() { + cat <&2 <<'EOF' +ERROR: This legacy installation script has been retired and no longer runs. -# NetBird Getting Started with Dex IDP -# This script sets up NetBird with Dex as the identity provider +Dex support is not deprecated. For new deployments, use getting-started.sh: -# Sed pattern to strip base64 padding characters -SED_STRIP_PADDING='s/=//g' +https://docs.netbird.io/selfhosted/selfhosted-quickstart -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 +The current installer includes NetBird's embedded Dex-based identity provider. +Local users and external identity providers can be managed through the +NetBird Dashboard: - echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr - exit 1 -} +https://docs.netbird.io/selfhosted/identity-providers/local -check_jq() { - if ! command -v jq &> /dev/null - then - echo "jq is not installed or not in PATH, please install with your package manager. e.g. sudo apt install jq" > /dev/stderr - exit 1 - fi - return 0 -} - -get_main_ip_address() { - if [[ "$OSTYPE" == "darwin"* ]]; then - interface=$(route -n get default | grep 'interface:' | awk '{print $2}') - ip_address=$(ifconfig "$interface" | grep 'inet ' | awk '{print $2}') - else - interface=$(ip route | grep default | awk '{print $5}' | head -n 1) - ip_address=$(ip addr show "$interface" | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1) - fi - - echo "$ip_address" - return 0 -} - -check_nb_domain() { - DOMAIN=$1 - if [[ "$DOMAIN-x" == "-x" ]]; then - echo "The NETBIRD_DOMAIN variable cannot be empty." > /dev/stderr - return 1 - fi - - if [[ "$DOMAIN" == "netbird.example.com" ]]; then - echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr - return 1 - fi - return 0 -} - -read_nb_domain() { - READ_NETBIRD_DOMAIN="" - echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/stderr - read -r READ_NETBIRD_DOMAIN < /dev/tty - if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then - read_nb_domain - fi - echo "$READ_NETBIRD_DOMAIN" - return 0 -} - -get_turn_external_ip() { - TURN_EXTERNAL_IP_CONFIG="#external-ip=" - IP=$(curl -s -4 https://jsonip.com | jq -r '.ip') - if [[ "x-$IP" != "x-" ]]; then - TURN_EXTERNAL_IP_CONFIG="external-ip=$IP" - fi - echo "$TURN_EXTERNAL_IP_CONFIG" - return 0 -} - -wait_dex() { - set +e - echo -n "Waiting for Dex to become ready (via $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN)" - counter=1 - while true; do - # Check Dex through Caddy proxy (also validates TLS is working) - if curl -sk -f -o /dev/null "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/.well-known/openid-configuration" 2>/dev/null; then - break - fi - if [[ $counter -eq 60 ]]; then - echo "" - echo "Taking too long. Checking logs..." - $DOCKER_COMPOSE_COMMAND logs --tail=20 caddy - $DOCKER_COMPOSE_COMMAND logs --tail=20 dex - fi - echo -n " ." - sleep 2 - counter=$((counter + 1)) - done - echo " done" - set -e - return 0 -} - -init_environment() { - CADDY_SECURE_DOMAIN="" - NETBIRD_PORT=80 - NETBIRD_HTTP_PROTOCOL="http" - NETBIRD_RELAY_PROTO="rel" - TURN_USER="self" - TURN_PASSWORD=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING") - NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING") - TURN_MIN_PORT=49152 - TURN_MAX_PORT=65535 - TURN_EXTERNAL_IP_CONFIG=$(get_turn_external_ip) - - # Generate secrets for Dex - DEX_DASHBOARD_CLIENT_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING") - - # Generate admin password - NETBIRD_ADMIN_PASSWORD=$(openssl rand -base64 16 | sed "$SED_STRIP_PADDING") - - if ! check_nb_domain "$NETBIRD_DOMAIN"; then - NETBIRD_DOMAIN=$(read_nb_domain) - fi - - if [[ "$NETBIRD_DOMAIN" == "use-ip" ]]; then - NETBIRD_DOMAIN=$(get_main_ip_address) - else - NETBIRD_PORT=443 - CADDY_SECURE_DOMAIN=", $NETBIRD_DOMAIN:$NETBIRD_PORT" - NETBIRD_HTTP_PROTOCOL="https" - NETBIRD_RELAY_PROTO="rels" - fi - - check_jq - - DOCKER_COMPOSE_COMMAND=$(check_docker_compose) - - if [[ -f dex.yaml ]]; then - echo "Generated files already exist, if you want to reinitialize the environment, please remove them first." - echo "You can use the following commands:" - echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes" - echo " rm -f docker-compose.yml Caddyfile dex.yaml dashboard.env turnserver.conf management.json relay.env" - echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard." - exit 1 - fi - - echo Rendering initial files... - render_docker_compose > docker-compose.yml - render_caddyfile > Caddyfile - render_dex_config > dex.yaml - render_dashboard_env > dashboard.env - render_management_json > management.json - render_turn_server_conf > turnserver.conf - render_relay_env > relay.env - - echo -e "\nStarting Dex IDP\n" - $DOCKER_COMPOSE_COMMAND up -d caddy dex - - # Wait for Dex to be ready (through caddy proxy) - sleep 3 - wait_dex - - echo -e "\nStarting NetBird services\n" - $DOCKER_COMPOSE_COMMAND up -d - - echo -e "\nDone!\n" - echo "You can access the NetBird dashboard at $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN" - echo "" - echo "Login with the following credentials:" - install -m 600 /dev/null .env - printf 'Email: admin@%s\nPassword: %s\n' \ - "$NETBIRD_DOMAIN" "$NETBIRD_ADMIN_PASSWORD" >> .env - echo "Email: admin@$NETBIRD_DOMAIN" - echo "Password: $NETBIRD_ADMIN_PASSWORD" - echo "" - echo "Dex admin UI is not available (Dex has no built-in UI)." - echo "To add more users, edit dex.yaml and restart: $DOCKER_COMPOSE_COMMAND restart dex" - return 0 -} - -render_caddyfile() { - cat < /dev/null; then - ADMIN_PASSWORD_HASH=$(htpasswd -bnBC 10 "" "$NETBIRD_ADMIN_PASSWORD" | tr -d ':\n') - elif command -v python3 &> /dev/null; then - ADMIN_PASSWORD_HASH=$(python3 -c "import bcrypt; print(bcrypt.hashpw('$NETBIRD_ADMIN_PASSWORD'.encode(), bcrypt.gensalt(rounds=10)).decode())" 2>/dev/null || echo "") - fi - - # Fallback to a known hash if we can't generate one - if [[ -z "$ADMIN_PASSWORD_HASH" ]]; then - # This is hash of "password" - user should change it - ADMIN_PASSWORD_HASH='$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W' - NETBIRD_ADMIN_PASSWORD="password" - echo "Warning: Could not generate password hash. Using default password: password. Please change it in dex.yaml" > /dev/stderr - fi - - cat </dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "admin-user-id-001")" - -# Optional: Add external identity provider connectors -# connectors: -# - type: github -# id: github -# name: GitHub -# config: -# clientID: \$GITHUB_CLIENT_ID -# clientSecret: \$GITHUB_CLIENT_SECRET -# redirectURI: $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/dex/callback -# -# - type: ldap -# id: ldap -# name: LDAP -# config: -# host: ldap.example.com:636 -# insecureNoSSL: false -# bindDN: cn=admin,dc=example,dc=com -# bindPW: admin -# userSearch: -# baseDN: ou=users,dc=example,dc=com -# filter: "(objectClass=person)" -# username: uid -# idAttr: uid -# emailAttr: mail -# nameAttr: cn -EOF - return 0 -} - -render_turn_server_conf() { - cat <&2 <<'EOF' +ERROR: This legacy installation script has been retired and no longer runs. -handle_request_command_status() { - PARSED_RESPONSE=$1 - FUNCTION_NAME=$2 - RESPONSE=$3 - if [[ $PARSED_RESPONSE -ne 0 ]]; then - echo "ERROR calling $FUNCTION_NAME:" $(echo "$RESPONSE" | jq -r '.message') > /dev/stderr - exit 1 - fi -} +Zitadel support and existing Zitadel deployments are not deprecated. -handle_zitadel_request_response() { - PARSED_RESPONSE=$1 - FUNCTION_NAME=$2 - RESPONSE=$3 - if [[ $PARSED_RESPONSE == "null" ]]; then - echo "ERROR calling $FUNCTION_NAME:" $(echo "$RESPONSE" | jq -r '.message') > /dev/stderr - exit 1 - fi - sleep 1 -} +For new deployments, use getting-started.sh: -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 +https://docs.netbird.io/selfhosted/selfhosted-quickstart - echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr - exit 1 -} +The current installer includes NetBird's embedded Dex-based identity provider. +Zitadel can be added as an external identity provider directly through the +NetBird Dashboard: -check_jq() { - if ! command -v jq &> /dev/null - then - echo "jq is not installed or not in PATH, please install with your package manager. e.g. sudo apt install jq" > /dev/stderr - exit 1 - fi -} +https://docs.netbird.io/selfhosted/identity-providers/zitadel -wait_crdb() { - set +e - while true; do - if $DOCKER_COMPOSE_COMMAND exec -T zdb curl -sf -o /dev/null 'http://localhost:8080/health?ready=1'; then - break - fi - echo -n " ." - sleep 5 - done - echo " done" - set -e -} +Standalone Zitadel and other custom identity-provider deployments remain +supported through the advanced guide: -init_crdb() { - if [[ $ZITADEL_DATABASE == "cockroach" ]]; then - echo -e "\nInitializing Zitadel's CockroachDB\n\n" - $DOCKER_COMPOSE_COMMAND up -d zdb - echo "" - # shellcheck disable=SC2028 - echo -n "Waiting CockroachDB to become ready" - wait_crdb - $DOCKER_COMPOSE_COMMAND exec -T zdb /bin/bash -c "cp /cockroach/certs/* /zitadel-certs/ && cockroach cert create-client --overwrite --certs-dir /zitadel-certs/ --ca-key /zitadel-certs/ca.key zitadel_user && chown -R 1000:1000 /zitadel-certs/" - handle_request_command_status $? "init_crdb failed" "" - fi -} +https://docs.netbird.io/selfhosted/selfhosted-guide -get_main_ip_address() { - if [[ "$OSTYPE" == "darwin"* ]]; then - interface=$(route -n get default | grep 'interface:' | awk '{print $2}') - ip_address=$(ifconfig "$interface" | grep 'inet ' | awk '{print $2}') - else - interface=$(ip route | grep default | awk '{print $5}' | head -n 1) - ip_address=$(ip addr show "$interface" | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1) - fi - - echo "$ip_address" -} - -wait_pat() { - PAT_PATH=$1 - set +e - while true; do - if [[ -f "$PAT_PATH" ]]; then - break - fi - echo -n " ." - sleep 1 - done - echo " done" - set -e -} - -wait_api() { - INSTANCE_URL=$1 - PAT=$2 - set +e - counter=1 - while true; do - FLAGS="-s" - if [[ $counter -eq 45 ]]; then - FLAGS="-v" - echo "" - fi - - curl $FLAGS --fail --connect-timeout 1 -o /dev/null "$INSTANCE_URL/auth/v1/users/me" -H "Authorization: Bearer $PAT" - if [[ $? -eq 0 ]]; then - break - fi - if [[ $counter -eq 45 ]]; then - echo "" - echo "Unable to connect to Zitadel for more than 45s, please check the output above, your firewall rules and the caddy container logs to confirm if there are any issues provisioning TLS certificates" - fi - echo -n " ." - sleep 1 - counter=$((counter + 1)) - done - echo " done" - set -e -} - -create_new_project() { - INSTANCE_URL=$1 - PAT=$2 - PROJECT_NAME="NETBIRD" - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/projects" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{"name": "'"$PROJECT_NAME"'"}' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.id') - handle_zitadel_request_response "$PARSED_RESPONSE" "create_new_project" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -create_new_application() { - INSTANCE_URL=$1 - PAT=$2 - APPLICATION_NAME=$3 - BASE_REDIRECT_URL1=$4 - BASE_REDIRECT_URL2=$5 - LOGOUT_URL=$6 - ZITADEL_DEV_MODE=$7 - DEVICE_CODE=$8 - - if [[ $DEVICE_CODE == "true" ]]; then - GRANT_TYPES='["OIDC_GRANT_TYPE_AUTHORIZATION_CODE","OIDC_GRANT_TYPE_DEVICE_CODE","OIDC_GRANT_TYPE_REFRESH_TOKEN"]' - else - GRANT_TYPES='["OIDC_GRANT_TYPE_AUTHORIZATION_CODE","OIDC_GRANT_TYPE_REFRESH_TOKEN"]' - fi - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/projects/$PROJECT_ID/apps/oidc" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "'"$APPLICATION_NAME"'", - "redirectUris": [ - "'"$BASE_REDIRECT_URL1"'", - "'"$BASE_REDIRECT_URL2"'" - ], - "postLogoutRedirectUris": [ - "'"$LOGOUT_URL"'" - ], - "RESPONSETypes": [ - "OIDC_RESPONSE_TYPE_CODE" - ], - "grantTypes": '"$GRANT_TYPES"', - "appType": "OIDC_APP_TYPE_USER_AGENT", - "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE", - "version": "OIDC_VERSION_1_0", - "devMode": '"$ZITADEL_DEV_MODE"', - "accessTokenType": "OIDC_TOKEN_TYPE_JWT", - "accessTokenRoleAssertion": true, - "skipNativeAppSuccessPage": true - }' - ) - - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.clientId') - handle_zitadel_request_response "$PARSED_RESPONSE" "create_new_application" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -create_service_user() { - INSTANCE_URL=$1 - PAT=$2 - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/users/machine" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "userName": "netbird-service-account", - "name": "Netbird Service Account", - "description": "Netbird Service Account for IDP management", - "accessTokenType": "ACCESS_TOKEN_TYPE_JWT" - }' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.userId') - handle_zitadel_request_response "$PARSED_RESPONSE" "create_service_user" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -create_service_user_secret() { - INSTANCE_URL=$1 - PAT=$2 - USER_ID=$3 - - RESPONSE=$( - curl -sS -X PUT "$INSTANCE_URL/management/v1/users/$USER_ID/secret" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{}' - ) - SERVICE_USER_CLIENT_ID=$(echo "$RESPONSE" | jq -r '.clientId') - handle_zitadel_request_response "$SERVICE_USER_CLIENT_ID" "create_service_user_secret_id" "$RESPONSE" - SERVICE_USER_CLIENT_SECRET=$(echo "$RESPONSE" | jq -r '.clientSecret') - handle_zitadel_request_response "$SERVICE_USER_CLIENT_SECRET" "create_service_user_secret" "$RESPONSE" -} - -add_organization_user_manager() { - INSTANCE_URL=$1 - PAT=$2 - USER_ID=$3 - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/orgs/me/members" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "userId": "'"$USER_ID"'", - "roles": [ - "ORG_USER_MANAGER" - ] - }' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.creationDate') - handle_zitadel_request_response "$PARSED_RESPONSE" "add_organization_user_manager" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -create_admin_user() { - INSTANCE_URL=$1 - PAT=$2 - USERNAME=$3 - PASSWORD=$4 - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/users/human/_import" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "userName": "'"$USERNAME"'", - "profile": { - "firstName": "Zitadel", - "lastName": "Admin" - }, - "email": { - "email": "'"$USERNAME"'", - "isEmailVerified": true - }, - "password": "'"$PASSWORD"'", - "passwordChangeRequired": true - }' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.userId') - handle_zitadel_request_response "$PARSED_RESPONSE" "create_admin_user" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -add_instance_admin() { - INSTANCE_URL=$1 - PAT=$2 - USER_ID=$3 - - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/admin/v1/members" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "userId": "'"$USER_ID"'", - "roles": [ - "IAM_OWNER" - ] - }' - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.creationDate') - handle_zitadel_request_response "$PARSED_RESPONSE" "add_instance_admin" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -delete_auto_service_user() { - INSTANCE_URL=$1 - PAT=$2 - - RESPONSE=$( - curl -sS -X GET "$INSTANCE_URL/auth/v1/users/me" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - ) - USER_ID=$(echo "$RESPONSE" | jq -r '.user.id') - handle_zitadel_request_response "$USER_ID" "delete_auto_service_user_get_user" "$RESPONSE" - - RESPONSE=$( - curl -sS -X DELETE "$INSTANCE_URL/admin/v1/members/$USER_ID" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate') - handle_zitadel_request_response "$PARSED_RESPONSE" "delete_auto_service_user_remove_instance_permissions" "$RESPONSE" - - RESPONSE=$( - curl -sS -X DELETE "$INSTANCE_URL/management/v1/orgs/me/members/$USER_ID" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate') - handle_zitadel_request_response "$PARSED_RESPONSE" "delete_auto_service_user_remove_org_permissions" "$RESPONSE" - echo "$PARSED_RESPONSE" -} - -delete_default_zitadel_admin() { - INSTANCE_URL=$1 - PAT=$2 - - # Search for the default zitadel-admin user - RESPONSE=$( - curl -sS -X POST "$INSTANCE_URL/management/v1/users/_search" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - -d '{ - "queries": [ - { - "userNameQuery": { - "userName": "zitadel-admin@", - "method": "TEXT_QUERY_METHOD_STARTS_WITH" - } - } - ] - }' - ) - - DEFAULT_ADMIN_ID=$(echo "$RESPONSE" | jq -r '.result[0].id // empty') - - if [ -n "$DEFAULT_ADMIN_ID" ] && [ "$DEFAULT_ADMIN_ID" != "null" ]; then - echo "Found default zitadel-admin user with ID: $DEFAULT_ADMIN_ID" - - RESPONSE=$( - curl -sS -X DELETE "$INSTANCE_URL/management/v1/users/$DEFAULT_ADMIN_ID" \ - -H "Authorization: Bearer $PAT" \ - -H "Content-Type: application/json" \ - ) - PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate // "deleted"') - handle_zitadel_request_response "$PARSED_RESPONSE" "delete_default_zitadel_admin" "$RESPONSE" - - else - echo "Default zitadel-admin user not found: $RESPONSE" - fi -} - -init_zitadel() { - echo -e "\nInitializing Zitadel with NetBird's applications\n" - INSTANCE_URL="$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN" - - TOKEN_PATH=./machinekey/zitadel-admin-sa.token - - echo -n "Waiting for Zitadel's PAT to be created " - wait_pat "$TOKEN_PATH" - echo "Reading Zitadel PAT" - PAT=$(cat $TOKEN_PATH) - if [ "$PAT" = "null" ]; then - echo "Failed requesting getting Zitadel PAT" - exit 1 - fi - - echo -n "Waiting for Zitadel to become ready " - wait_api "$INSTANCE_URL" "$PAT" - - echo "Deleting default zitadel-admin user..." - delete_default_zitadel_admin "$INSTANCE_URL" "$PAT" - - # create the zitadel project - echo "Creating new zitadel project" - PROJECT_ID=$(create_new_project "$INSTANCE_URL" "$PAT") - - ZITADEL_DEV_MODE=false - BASE_REDIRECT_URL=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN - if [[ $NETBIRD_HTTP_PROTOCOL == "http" ]]; then - ZITADEL_DEV_MODE=true - fi - - # create zitadel spa applications - echo "Creating new Zitadel SPA Dashboard application" - DASHBOARD_APPLICATION_CLIENT_ID=$(create_new_application "$INSTANCE_URL" "$PAT" "Dashboard" "$BASE_REDIRECT_URL/nb-auth" "$BASE_REDIRECT_URL/nb-silent-auth" "$BASE_REDIRECT_URL/" "$ZITADEL_DEV_MODE" "false") - - echo "Creating new Zitadel SPA Cli application" - CLI_APPLICATION_CLIENT_ID=$(create_new_application "$INSTANCE_URL" "$PAT" "Cli" "http://localhost:53000/" "http://localhost:54000/" "http://localhost:53000/" "true" "true") - - MACHINE_USER_ID=$(create_service_user "$INSTANCE_URL" "$PAT") - - SERVICE_USER_CLIENT_ID="null" - SERVICE_USER_CLIENT_SECRET="null" - - create_service_user_secret "$INSTANCE_URL" "$PAT" "$MACHINE_USER_ID" - - DATE=$(add_organization_user_manager "$INSTANCE_URL" "$PAT" "$MACHINE_USER_ID") - - ZITADEL_ADMIN_USERNAME="admin@$NETBIRD_DOMAIN" - ZITADEL_ADMIN_PASSWORD="$(openssl rand -base64 32 | sed 's/=//g')@" - - HUMAN_USER_ID=$(create_admin_user "$INSTANCE_URL" "$PAT" "$ZITADEL_ADMIN_USERNAME" "$ZITADEL_ADMIN_PASSWORD") - - DATE="null" - - DATE=$(add_instance_admin "$INSTANCE_URL" "$PAT" "$HUMAN_USER_ID") - - DATE="null" - DATE=$(delete_auto_service_user "$INSTANCE_URL" "$PAT") - if [ "$DATE" = "null" ]; then - echo "Failed deleting auto service user" - echo "Please remove it manually" - fi - - export NETBIRD_AUTH_CLIENT_ID=$DASHBOARD_APPLICATION_CLIENT_ID - export NETBIRD_AUTH_CLIENT_ID_CLI=$CLI_APPLICATION_CLIENT_ID - export NETBIRD_IDP_MGMT_CLIENT_ID=$SERVICE_USER_CLIENT_ID - export NETBIRD_IDP_MGMT_CLIENT_SECRET=$SERVICE_USER_CLIENT_SECRET - export ZITADEL_ADMIN_USERNAME - export ZITADEL_ADMIN_PASSWORD -} - -check_nb_domain() { - DOMAIN=$1 - if [ "$DOMAIN-x" == "-x" ]; then - echo "The NETBIRD_DOMAIN variable cannot be empty." > /dev/stderr - return 1 - fi - - if [ "$DOMAIN" == "netbird.example.com" ]; then - echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr - return 1 - fi - return 0 -} - -read_nb_domain() { - READ_NETBIRD_DOMAIN="" - echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/stderr - read -r READ_NETBIRD_DOMAIN < /dev/tty - if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then - read_nb_domain - fi - echo "$READ_NETBIRD_DOMAIN" -} - -get_turn_external_ip() { - TURN_EXTERNAL_IP_CONFIG="#external-ip=" - IP=$(curl -s -4 https://jsonip.com | jq -r '.ip') - if [[ "x-$IP" != "x-" ]]; then - TURN_EXTERNAL_IP_CONFIG="external-ip=$IP" - fi - echo "$TURN_EXTERNAL_IP_CONFIG" -} - -initEnvironment() { - CADDY_SECURE_DOMAIN="" - ZITADEL_EXTERNALSECURE="false" - ZITADEL_TLS_MODE="disabled" - ZITADEL_MASTERKEY="$(openssl rand -base64 32 | head -c 32)" - NETBIRD_PORT=80 - NETBIRD_HTTP_PROTOCOL="http" - NETBIRD_RELAY_PROTO="rel" - TURN_USER="self" - TURN_PASSWORD=$(openssl rand -base64 32 | sed 's/=//g') - NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed 's/=//g') - TURN_MIN_PORT=49152 - TURN_MAX_PORT=65535 - TURN_EXTERNAL_IP_CONFIG=$(get_turn_external_ip) - - if ! check_nb_domain "$NETBIRD_DOMAIN"; then - NETBIRD_DOMAIN=$(read_nb_domain) - fi - - if [ "$NETBIRD_DOMAIN" == "use-ip" ]; then - NETBIRD_DOMAIN=$(get_main_ip_address) - else - ZITADEL_EXTERNALSECURE="true" - ZITADEL_TLS_MODE="external" - NETBIRD_PORT=443 - CADDY_SECURE_DOMAIN=", $NETBIRD_DOMAIN:$NETBIRD_PORT" - NETBIRD_HTTP_PROTOCOL="https" - NETBIRD_RELAY_PROTO="rels" - fi - - if [[ "$OSTYPE" == "darwin"* ]]; then - ZIDATE_TOKEN_EXPIRATION_DATE=$(date -u -v+30M "+%Y-%m-%dT%H:%M:%SZ") - else - ZIDATE_TOKEN_EXPIRATION_DATE=$(date -u -d "+30 minutes" "+%Y-%m-%dT%H:%M:%SZ") - fi - - check_jq - - DOCKER_COMPOSE_COMMAND=$(check_docker_compose) - - if [ -f zitadel.env ]; then - echo "Generated files already exist, if you want to reinitialize the environment, please remove them first." - echo "You can use the following commands:" - echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes" - echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json relay.env" - echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard." - exit 1 - fi - - if [[ $ZITADEL_DATABASE == "cockroach" ]]; then - echo "Use CockroachDB as Zitadel database." - ZDB=$(renderDockerComposeCockroachDB) - ZITADEL_DB_ENV=$(renderZitadelCockroachDBEnv) - else - echo "Use Postgres as default Zitadel database." - echo "For using CockroachDB please the environment variable 'export ZITADEL_DATABASE=cockroach'." - POSTGRES_ROOT_PASSWORD="$(openssl rand -base64 32 | sed 's/=//g')@" - POSTGRES_ZITADEL_PASSWORD="$(openssl rand -base64 32 | sed 's/=//g')@" - ZDB=$(renderDockerComposePostgres) - ZITADEL_DB_ENV=$(renderZitadelPostgresEnv) - renderPostgresEnv > zdb.env - fi - - echo Rendering initial files... - renderDockerCompose > docker-compose.yml - renderCaddyfile > Caddyfile - renderZitadelEnv > zitadel.env - echo "" > dashboard.env - echo "" > turnserver.conf - echo "" > management.json - echo "" > relay.env - - mkdir -p machinekey - chmod 777 machinekey - - init_crdb - - echo -e "\nStarting Zitadel IDP for user management\n\n" - $DOCKER_COMPOSE_COMMAND up -d caddy zitadel - init_zitadel - - echo -e "\nRendering NetBird files...\n" - renderTurnServerConf > turnserver.conf - renderManagementJson > management.json - renderDashboardEnv > dashboard.env - renderRelayEnv > relay.env - - echo -e "\nStarting NetBird services\n" - $DOCKER_COMPOSE_COMMAND up -d - echo -e "\nDone!\n" - echo "You can access the NetBird dashboard at $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN" - echo "Login with the following credentials:" - install -m 600 /dev/null .env - printf 'Username: %s\nPassword: %s\n' \ - "$ZITADEL_ADMIN_USERNAME" "$ZITADEL_ADMIN_PASSWORD" >> .env - echo "Username: $ZITADEL_ADMIN_USERNAME" - echo "Password: $ZITADEL_ADMIN_PASSWORD" -} - -renderCaddyfile() { - cat </dev/null +} + +# resolve ENV_VAR_NAME DEFAULT PROMPT_FN [prompt args...] +# env var set and non-empty -> its value +# interactive -> PROMPT_FN "$@" (prompt behavior unchanged) +# otherwise -> DEFAULT, or abort when DEFAULT is "required" +resolve() { + local env_name="$1" default="$2" prompt_fn="$3" + shift 3 + local env_value="${!env_name:-}" + if [[ -n "$env_value" ]]; then + echo "$env_value" + elif tty_available; then + "$prompt_fn" "$@" + elif [[ "$default" == "required" ]]; then + echo "$env_name is required for a non-interactive install." > /dev/stderr + exit 1 + else + echo "$default" + fi + return 0 +} + read_nb_domain() { READ_NETBIRD_DOMAIN="" echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/stderr @@ -348,6 +401,7 @@ initialize_default_values() { NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING") # Note: DataStoreEncryptionKey must keep base64 padding (=) for Go's base64.StdEncoding DATASTORE_ENCRYPTION_KEY=$(openssl rand -base64 32) + SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32) NETBIRD_STUN_PORT=3478 # Docker images @@ -382,7 +436,14 @@ initialize_default_values() { } configure_domain() { + # Domain is validated (not a free-form value), so it keeps its own guard + # rather than going through resolve(): a valid NETBIRD_DOMAIN is used as-is, + # otherwise we prompt, or abort when there is no terminal to prompt on. if ! check_nb_domain "$NETBIRD_DOMAIN"; then + if ! tty_available; then + echo "NETBIRD_DOMAIN is required for a non-interactive install." > /dev/stderr + exit 1 + fi NETBIRD_DOMAIN=$(read_nb_domain) fi @@ -398,36 +459,69 @@ configure_domain() { return 0 } +apply_agent_network_preset() { + # Agent-network turnkey install: built-in Traefik + NetBird Proxy with + # NB_PROXY_PRIVATE=true, dashboard locked to agent-network-only mode. + # Bypasses every reverse-proxy / proxy / CrowdSec prompt. The only + # inputs we still need from the operator are the domain (handled by + # configure_domain via NETBIRD_DOMAIN env var or interactive prompt) + # and the ACME email — both honor env vars first and fall back to a + # prompt only when unset. CrowdSec is intentionally off. + REVERSE_PROXY_TYPE="0" + ENABLE_PROXY="true" + ENABLE_CROWDSEC="false" + + TRAEFIK_ACME_EMAIL=$(resolve NETBIRD_LETSENCRYPT_EMAIL required read_traefik_acme_email) + + echo "" > /dev/stderr + echo "Agent-network preset enabled (NETBIRD_AGENT_NETWORK=true):" > /dev/stderr + echo " - reverse proxy: built-in Traefik" > /dev/stderr + echo " - NetBird Proxy: enabled with NB_PROXY_PRIVATE=true" > /dev/stderr + echo " - server image: ${NETBIRD_SERVER_IMAGE}" > /dev/stderr + echo " - proxy image: ${NETBIRD_PROXY_IMAGE}" > /dev/stderr + echo " - dashboard: NETBIRD_AGENT_NETWORK_ONLY=true" > /dev/stderr + echo " - CrowdSec: disabled" > /dev/stderr + echo " - Let's Encrypt email: ${TRAEFIK_ACME_EMAIL}" > /dev/stderr + echo "" > /dev/stderr +} + configure_reverse_proxy() { - # Prompt for reverse proxy type - REVERSE_PROXY_TYPE=$(read_reverse_proxy_type) + # Short-circuit: agent-network preset locks every reverse-proxy / + # proxy / CrowdSec choice and bypasses the interactive prompts. + if [[ "${NETBIRD_AGENT_NETWORK}" == "true" ]]; then + apply_agent_network_preset + return 0 + fi + + # Reverse proxy type (env NETBIRD_REVERSE_PROXY_TYPE, else prompt, else 0) + REVERSE_PROXY_TYPE=$(resolve NETBIRD_REVERSE_PROXY_TYPE 0 read_reverse_proxy_type) # Handle built-in Traefik prompts (option 0) if [[ "$REVERSE_PROXY_TYPE" == "0" ]]; then - TRAEFIK_ACME_EMAIL=$(read_traefik_acme_email) - ENABLE_PROXY=$(read_enable_proxy) + TRAEFIK_ACME_EMAIL=$(resolve NETBIRD_LETSENCRYPT_EMAIL required read_traefik_acme_email) + ENABLE_PROXY=$(resolve NETBIRD_ENABLE_PROXY false read_enable_proxy) if [[ "$ENABLE_PROXY" == "true" ]]; then - ENABLE_CROWDSEC=$(read_enable_crowdsec) + ENABLE_CROWDSEC=$(resolve NETBIRD_ENABLE_CROWDSEC false read_enable_crowdsec) fi fi # Handle external Traefik-specific prompts (option 1) if [[ "$REVERSE_PROXY_TYPE" == "1" ]]; then - TRAEFIK_EXTERNAL_NETWORK=$(read_traefik_network) - TRAEFIK_ENTRYPOINT=$(read_traefik_entrypoint) - TRAEFIK_CERTRESOLVER=$(read_traefik_certresolver) + TRAEFIK_EXTERNAL_NETWORK=$(resolve NETBIRD_TRAEFIK_EXTERNAL_NETWORK "" read_traefik_network) + TRAEFIK_ENTRYPOINT=$(resolve NETBIRD_TRAEFIK_ENTRYPOINT websecure read_traefik_entrypoint) + TRAEFIK_CERTRESOLVER=$(resolve NETBIRD_TRAEFIK_CERTRESOLVER "" read_traefik_certresolver) fi # Handle port binding for external proxy options (2-5) if [[ "$REVERSE_PROXY_TYPE" -ge 2 ]]; then - BIND_LOCALHOST_ONLY=$(read_port_binding_preference) + BIND_LOCALHOST_ONLY=$(resolve NETBIRD_BIND_LOCALHOST_ONLY true read_port_binding_preference) fi # Handle Docker network prompts for external proxies (options 2-4) case "$REVERSE_PROXY_TYPE" in - 2) EXTERNAL_PROXY_NETWORK=$(read_proxy_docker_network "Nginx") ;; - 3) EXTERNAL_PROXY_NETWORK=$(read_proxy_docker_network "Nginx Proxy Manager") ;; - 4) EXTERNAL_PROXY_NETWORK=$(read_proxy_docker_network "Caddy") ;; + 2) EXTERNAL_PROXY_NETWORK=$(resolve NETBIRD_EXTERNAL_PROXY_NETWORK "" read_proxy_docker_network "Nginx") ;; + 3) EXTERNAL_PROXY_NETWORK=$(resolve NETBIRD_EXTERNAL_PROXY_NETWORK "" read_proxy_docker_network "Nginx Proxy Manager") ;; + 4) EXTERNAL_PROXY_NETWORK=$(resolve NETBIRD_EXTERNAL_PROXY_NETWORK "" read_proxy_docker_network "Caddy") ;; *) ;; # No network prompt for other options esac return 0 @@ -490,7 +584,8 @@ generate_configuration_files() { # Common files for all configurations render_dashboard_env > dashboard.env - render_combined_yaml > config.yaml + install -m 600 /dev/null config.yaml + render_combined_yaml >> config.yaml return 0 } @@ -519,7 +614,7 @@ start_services_and_show_instructions() { echo "Creating proxy access token..." # Use docker exec with bash to run the token command directly PROXY_TOKEN=$($DOCKER_COMPOSE_COMMAND exec -T netbird-server \ - /go/bin/netbird-server token create --name "default-proxy" --config /etc/netbird/config.yaml 2>/dev/null | grep "^Token:" | awk '{print $2}') + /go/bin/netbird-server admin token create --name "default-proxy" --config /etc/netbird/config.yaml 2>/dev/null | grep "^Token:" | awk '{print $2}') if [[ -z "$PROXY_TOKEN" ]]; then echo "ERROR: Failed to create proxy token. Check netbird-server logs." > /dev/stderr @@ -604,8 +699,13 @@ start_services_and_show_instructions() { print_post_setup_instructions echo "" - echo -n "Press Enter when your reverse proxy is configured (or Ctrl+C to exit)... " - read -r < /dev/tty + if tty_available; then + echo -n "Press Enter when your reverse proxy is configured (or Ctrl+C to exit)... " + read -r < /dev/tty + else + echo "Non-interactive mode: starting NetBird containers now. Finish configuring" + echo "your reverse proxy using the instructions above so it can reach them." + fi echo -e "$MSG_STARTING_SERVICES" $DOCKER_COMPOSE_COMMAND up -d @@ -874,6 +974,7 @@ server: auth: issuer: "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2" signKeyRefreshEnabled: true + sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY" dashboardRedirectURIs: - "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-auth" - "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-silent-auth" @@ -910,6 +1011,15 @@ NGINX_SSL_PORT=443 # Letsencrypt LETSENCRYPT_DOMAIN=none EOF + + if [[ "${NETBIRD_AGENT_NETWORK}" == "true" ]]; then + cat < /dev/null; then + echo "docker-compose" + return + fi + if docker compose --help &> /dev/null; then + echo "docker compose" + return + fi + echo "docker-compose is not installed or not in PATH." > /dev/stderr + exit 1 +} + +check_yq() { + if ! command -v yq &> /dev/null; then + cat > /dev/stderr <<'EOF' +yq is required to parse and update YAML safely. + + macOS: brew install yq + Linux: https://github.com/mikefarah/yq/releases (download binary into PATH) + Debian: apt-get install yq (Note: must be the mikefarah Go yq, not the Python wrapper.) + +EOF + exit 1 + fi + if ! yq --version 2>&1 | grep -q "mikefarah"; then + echo "yq is present but appears to be the wrong implementation. The mikefarah Go-based yq is required (https://github.com/mikefarah/yq)." > /dev/stderr + exit 1 + fi +} + +check_openssl() { + if ! command -v openssl &> /dev/null; then + echo "openssl is not installed or not in PATH." > /dev/stderr + exit 1 + fi +} + +rand_password() { + openssl rand -hex 32 +} + +read_required() { + local prompt="$1" + local value="" + while [[ -z "$value" ]]; do + echo -n "$prompt: " > /dev/stderr + read -r value < /dev/tty + if [[ -z "$value" ]]; then + echo "Value cannot be empty." > /dev/stderr + fi + done + echo "$value" +} + +read_secret() { + local prompt="$1" + local value="" + while [[ -z "$value" ]]; do + echo -n "$prompt: " > /dev/stderr + read -rs value < /dev/tty + echo "" > /dev/stderr + if [[ -z "$value" ]]; then + echo "Value cannot be empty." > /dev/stderr + fi + done + echo "$value" +} + +read_yes_no() { + local prompt="$1" + local default="${2:-n}" + local hint + if [[ "$default" == "y" ]]; then + hint="[Y/n]" + else + hint="[y/N]" + fi + echo -n "${prompt} ${hint}: " > /dev/stderr + local ans="" + read -r ans < /dev/tty + if [[ -z "$ans" ]]; then + ans="$default" + fi + case "$ans" in + [Yy] | [Yy][Ee][Ss]) echo "yes" ;; + *) echo "no" ;; + esac +} + +# Gate the migration on explicit acceptance of the NetBird On-Premise EULA. +require_eula_acceptance() { + cat > /dev/stderr < /dev/stderr + return 0 + fi + + local ans="" + echo -n 'Type "accept" to agree, or anything else to abort: ' > /dev/stderr + read -r ans < /dev/tty + if [[ "$ans" != "accept" ]]; then + echo "" > /dev/stderr + echo "EULA not accepted. Aborting migration." > /dev/stderr + exit 1 + fi + echo "" > /dev/stderr +} + +# --------------------------------------------------------------------------- +# 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("^(ghcr\\.io/)?netbirdio/netbird-server([:@]|$)"))) | .[0].key // ""' "$COMPOSE_FILE" +} + +detect_dashboard_service() { + yq eval '.services | to_entries | map(select(.value.image | test("^(ghcr\\.io/)?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" +} + +# The engine is a config.yaml-only setting — there is no env override for it +# (combined/cmd/root.go reads it from YAML and derives the env vars), so +# config.yaml is authoritative. Absent means the sqlite default. +detect_store_engine() { + local engine + engine=$(yq eval '.server.store.engine // ""' "$CONFIG_YAML_HOST") + if [[ -z "$engine" ]] || [[ "$engine" == "null" ]]; then + engine="sqlite" + fi + echo "$engine" | tr '[:upper:]' '[:lower:]' +} + +detect_store_dsn() { + yq eval '.server.store.dsn // ""' "$CONFIG_YAML_HOST" +} + +# config.yaml is where a combined deployment carries its DSN; this only covers +# hand-rolled installs that keep it in the environment instead. +detect_store_dsn_from_compose() { + # `compose config` re-escapes a literal $ as $$ on the way out, so undo that + # to get the value the container actually receives. + $DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval " + .services[\"$COMBINED_SERVICE\"].environment.NB_STORE_ENGINE_POSTGRES_DSN // + .services[\"$COMBINED_SERVICE\"].environment.NETBIRD_STORE_ENGINE_POSTGRES_DSN // \"\" + " - 2>/dev/null | sed 's/\$\$/$/g' +} + +# Reads either DSN form: "host=db ..." or "postgres://user:pass@db:5432/name". +dsn_host() { + local dsn="$1" + case "$dsn" in + *://*) printf '%s' "$dsn" | sed -n 's,^[a-zA-Z+]*://\([^/?]*\).*,\1,p' | sed -e 's,.*@,,' -e 's,:.*,,' ;; + *) printf '%s' "$dsn" | sed -n 's/.*[[:space:]]*host=\([^[:space:]]*\).*/\1/p' ;; + esac +} + +# flow-enricher is its own container, so a loopback host or a socket path would +# reach the enricher rather than Postgres. Only flag hosts we can positively +# identify — an unparseable DSN must not leave the operator with no way forward. +dsn_host_reachable() { + local dsn="$1" + case "$(dsn_host "$dsn")" in + localhost | 127.* | ::1 | 0.0.0.0 | /*) return 1 ;; + *) return 0 ;; + esac +} + +# Names the compose service running this deployment's Postgres, for depends_on. +# Empty means external — the DSN host matched no service. A DSN with no readable +# host falls back to matching on image. +detect_postgres_service() { + local host + host=$(dsn_host "$POSTGRES_DSN") + if [[ -n "$host" ]]; then + if [[ "$(host="$host" yq eval '.services | has(env(host))' "$COMPOSE_FILE" 2>/dev/null)" == "true" ]]; then + echo "$host" + fi + return + fi + yq eval '.services | to_entries | map(select(.value.image // "" | test("(^|/)(postgres|postgis|pgvector|timescaledb)(:|@|$)"))) | .[0].key // ""' "$COMPOSE_FILE" +} + +# depends_on: service_healthy is only legal if the service defines a healthcheck. +detect_postgres_depends_condition() { + local tag + tag=$(yq eval ".services[\"$POSTGRES_SERVICE\"].healthcheck | tag" "$COMPOSE_FILE" 2>/dev/null) + if [[ "$tag" == "!!map" ]]; then + echo "service_healthy" + else + echo "service_started" + fi +} + +env_value() { + local value="$1" + value=$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\$/$$/g') + printf '"%s"' "$value" +} + +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 [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + # Fresh Postgres: point every store section at it. migrate-store carries the + # SQLite contents across. + POSTGRES_DSN="$POSTGRES_DSN" yq eval -i ' + .server.store.engine = "postgres" | + .server.store.dsn = strenv(POSTGRES_DSN) | + .server.activityStore.engine = "postgres" | + .server.activityStore.dsn = strenv(POSTGRES_DSN) | + .server.authStore.engine = "postgres" | + .server.authStore.dsn = strenv(POSTGRES_DSN) + ' "$ENTERPRISE_CONFIG_FILE" + fi + # Otherwise the store config is the operator's and stays untouched. + # activityStore and authStore do not inherit from server.store — each falls + # back to its own SQLite file under dataDir — so repointing them at Postgres + # here would silently strand the existing audit log and the embedded IdP's + # users, with no migrate-store run to carry them over. + + if [[ "$ENABLE_FLOW" == "yes" ]]; then + NETBIRD_DOMAIN="$NETBIRD_DOMAIN" yq eval -i ' + .server.trafficFlow.enabled = true | + .server.trafficFlow.address = strenv(NETBIRD_DOMAIN) | + .server.trafficFlow.interval = "60s" + ' "$ENTERPRISE_CONFIG_FILE" + fi +} + +# --------------------------------------------------------------------------- +# Execution steps +# --------------------------------------------------------------------------- + +combined_container_id() { + $DOCKER_COMPOSE_COMMAND ps -aq "$COMBINED_SERVICE" 2>/dev/null | head -1 +} + +container_data_mount() { + local container="$1" + [[ -n "$container" ]] || return 0 + docker inspect "$container" --format \ + '{{range .Mounts}}{{if eq .Destination "/var/lib/netbird"}}{{if .Name}}{{.Name}}{{else}}{{.Source}}{{end}}{{end}}{{end}}' 2>/dev/null +} + +# The name comes from the container, so `-v` cannot invent an empty volume here. +# 0 = empty, 1 = holds data, 2 = could not determine. A failed listing must not +# be reported as empty: that would abort a healthy migration over a pull error +# or an unreadable bind mount. +data_dir_state() { + local src="$1" out + if [[ "$src" == /* ]]; then + [[ -d "$src" ]] || return 2 + out=$(ls -A "$src" 2>/dev/null) || return 2 + else + docker volume inspect "$src" &> /dev/null || return 0 + out=$(docker run --rm -v "${src}:/d:ro" busybox sh -c 'ls -A /d' 2>/dev/null) || return 2 + fi + [[ -z "$out" ]] && return 0 + return 1 +} + +check_data_directory() { + [[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0 + + local container + container=$(combined_container_id) + if [[ -z "$container" ]]; then + echo "" > /dev/stderr + echo "No container found for service '$COMBINED_SERVICE'." > /dev/stderr + echo "The migration backs up the store by copying it out of that container," > /dev/stderr + echo "so it has to exist. Start the deployment and re-run:" > /dev/stderr + echo " $DOCKER_COMPOSE_COMMAND up -d" > /dev/stderr + exit 1 + fi + + local src + src=$(container_data_mount "$container") + if [[ -z "$src" ]]; then + echo "" > /dev/stderr + echo "The '$COMBINED_SERVICE' container has nothing mounted at /var/lib/netbird." > /dev/stderr + echo "Cannot locate the NetBird store to back it up." > /dev/stderr + exit 1 + fi + + local state=0 + data_dir_state "$src" || state=$? + if [[ $state -eq 0 ]]; then + echo "" > /dev/stderr + echo "The NetBird data directory is empty:" > /dev/stderr + echo " $src" > /dev/stderr + echo "There is nothing to migrate. Check that you are running this from the" > /dev/stderr + echo "deployment directory of the NetBird install you mean to migrate." > /dev/stderr + exit 1 + fi + if [[ $state -eq 2 ]]; then + echo " ⚠ Could not read $src to confirm it holds data — continuing." > /dev/stderr + echo " The backup step still fails loudly if it turns out to be empty." > /dev/stderr + fi + + echo " Data directory: $src" +} + +# Only for the Postgres volume, which has no container to read it off yet. +resolve_compose_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 container + container=$(combined_container_id) + if [[ -z "$container" ]]; then + echo " ⚠ No container found for '$COMBINED_SERVICE' — cannot back up the store." > /dev/stderr + exit 1 + fi + + echo "Backing up the NetBird store to $BACKUP_DIR ..." + docker cp "${container}:/var/lib/netbird/." "$BACKUP_DIR/" + + local copied + copied=$(find "$BACKUP_DIR" -mindepth 1 | head -1) + if [[ -z "$copied" ]]; then + echo " ⚠ Backup directory is empty — /var/lib/netbird held no 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" +} + +# --------------------------------------------------------------------------- +# Rollback — a failed run must not leave the operator with a stopped stack and +# half-written artifacts. +# --------------------------------------------------------------------------- + +# Resolve the name Compose would give the Postgres volume before the override +# exists, so a leftover volume can be spotted up front. +compose_project_name() { + local container project + container=$($DOCKER_COMPOSE_COMMAND ps -aq 2>/dev/null | head -1) + if [[ -n "$container" ]]; then + project=$(docker inspect "$container" \ + --format '{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null) + if [[ -n "$project" ]]; then + echo "$project" + return 0 + fi + fi + project=$($DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval '.name // ""' - 2>/dev/null) + if [[ -n "$project" ]] && [[ "$project" != "null" ]]; then + echo "$project" + fi + return 0 +} + +postgres_volume_name() { + local project + project=$(compose_project_name) + if [[ -n "$project" ]]; then + echo "${project}_netbird_postgres" + fi + return 0 +} + +# Postgres skips initdb when its data directory is non-empty, so a volume left +# behind by an interrupted run would keep the old password and old contents, +# and migrate-store would fail against it. +check_stale_postgres_volume() { + [[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0 + + PG_VOLUME_NAME=$(postgres_volume_name) + if [[ -z "$PG_VOLUME_NAME" ]]; then + echo "" + echo " ⚠ Could not determine the Compose project name, so a Postgres volume" + echo " left over from an earlier attempt cannot be checked for. If a" + echo " previous run failed, remove it before continuing:" + echo " docker volume ls | grep netbird_postgres" + return 0 + fi + docker volume inspect "$PG_VOLUME_NAME" &> /dev/null || return 0 + + echo "" + echo " ⚠ A Postgres volume from an earlier attempt already exists:" + echo " $PG_VOLUME_NAME" + echo " Postgres does not re-initialise a non-empty data directory, so the" + echo " migration would run against stale credentials and stale data." + local remove + remove=$(read_yes_no " Remove it and continue?" "y") + if [[ "$remove" != "yes" ]]; then + echo "" > /dev/stderr + echo "Aborted. Remove it manually with: docker volume rm $PG_VOLUME_NAME" > /dev/stderr + exit 1 + fi + docker volume rm "$PG_VOLUME_NAME" > /dev/null + echo " Removed." +} + +# Undo whatever this run changed and start the previous deployment again. +rollback() { + ROLLBACK_STATE="done" + + echo "" + echo "──────────────────────────────────────────────────────────────────────" + echo " Migration failed — restoring the previous deployment" + echo "──────────────────────────────────────────────────────────────────────" + + # Resolve while the override is still present; without it Compose no longer + # knows about the Postgres volume. + local pg_volume="$PG_VOLUME_NAME" + if [[ -z "$pg_volume" ]] && [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + pg_volume=$(postgres_volume_name) + fi + + echo "" + echo "Stopping services ..." + $DOCKER_COMPOSE_COMMAND down || true + + echo "Removing generated files ..." + rm -f "$OVERRIDE_FILE" "$ENTERPRISE_CONFIG_FILE" + + # Restore .env to exactly what it was, or remove it if this run created it. + if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then + mv -f "$ENV_BACKUP" .env || echo " ⚠ Could not restore .env from $ENV_BACKUP." > /dev/stderr + elif [[ "$ENV_EXISTED" == "no" ]]; then + rm -f .env || true + fi + + # Only ever the volume this run created — never the NetBird data volume. + if [[ -n "$pg_volume" ]] && [[ "$pg_volume" != "null" ]]; then + echo "Removing Postgres volume $pg_volume ..." + docker volume rm "$pg_volume" &> /dev/null || true + fi + + echo "Starting the previous deployment ..." + if ! $DOCKER_COMPOSE_COMMAND up -d; then + echo "" + echo " ⚠ Could not start the previous deployment automatically." > /dev/stderr + echo " Run: $DOCKER_COMPOSE_COMMAND up -d" > /dev/stderr + fi + + echo "" + echo "Rolled back. Your docker-compose.yml, config.yaml and the NetBird data" + echo "volume were never modified." + if [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then + echo "The SQLite backup taken during this run is kept at:" + echo " $BACKUP_DIR" + fi + echo "──────────────────────────────────────────────────────────────────────" +} + +on_exit() { + local code=$? + trap - EXIT + if [[ $code -ne 0 ]] && [[ "$ROLLBACK_STATE" == "armed" ]]; then + rollback + fi + exit $code +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +# Already on Postgres: there is nothing to provision and nothing to migrate. +# The enterprise image reads the very same store config the community image +# did, so step 2 collapses to a no-op and the run is a plain image swap. +configure_existing_postgres() { + EXISTING_POSTGRES="yes" + MIGRATE_POSTGRES="no" + + # DSN first — detect_postgres_service prefers the host it names. + POSTGRES_DSN=$(detect_store_dsn) + if [[ -z "$POSTGRES_DSN" ]] || [[ "$POSTGRES_DSN" == "null" ]]; then + POSTGRES_DSN=$(detect_store_dsn_from_compose) + fi + if [[ "$POSTGRES_DSN" == "null" ]]; then + POSTGRES_DSN="" + fi + + POSTGRES_SERVICE=$(detect_postgres_service) + if [[ -n "$POSTGRES_SERVICE" ]]; then + POSTGRES_DEPENDS_CONDITION=$(detect_postgres_depends_condition) + fi + + echo "Step 2: Postgres migration not needed — this deployment already runs on" + echo " Postgres. Its store configuration is reused as-is and left" + echo " untouched; no database is created and no data is moved." + if [[ -n "$POSTGRES_SERVICE" ]]; then + echo " Postgres service: $POSTGRES_SERVICE (in $COMPOSE_FILE)" + else + echo " Postgres service: managed outside $COMPOSE_FILE" + fi +} + +configure_sqlite_store() { + MIGRATE_POSTGRES=$(read_yes_no "Step 2: Migrate storage from SQLite to Postgres? (recommended)" "n") + [[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0 + + # The override would otherwise merge into a service of the same name and + # quietly rewrite its image and credentials. + local existing + existing=$(yq eval '.services | has("postgres")' "$COMPOSE_FILE") + if [[ "$existing" == "true" ]]; then + echo "" > /dev/stderr + echo "$COMPOSE_FILE already defines a service named 'postgres', but config.yaml" > /dev/stderr + echo "still has server.store.engine: sqlite. This script would add its own" > /dev/stderr + echo "'postgres' service and Compose would merge the two." > /dev/stderr + echo "" > /dev/stderr + echo "Point server.store.engine at that Postgres yourself, or rename the service," > /dev/stderr + echo "then re-run." > /dev/stderr + exit 1 + fi + + 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." + return 0 + fi + + POSTGRES_PASSWORD=$(rand_password) + POSTGRES_SERVICE="postgres" + POSTGRES_DEPENDS_CONDITION="service_healthy" + POSTGRES_DSN="host=postgres user=netbird password=${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable" +} + +# mysql, or something this script has never seen. Swapping the images is still +# valid; touching the store is not. +configure_unsupported_store() { + MIGRATE_POSTGRES="no" + echo " ⚠ server.store.engine is '$STORE_ENGINE'. This script only migrates" + echo " SQLite to Postgres, and traffic flow requires Postgres, so both are" + echo " unavailable here. The store configuration will be left untouched." + echo "" + local proceed + proceed=$(read_yes_no "Step 2 skipped. Continue with the image swap only?" "n") + if [[ "$proceed" != "yes" ]]; then + echo "Aborted." + exit 0 + fi +} + +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 or ghcr.io/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 or ghcr.io/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 + + STORE_ENGINE=$(detect_store_engine) + + 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 " Store engine: $STORE_ENGINE" + echo "" + + require_eula_acceptance + NETBIRD_EULA_ACCEPTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) + + 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") + + # Step 2 — what this does depends on what the deployment already stores in. + echo "" + case "$STORE_ENGINE" in + postgres) configure_existing_postgres ;; + sqlite) configure_sqlite_store ;; + *) configure_unsupported_store ;; + esac + + # Step 3 — optional, only if Postgres is on (flow requires Postgres) + echo "" + if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$EXISTING_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 + + # flow-enricher talks to Postgres directly, so this is the one place an + # existing deployment's DSN is actually needed — and the one place a host + # that only works from inside the server container shows up. + while :; do + local dsn_problem="" + if [[ -z "$POSTGRES_DSN" ]]; then + dsn_problem="No DSN could be read from $CONFIG_YAML_HOST or from the $COMBINED_SERVICE environment." + elif ! dsn_host_reachable "$POSTGRES_DSN"; then + dsn_problem="Its host '$(dsn_host "$POSTGRES_DSN")' only resolves inside the server container." + fi + [[ -n "$dsn_problem" ]] || break + + echo "" + echo " The flow enricher reaches Postgres from a container of its own." + echo " $dsn_problem" + echo " Enter a DSN reachable from other containers, or press Ctrl-C to abort." + POSTGRES_DSN=$(read_required " Postgres DSN (host=… user=… password=… dbname=… port=5432 sslmode=disable)") + done + + # Only where the operator owns Postgres: a DSN entered above may name a + # different host. The sqlite path creates its own service, nothing to find. + if [[ "$EXISTING_POSTGRES" == "yes" ]]; then + POSTGRES_SERVICE=$(detect_postgres_service) + if [[ -n "$POSTGRES_SERVICE" ]]; then + POSTGRES_DEPENDS_CONDITION=$(detect_postgres_depends_condition) + fi + fi + fi + else + ENABLE_FLOW="no" + echo "Step 3 (traffic flow) skipped — requires Postgres." + fi + + # config.yaml.enterprise only exists to hold changes; without any there is + # nothing to generate and the server keeps running on its own config.yaml. + if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$ENABLE_FLOW" == "yes" ]]; then + ENTERPRISE_CONFIG="yes" + fi + + check_data_directory + check_stale_postgres_volume +} + +apply_changes() { + # From here on a failure must roll the deployment back. + ROLLBACK_STATE="armed" + + 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 [[ "$ENTERPRISE_CONFIG" == "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" + # Snapshot the operator's .env so a rollback can restore it byte for byte. + if [[ -f "$ENV_FILE" ]]; then + ENV_EXISTED="yes" + ENV_BACKUP="${ENV_FILE}.pre-enterprise-$(date +%Y%m%d-%H%M%S)" + cp -p "$ENV_FILE" "$ENV_BACKUP" + else + ENV_EXISTED="no" + fi + 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 "# NetBird On-Premise EULA accepted at install time" + echo "NETBIRD_EULA_ACCEPTED=yes" + echo "NETBIRD_EULA_ACCEPTED_AT=${NETBIRD_EULA_ACCEPTED_AT}" + echo "NETBIRD_EULA_URL=${NETBIRD_EULA_URL}" + 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 + # Own variable name rather than NB_STORE_ENGINE_POSTGRES_DSN so that a + # deployment already setting that one keeps its own value. + echo "NB_ENTERPRISE_POSTGRES_DSN=$(env_value "$POSTGRES_DSN")" + echo "NB_FLOW_AUTH_SECRET=${NB_FLOW_AUTH_SECRET}" + echo "NETBIRD_ENCRYPTION_KEY=${NETBIRD_ENCRYPTION_KEY}" + fi + } >> "$ENV_FILE" + + echo "" + echo "Pulling enterprise images ..." + $DOCKER_COMPOSE_COMMAND pull + + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + echo "" + # Stop, but keep the containers: the backup reads the store out of one. + echo "Stopping services so the store is quiescent ..." + $DOCKER_COMPOSE_COMMAND stop + + backup_sqlite + + echo "" + echo "Removing stopped containers (volumes preserved) ..." + $DOCKER_COMPOSE_COMMAND down + + 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." + + # Nothing left to undo. + ROLLBACK_STATE="disarmed" +} + +print_summary() { + echo "" + echo "──────────────────────────────────────────────────────────────────────" + echo " Summary" + echo "──────────────────────────────────────────────────────────────────────" + echo " Images: swapped to enterprise" + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + echo " Storage: Postgres (data migrated from SQLite)" + elif [[ "$EXISTING_POSTGRES" == "yes" ]]; then + echo " Storage: Postgres (pre-existing, configuration unchanged)" + else + echo " Storage: $STORE_ENGINE (unchanged)" + fi + [[ "$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" + [[ "$ENTERPRISE_CONFIG" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE" + echo " .env (license key + secrets, mode 600)" + [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]] && echo " $ENV_BACKUP (.env as it was before this run)" + [[ "$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 "──────────────────────────────────────────────────────────────────────" + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + # Resolve the project-prefixed volume name now, before the override is gone. + local pg_volume + pg_volume=$(resolve_compose_volume "netbird_postgres") + echo " # Stop, but keep the containers so the store can be copied back in:" + echo " $DOCKER_COMPOSE_COMMAND stop" + echo " # Restore SQLite from the backup created during this run:" + echo " docker cp ${BACKUP_DIR}/. \$($DOCKER_COMPOSE_COMMAND ps -aq $COMBINED_SERVICE):/var/lib/netbird/" + echo " $DOCKER_COMPOSE_COMMAND down" + echo " docker volume rm $pg_volume" + else + echo " $DOCKER_COMPOSE_COMMAND down" + fi + if [[ "$ENTERPRISE_CONFIG" == "yes" ]]; then + echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE" + else + echo " rm -f $OVERRIDE_FILE" + fi + if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then + echo " mv $ENV_BACKUP .env # restores .env as it was before this run" + elif [[ "$ENV_EXISTED" == "no" ]]; then + echo " rm -f .env # created by this run" + else + echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)" + fi + echo " $DOCKER_COMPOSE_COMMAND up -d" + echo "──────────────────────────────────────────────────────────────────────" +} + +# --------------------------------------------------------------------------- +# Run +# --------------------------------------------------------------------------- + +trap on_exit EXIT +# Turn signals into a normal exit so the EXIT trap can roll back. +trap 'exit 130' INT TERM + +init_migration +apply_changes +print_summary diff --git a/infrastructure_files/observability/grafana/dashboards/management-enterprise.json b/infrastructure_files/observability/grafana/dashboards/management-enterprise.json new file mode 100644 index 000000000..481050a02 --- /dev/null +++ b/infrastructure_files/observability/grafana/dashboards/management-enterprise.json @@ -0,0 +1,8857 @@ +{ + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "builtIn": true, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "query": { + "datasource": { + "name": "-- Grafana --" + }, + "group": "grafana", + "kind": "DataQuery", + "spec": {}, + "version": "v0" + } + } + }, + { + "kind": "AnnotationQuery", + "spec": { + "enable": true, + "hide": false, + "iconColor": "red", + "name": "Deployments", + "query": { + "datasource": { + "name": "grafana" + }, + "group": "datasource", + "kind": "DataQuery", + "spec": { + "expr": "", + "interval": "", + "refId": "Anno", + "tags": [], + "type": "tags" + }, + "version": "v0" + } + } + } + ], + "cursorSync": "Crosshair", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "histogram_quantile(0.99, sum by(le) (rate(management_grpc_updatechannel_queue_length_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "format": "table", + "fullMetaSearch": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "Connected grpc streams", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p99 of the update-channel queue length: how many network map updates are waiting to be delivered to a peer. The channel buffer is 100 messages - approaching it means updates start getting dropped and peers fall behind.", + "id": 1, + "links": [], + "title": "UpdateChannel Queue length max", + "vizConfig": { + "group": "stat", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": 0 + }, + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 50 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-10": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_http_request_duration_ms_total_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\", type=\"read\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "HTTP read duration", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 duration of read API requests (GET/OPTIONS), from the total request duration histogram tagged type=read.", + "id": 10, + "links": [], + "title": "Read HTTP API Requests p95 Duration (GET/OPTIONS)", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "orange", + "value": 200 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-11": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_updatechannel_close_one_duration_micro_microseconds_count{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])*60)", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "CloseOne", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum(rate(management_updatechannel_send_duration_micro_count{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "Send", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum(rate(management_updatechannel_create_duration_micro_count{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])*60)", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "Create", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "C" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_updatechannel_get_all_duration_micro_count{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])*60", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GetAll", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "D" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_updatechannel_haschannel_duration_micro_count{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])*60", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "HasChannel", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "E" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_updatechannel_close_multiple_channels_count{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])*60", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "CloseMultiple", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "F" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate per minute of update-channel operations: close one, send update, create, get all connected peers, has-channel and close multiple. Together they show how much channel churn an instance is handling.", + "id": 11, + "links": [], + "title": "Update Channel operations", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 69, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "reqpm" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-12": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": true, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_updatechannel_create_duration_micro_microseconds_count{job=~\"$job\", instance=~\"$instance\", closed=\"true\"}[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": true, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_updatechannel_create_duration_micro_microseconds_count{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "__expr__" + }, + "group": "__expr__", + "kind": "DataQuery", + "spec": { + "expression": "100-$A/$B*100", + "type": "math" + }, + "version": "v0" + }, + "refId": "C" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Tracks how often a new peer update channel replaces a channel that was still open (closed=true), i.e. peers reconnecting before management noticed the previous stream was gone. The plotted expression is 100 - recreated/total, so the line drops as recreations rise.", + "id": 12, + "links": [], + "title": "Percentage of Recreated channels", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMax": 100, + "axisSoftMin": 0, + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "percent" + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "C" + }, + "properties": [ + { + "id": "displayName", + "value": "Recreation" + } + ] + } + ] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-13": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_updatechannel_close_one_duration_micro_microseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "CloseOne", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_updatechannel_send_duration_micro_microseconds_bucket{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "Send", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_updatechannel_create_duration_micro_microseconds_bucket{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "Create", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "C" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_updatechannel_get_all_duration_micro_microseconds_bucket{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GetAll", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "D" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_updatechannel_haschannel_duration_micro_microseconds_bucket{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "CloseMultiple", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "E" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_updatechannel_close_multiple_channels_bucket{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "HasChannel", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "F" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 duration of each update-channel operation, in microseconds. These are in-memory operations behind a lock, so growth here means lock contention rather than I/O.", + "id": 13, + "links": [], + "title": "Update Channel methods p95 Duration", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "µs" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-14": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "sum(management_grpc_connected_streams_ratio{job=~\"$job\", instance=~\"$instance\"})", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "Connected grpc streams", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Total number of peers currently holding an open gRPC Sync stream across all management instances (management.grpc.connected.streams). A sudden drop means peers were disconnected and will come back as a Login/Sync reconnect storm.", + "id": 14, + "links": [], + "title": "Connected peers", + "vizConfig": { + "group": "stat", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": 0 + }, + { + "color": "red", + "value": 0 + }, + { + "color": "#EAB839", + "value": 8000 + }, + { + "color": "green", + "value": 11000 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": true, + "textMode": "auto", + "wideLayout": true + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-15": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "management_grpc_connected_streams_ratio{job=~\"$job\", instance=~\"$instance\"}", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "builder", + "expr": "sum by(instance) (management_grpc_connected_streams_ratio{job=~\"$job\", instance=~\"$instance\"})", + "instant": false, + "legendFormat": "Total", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Open peer Sync streams over time, in total per instance (A) and as raw series (B). Steps down mark instance restarts or LB rebalancing; the ramp back up is the reconnect wave.", + "id": 15, + "links": [], + "title": "Connected peers historical", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-16": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_grpc_sync_request_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GRPC Sync duration", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 duration of the Sync gRPC request - the time management needs to authenticate the peer, build its first network map and open the update channel. Primary latency SLI for peer connects.", + "id": 16, + "links": [], + "title": "gRPC Sync Request p95 Duration", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-17": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_grpc_login_request_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GRPC Login duration", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 duration of the Login gRPC request (peer authentication plus initial configuration and relay credentials). Rises when the IdP, the store or account locking is slow.", + "id": 17, + "links": [], + "title": "gRPC Login Request p95 Duration", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-18": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_account_get_peer_network_map_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of the time taken to build the network map returned to a single peer, in milliseconds.", + "id": 18, + "links": [], + "title": "GetPeerNetworkMap Latency", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-19": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "sum by(le) (increase(management_account_network_map_object_count_objects_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of the number of objects (peers, routes, firewall rules, DNS entries and so on) in the network maps sent to clients. Large maps make both calculation and serialization more expensive.", + "id": 19, + "links": [], + "title": "NetworkMap Objects", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 0, + "reverse": false, + "unit": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "sum by(environment) (rate(management_grpc_login_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "login", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "sum by(environment) (rate(management_grpc_sync_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "sync", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "sum by(environment) (rate(management_grpc_key_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "get key", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "C" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate of the three peer-facing gRPC calls: Login (authenticate, return initial config and relay credentials), Sync (open the update channel and receive network map updates) and GetServerKey (fetch the server WireGuard public key). Elevated Login/Sync rates usually mean peers are reconnecting in a loop.", + "id": 2, + "links": [], + "title": "gRPC Requests", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 51, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": true, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "text": {}, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-20": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_updatechannel_tosyncresponse_duration_micro_microseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of the time taken to convert a calculated network map into the gRPC SyncResponse sent to the peer, in microseconds.", + "id": 20, + "links": [], + "title": "To Sync Response", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "µs" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-23": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(le) (increase(management_grpc_sync_request_duration_p95_by_account_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of per-account p95 Sync durations - each sample is one account's p95, flushed every 60s. Shows whether slow Syncs are spread across the fleet or concentrated in a few accounts.", + "id": 23, + "links": [], + "title": "GRPC Sync Latency", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.6, + "fill": "dark-red", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Purples", + "steps": 128 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-24": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_grpc_login_request_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of Login gRPC request durations across all peers. The right tail is what a peer experiences on startup; samples beyond 7s are what the high-latency counters in the Debugging row track.", + "id": 24, + "links": [], + "title": "GRPC Login Latency", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Purples", + "steps": 128 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-25": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_grpc_login_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "expr": "sum by(environment) (rate(management_grpc_login_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "Total", + "range": true + }, + "version": "v0" + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate of Login requests, per instance (A) and in total (B). Login is the most expensive peer call (IdP plus store writes); a sustained high rate means clients are restarting or failing to hold their Sync stream.", + "id": 25, + "links": [], + "title": "gRPC Login Request rate", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-26": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_grpc_sync_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "expr": "sum by(environment) (rate(management_grpc_sync_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "Total", + "range": true + }, + "version": "v0" + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate of Sync requests, per instance (A) and in total (B). Every Sync opens or re-opens a peer update channel, so a spike here is a reconnect storm and predicts CPU spent on network map calculation.", + "id": 26, + "links": [], + "title": "gRPC Sync Request rate", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-27": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_grpc_updatechannel_queue_length_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of update-channel queue lengths across all peers. Mass moving to the right shows a growing backlog of undelivered network map updates.", + "id": 27, + "links": [], + "title": "Update Channel heat map", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Greens", + "steps": 73 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-29": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(endpoint) (rate(management_http_response_counter_total{job=~\"$job\", instance=~\"$instance\", method=\"GET\", code=~\"^2.+\"}[$__rate_interval])*60) > 10", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{endpoint}} > 10", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_http_response_counter_total{job=~\"$job\", instance=~\"$instance\", host=\"$host\", method=\"GET\", code=~\"^2.+\"}[$__rate_interval])*60)", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Total", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Successful (2xx) GET requests per minute broken down by endpoint, limited to endpoints above 10 rpm. Shows which read endpoints dashboard and API clients hit hardest.", + "id": 29, + "links": [], + "title": "HTTP Read request counter", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-30": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(endpoint) (rate(management_http_response_counter_total{job=~\"$job\", instance=~\"$instance\", method!~\"(GET|OPTIONS)\", code=~\"^2.+\"}[$__rate_interval])*60) > 1", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{endpoint}} > 1", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": true, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_http_response_counter_total{job=~\"$job\", instance=~\"$instance\", host=\"$host\", method!~\"(GET|OPTIONS)\", code=~\"^2.+\"}[$__rate_interval])*60)", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Total", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Successful (2xx) non-GET/OPTIONS requests per minute by endpoint, limited to endpoints above 1 rpm. Writes take account locks and fan out network map updates, so read this together with the Network Map row.", + "id": 30, + "links": [], + "title": "HTTP Write request counter", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqpm" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true, + "sortBy": "Last *", + "sortDesc": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-31": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_store_persistence_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of how long it takes to save or delete an account in the store (management.store.persistence.duration.ms). Widening buckets point at database write pressure.", + "id": 31, + "links": [], + "title": "Store Persistence latency", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.6, + "fill": "dark-red", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Reds", + "steps": 128 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-32": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_store_transaction_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of store transaction execution time. Nearly every API and gRPC call runs through a transaction, so this is the first place to look when latency rises everywhere at once.", + "id": 32, + "links": [], + "title": "Store Transaction latency", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.6, + "fill": "dark-red", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Reds", + "steps": 128 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-33": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_account_peer_meta_update_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])*60)", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{endpoint}} > 10", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate per minute of peers reporting changed metadata (OS, version, hostname, addresses). Each change persists the peer and can trigger an account peers update.", + "id": 33, + "links": [], + "title": "Peer meta updates counter", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqpm" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-34": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_grpc_sync_request_blocked_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GRPC Sync request rate", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate of Sync requests rejected because the peer is blocked (login expired, peer disabled or account restricted). A rising line means clients retrying without valid access.", + "id": 34, + "links": [], + "title": "gRPC Sync Request Blocked rate", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 0 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-35": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_grpc_login_request_blocked_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GRPC Login request rate", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate of Login requests rejected because the peer is blocked. Persistent volume here is usually expired peer logins retrying in a loop.", + "id": 35, + "links": [], + "title": "gRPC Login Request Blocked rate", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 0 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-36": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_account_update_account_peers_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of the time taken by an account peers update: preparing the data and pushing a fresh network map to every peer in the account, in milliseconds. Scales with account size.", + "id": 36, + "links": [], + "title": "UpdateAccountPeers Latency", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-37": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_updatechannel_calc_networkmap_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of the time taken to calculate one peer's network map, in milliseconds. This is the dominant CPU cost when many peers reconnect at once.", + "id": 37, + "links": [], + "title": "Network Map Calculation", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-39": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_updatechannel_calc_posturechecks_duration_micro_microseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of the time taken to evaluate a peer's posture checks while building its network map, in microseconds.", + "id": 39, + "links": [], + "title": "Posture Check Calculation", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Oranges", + "steps": 64 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "µs" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-45": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "exemplar": false, + "expr": "sort_desc(\n topk(\n 10,\n sum by (account_id) (\n increase(management_grpc_login_request_high_latency_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__interval])\n )\n )\n)", + "format": "table", + "instant": true, + "legendFormat": "__auto", + "range": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [ + { + "group": "organize", + "kind": "Transformation", + "spec": { + "options": { + "excludeByName": { + "Time": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": { + "Time": "" + } + } + } + } + ] + } + }, + "description": "Top 10 accounts by number of Login requests that exceeded the 7s high-latency threshold.", + "id": 45, + "links": [], + "title": "High Login Latency Ranking (login count per account)", + "vizConfig": { + "group": "table", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "footer": { + "reducers": [] + }, + "inspect": false, + "tooltip": { + "placement": "auto" + }, + "wrapHeaderText": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "cellHeight": "sm", + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Time" + } + ] + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-46": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "exemplar": false, + "expr": "sort_desc(\n topk(\n 10,\n sum by (account_id) (\n increase(management_grpc_sync_request_high_latency_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__interval])\n )\n )\n)", + "format": "table", + "instant": true, + "legendFormat": "__auto", + "range": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [ + { + "group": "organize", + "kind": "Transformation", + "spec": { + "options": { + "excludeByName": { + "Time": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": { + "Time": "" + } + } + } + } + ] + } + }, + "description": "Top 10 accounts by number of Sync requests that exceeded the 7s high-latency threshold. Use it to find which tenant is behind a latency spike.", + "id": 46, + "links": [], + "title": "High Sync Latency Ranking (sync count per account)", + "vizConfig": { + "group": "table", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "footer": { + "reducers": [] + }, + "inspect": false, + "tooltip": { + "placement": "auto" + }, + "wrapHeaderText": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "cellHeight": "sm", + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Time" + } + ] + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-47": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "expr": "count(\n sum by (account_id) (\n increase(management_grpc_sync_request_high_latency_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__interval])\n ) > 1\n)", + "legendFormat": "__auto", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Number of distinct accounts with more than one slow (>7s) Sync in the interval - tells you whether a latency spike is fleet-wide or limited to a few tenants.", + "id": 47, + "links": [], + "title": "Accounts with high Sync latency", + "vizConfig": { + "group": "gauge", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "barShape": "flat", + "barWidthFactor": 0.5, + "effects": { + "barGlow": false, + "centerGlow": false, + "gradient": false + }, + "endpointMarker": "point", + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "segmentCount": 1, + "segmentSpacing": 0.3, + "shape": "gauge", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto", + "sparkline": false, + "textMode": "auto" + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-48": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "expr": "count(\n sum by (account_id) (\n increase(management_grpc_login_request_high_latency_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__interval])\n ) > 1\n)", + "legendFormat": "__auto", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Number of distinct accounts with more than one slow (>7s) Login in the interval.", + "id": 48, + "links": [], + "title": "Accounts with high Login latency", + "vizConfig": { + "group": "gauge", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "barShape": "flat", + "barWidthFactor": 0.5, + "effects": { + "barGlow": false, + "centerGlow": false, + "gradient": false + }, + "endpointMarker": "point", + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "segmentCount": 1, + "segmentSpacing": 0.3, + "shape": "gauge", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto", + "sparkline": false, + "textMode": "auto" + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-50": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${logs_datasource}" + }, + "group": "loki", + "kind": "DataQuery", + "spec": { + "direction": "backward", + "editorMode": "code", + "expr": "sort_desc(\ntopk(\n 20,\n sum by (peerID) (\n count_over_time(\n {job=\"$log_job\"} |= \"Sync took\" | regexp `peerID:\\s(?P[^,\\]]+)` [$__range]\n )\n )\n)\n)", + "queryType": "instant" + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [ + { + "group": "organize", + "kind": "Transformation", + "spec": { + "options": { + "excludeByName": { + "Time": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": { + "Value #A": "number of times ", + "peerID": "peerPubKey" + } + } + } + } + ] + } + }, + "description": "Top 20 peers by number of Sync log lines in the selected range, parsed out of the management logs to find individual peers stuck in a reconnect loop. Pick the log stream with the `log stream` variable; the query matches the `Sync took` debug line, so management must run at debug level for this panel to have data.", + "id": 50, + "links": [], + "title": "Highest Syncs by peer key", + "vizConfig": { + "group": "table", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "footer": { + "reducers": [] + }, + "inspect": false, + "tooltip": { + "placement": "auto" + }, + "wrapHeaderText": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "labels" + }, + "properties": [ + { + "id": "custom.width", + "value": 180 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "peerPubKey" + }, + "properties": [ + { + "id": "custom.width", + "value": 455 + } + ] + } + ] + }, + "options": { + "cellHeight": "sm", + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "number of times " + } + ] + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-52": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "builder", + "expr": "wsproxy_active_connections{job=~\"$job\", instance=~\"$instance\"}", + "legendFormat": "{{__name__}}", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Currently open WebSocket proxy connections. The wsproxy fronts gRPC for browser-based clients, so this is the number of connected web clients.", + "id": 52, + "links": [], + "title": "Proxy Active Connections", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-53": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "builder", + "expr": "delta(wsproxy_bytes_transferred_total{job=~\"$job\", instance=~\"$instance\"}[$__interval])", + "legendFormat": "{{direction}}", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Bytes transferred through the WebSocket proxy per interval, split by direction.", + "id": 53, + "links": [], + "title": "Proxy Traffic by Direction", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-54": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "builder", + "expr": "increase(wsproxy_errors_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])", + "legendFormat": "{{error_type}}", + "range": true + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "WebSocket proxy errors grouped by error type - dial failures, read/write errors and protocol issues on browser client connections.", + "id": 54, + "links": [], + "title": "Errors by Type", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-65": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "builder", + "exemplar": false, + "expr": "management_grpc_connected_streams_ratio{job=~\"$job\", instance=~\"$instance\"}", + "instant": true, + "legendFormat": "{{host}}", + "range": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Open peer Sync streams per management instance. Use it to confirm the load balancer spreads streams evenly - a skewed instance carries proportionally more update-channel and network map work.", + "id": 65, + "links": [], + "title": "Connected Peers per Server", + "vizConfig": { + "group": "gauge", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "barShape": "flat", + "barWidthFactor": 0.5, + "effects": { + "barGlow": false, + "centerGlow": false, + "gradient": false + }, + "endpointMarker": "point", + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "segmentCount": 1, + "segmentSpacing": 0.3, + "shape": "gauge", + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto", + "sparkline": false, + "textMode": "auto" + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-66": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(host) (rate(management_http_response_counter_total{job=~\"$job\", instance=~\"$instance\", method=\"GET\", code=~\"^2.+\"}[$__rate_interval]) * 60)", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Successful GET requests per minute grouped by management instance - read load distribution across the fleet.", + "id": 66, + "links": [], + "title": "HTTP Read request counter", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-67": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(host) (rate(management_http_response_counter_total{job=~\"$job\", instance=~\"$instance\", method!~\"(GET|OPTIONS)\", code=~\"^2.+\"}[$__rate_interval])*60)", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Successful write requests per minute grouped by management instance - write load distribution across the fleet.", + "id": 67, + "links": [], + "title": "HTTP Write request counter", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 0, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "fieldMinMax": false, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqpm" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true, + "sortBy": "Last *", + "sortDesc": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-7": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_http_request_duration_ms_total_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\", type=\"write\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "HTTP write duration", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 duration of write API requests (PUT/POST/DELETE), tagged type=write. Writes acquire account locks and trigger peer updates, so they are normally slower than reads.", + "id": 7, + "links": [], + "title": "Write HTTP API Requests p95 Duration (PUT/POST/DELETE)", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-74": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(le) (increase(management_pat_usage_distribution_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Distribution of personal access token usage counts, sampled once per minute per token. Mass in the high buckets means a single PAT is hammering the API.", + "id": 74, + "links": [], + "title": "PAT usage counter", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Purples", + "steps": 128 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-76": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "gnatsd_connz_subscriptions{job=~\"$job\", instance=~\"$instance\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": true, + "legendFormat": "{{host}}", + "range": false, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Current subscriptions held by each client connected to NATS (connz). Signal instances subscribe per connected peer, so this tracks how peers are spread across signal nodes.", + "id": 76, + "links": [], + "title": "Subscriptions per node", + "vizConfig": { + "group": "stat", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-77": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "gnatsd_connz_subscriptions{job=~\"$job\", instance=~\"$instance\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Subscriptions per connected NATS client over time. Steps mark signal instances restarting and re-subscribing their peers.", + "id": 77, + "links": [], + "title": "Subscriptions per node", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-78": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "gnatsd_connz_num_connections{job=~\"$job\", instance=~\"$instance\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Number of client connections on each NATS node - effectively how many signal instances are attached to that node.", + "id": 78, + "links": [], + "title": "Signal nodes connected per NATs", + "vizConfig": { + "group": "stat", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-79": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(environment) (rate(gnatsd_connz_out_msgs{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate of messages NATS delivers out to its clients: the peer signalling throughput flowing through the cluster.", + "id": 79, + "links": [], + "title": "Messages per Second", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "mps" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_http_request_duration_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "HTTP request duration", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 duration of incoming REST API requests by endpoint and method (management.http.request.duration.ms). Covers dashboard and API clients only - peer traffic is on the gRPC panels.", + "id": 8, + "links": [], + "title": "HTTP API Requests p95 Duration", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-80": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(environment) (rate(gnatsd_connz_out_bytes{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Rate of bytes NATS delivers out to its clients - signalling bandwidth, useful next to the message rate to spot unusually large payloads.", + "id": 80, + "links": [], + "title": "Data per Second", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-81": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(environment) (increase(gnatsd_varz_slow_consumers{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Increase of the NATS slow-consumer counter. Anything above zero means a signal node could not read fast enough and NATS dropped its connection, so peers lose signalling until it reconnects.", + "id": 81, + "links": [], + "title": "Slow consumers detected", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-82": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "gnatsd_varz_subscriptions{job=~\"$job\", instance=~\"$instance\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{host}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Total subscriptions registered on each NATS server (varz). Grows roughly with the number of connected peers.", + "id": 82, + "links": [], + "title": "Total number of NATS subscriptions", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-83": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "process_resident_memory_bytes{job=~\"$job\", instance=~\"$instance\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{instance}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Resident memory of the scraped processes. process_resident_memory_bytes is exposed by every Go/Prometheus process, so narrow the job and instance variables to the NATS nodes to read this panel as NATS memory.", + "id": 83, + "links": [], + "title": "NATS memory consumption", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "max", + "min" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-84": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "idelta(management_grpc_sync_request_duration_p95_by_account_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[2m])", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Per-account Sync p95 samples as a heatmap (2m deltas). Use it to spot a handful of large or slow accounts pulling the overall Sync latency up.", + "id": 84, + "links": [], + "title": "GRPC Sync Latency By Account", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.6, + "fill": "dark-red", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Purples", + "steps": 128 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-85": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "idelta(management_grpc_login_request_duration_p95_by_account_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[2m])", + "format": "heatmap", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Per-account Login p95 samples as a heatmap (2m deltas). Highlights accounts whose logins are much slower than the fleet average.", + "id": 85, + "links": [], + "title": "GRPC Login Latency By Account", + "vizConfig": { + "group": "heatmap", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "scaleDistribution": { + "type": "linear" + } + } + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "calculate": false, + "cellGap": 1, + "color": { + "exponent": 0.5, + "fill": "dark-orange", + "mode": "scheme", + "reverse": false, + "scale": "exponential", + "scheme": "Purples", + "steps": 128 + }, + "exemplars": { + "color": "rgba(255,0,255,0.7)" + }, + "filterValues": { + "le": 1e-09 + }, + "legend": { + "placement": "bottom", + "show": true + }, + "rowsFrame": { + "layout": "auto" + }, + "tooltip": { + "mode": "single", + "showColorScale": false, + "yHistogram": false + }, + "yAxis": { + "axisPlacement": "left", + "axisWidth": 60, + "decimals": 1, + "reverse": false, + "unit": "ms" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-86": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_grpc_sync_request_duration_p95_by_account_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GRPC Sync duration", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 of the per-account Sync p95 distribution - the latency seen by the worst-served accounts rather than by the average request. Diverges from the global p95 when only a few accounts are hurting.", + "id": 86, + "links": [], + "title": "gRPC Sync Request p95 Duration (by Account)", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 500 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-87": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by(le) (rate(management_grpc_login_request_duration_p95_by_account_ms_milliseconds_bucket{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "GRPC Login duration", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "p95 of the per-account Login p95 distribution - login latency for the worst-served accounts rather than the average request.", + "id": 87, + "links": [], + "title": "gRPC Login Request p95 Duration (by Account)", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 32, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-88": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "sum by(environment) (increase(management_account_update_account_peers_counter_total{job=~\"$job\", instance=~\"$instance\"}[2m])) / 2", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "{{environment}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Account peers updates triggered per minute (2m increase halved). Each trigger recalculates and pushes network maps to every peer in the account.", + "id": 88, + "links": [], + "title": "NetworkMap Triggers Total", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-BlYlRd", + "seriesBy": "last" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 19, + "gradientMode": "scheme", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "cpm" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-89": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "sum by(resource) (increase(management_account_update_account_peers_counter_total{job=~\"$job\", instance=~\"$instance\"}[2m]))", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "__auto", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Account peers updates over 2m intervals, grouped by the resource that triggered them - the time series behind the pie chart above.", + "id": 89, + "links": [], + "title": "NetworkMap Triggers By Source", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic", + "seriesBy": "max" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.9, + "drawStyle": "line", + "fillOpacity": 50, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 2, + "pointSize": 1, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "always", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "cpm" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-9": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "sum by(environment) (rate(management_idp_authenticate_request_counter_total{job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])*60)", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "authenticate (mgtm->auth0)", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_idp_update_user_meta_counter_total{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$interval])*60", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "update user meta", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "B" + } + }, + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "expr": "rate(management_idp_get_account_counter_total{job=~\"$job\", instance=~\"$instance\", host=\"$host\"}[$__rate_interval])*60", + "fullMetaSearch": false, + "includeNullMetadata": false, + "legendFormat": "get account", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "C" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Requests per minute that management sends to the configured identity provider: authenticate (service token refresh), user metadata updates and account lookups. IdP throttling shows up here before it shows up as Login latency.", + "id": 9, + "links": [], + "title": "IdP Requests", + "vizConfig": { + "group": "barchart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 69, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.97, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [ + "lastNotNull", + "min", + "mean", + "max" + ], + "displayMode": "table", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "orientation": "auto", + "showValue": "never", + "stacking": "normal", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 100 + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-90": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "code", + "exemplar": false, + "expr": "sum by(resource) (increase(management_account_update_account_peers_counter_total{job=~\"$job\", instance=~\"$instance\"}[1h]))", + "instant": true, + "legendFormat": "__auto", + "range": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Account peers updates over the last hour, grouped by the resource that triggered them (policy, group, peer, route, user, network, ...). Shows which kind of configuration change generates the most fan-out work.", + "id": 90, + "links": [], + "title": "NetworkMap Triggers by source in last hour", + "vizConfig": { + "group": "piechart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "#73BF69", + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + } + }, + "overrides": [] + }, + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "pieType": "donut", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "sort": "desc", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-91": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "editorMode": "builder", + "exemplar": false, + "expr": "sum by(resource) (increase(management_network_map_counter_total{job=~\"$job\", instance=~\"$instance\"}[1h]))", + "instant": true, + "legendFormat": "__auto", + "range": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Network maps computed in the last hour, grouped by the resource whose change triggered the computation.", + "id": 91, + "links": [], + "title": "NetworkMaps by source in last hour", + "vizConfig": { + "group": "piechart", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "#73BF69", + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + } + }, + "overrides": [] + }, + "options": { + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "pieType": "donut", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "sort": "desc", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-92": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "sum by(environment) (increase(management_network_map_counter_total{job=~\"$job\", instance=~\"$instance\"}[2m])) / 2", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "{{environment}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Network maps computed per minute (2m increase halved). One account peers update fans out into one map per peer, so this - not the trigger count - is the real CPU driver.", + "id": 92, + "links": [], + "title": "NetworkMaps Total", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-BlYlRd", + "seriesBy": "last" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 19, + "gradientMode": "scheme", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "cpm" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + }, + "panel-93": { + "kind": "Panel", + "spec": { + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "hidden": false, + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "disableTextWrap": false, + "editorMode": "code", + "exemplar": false, + "expr": "sum by(resource) (increase(management_network_map_counter_total{job=~\"$job\", instance=~\"$instance\"}[2m])) / 2", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "{{resource}}", + "range": true, + "useBackend": false + }, + "version": "v0" + }, + "refId": "A" + } + } + ], + "queryOptions": {}, + "transformations": [] + } + }, + "description": "Network maps computed per minute, grouped by the resource that triggered the computation.", + "id": 93, + "links": [], + "title": "NetworkMaps Total By Source", + "vizConfig": { + "group": "timeseries", + "kind": "VizConfig", + "spec": { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic", + "seriesBy": "max" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.9, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 1, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "always", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "cpm" + }, + "overrides": [] + }, + "options": { + "annotations": { + "clustering": -1, + "multiLane": false + }, + "legend": { + "calcs": [], + "displayMode": "list", + "enableFacetedFilter": false, + "overflow": "ellipsis", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + } + }, + "version": "13.2.0-30616302309" + } + } + } + }, + "layout": { + "kind": "RowsLayout", + "spec": { + "rows": [ + { + "kind": "RowsLayoutRow", + "spec": { + "collapse": false, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-14" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-65" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-15" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 8 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-2" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 8 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-23" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 16 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-24" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 16 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-16" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 24 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-17" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 24 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-84" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 32 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-85" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 32 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-86" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 40 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-87" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 40 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-26" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 48 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-25" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 48 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-34" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 56 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-35" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 56 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-31" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 64 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-32" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 64 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-9" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 72 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-8" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 72 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-29" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 80 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-30" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 80 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-66" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 88 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-67" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 88 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-10" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 96 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-7" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 96 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-33" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 104 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-74" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 104 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-11" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 112 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-13" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 112 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-12" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 120 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-1" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 120 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-27" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 128 + } + } + ] + } + }, + "title": "General" + } + }, + { + "kind": "RowsLayoutRow", + "spec": { + "collapse": true, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-76" + }, + "height": 8, + "width": 24, + "x": 0, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-77" + }, + "height": 8, + "width": 24, + "x": 0, + "y": 8 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-78" + }, + "height": 8, + "width": 24, + "x": 0, + "y": 16 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-79" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 24 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-80" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 24 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-81" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 32 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-82" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 32 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-83" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 40 + } + } + ] + } + }, + "title": "NATS" + } + }, + { + "kind": "RowsLayoutRow", + "spec": { + "collapse": true, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-46" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-45" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-47" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 8 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-48" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 8 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-50" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 16 + } + } + ] + } + }, + "title": "Debugging" + } + }, + { + "kind": "RowsLayoutRow", + "spec": { + "collapse": true, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-37" + }, + "height": 8, + "width": 23, + "x": 0, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-20" + }, + "height": 8, + "width": 23, + "x": 0, + "y": 8 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-39" + }, + "height": 8, + "width": 23, + "x": 0, + "y": 16 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-36" + }, + "height": 8, + "width": 23, + "x": 0, + "y": 24 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-18" + }, + "height": 8, + "width": 23, + "x": 0, + "y": 32 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-19" + }, + "height": 8, + "width": 23, + "x": 0, + "y": 40 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-90" + }, + "height": 19, + "width": 6, + "x": 0, + "y": 48 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-88" + }, + "height": 10, + "width": 17, + "x": 6, + "y": 48 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-89" + }, + "height": 9, + "width": 17, + "x": 6, + "y": 58 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-91" + }, + "height": 19, + "width": 6, + "x": 0, + "y": 67 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-92" + }, + "height": 10, + "width": 17, + "x": 6, + "y": 67 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-93" + }, + "height": 9, + "width": 17, + "x": 6, + "y": 77 + } + } + ] + } + }, + "title": "Network Map" + } + }, + { + "kind": "RowsLayoutRow", + "spec": { + "collapse": true, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-52" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-53" + }, + "height": 8, + "width": 12, + "x": 12, + "y": 0 + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-54" + }, + "height": 8, + "width": 12, + "x": 0, + "y": 8 + } + } + ] + } + }, + "title": "Browser Client" + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "autoRefresh": "auto", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "fiscalYearStartMonth": 0, + "from": "now-1h", + "hideTimepicker": false, + "timezone": "browser", + "to": "now" + }, + "title": "Management generic", + "variables": [ + { + "kind": "DatasourceVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "", + "value": "" + }, + "description": "Prometheus data source scraping the management metrics.", + "hide": "dontHide", + "includeAll": false, + "label": "Data source", + "multi": false, + "name": "datasource", + "options": [], + "pluginId": "prometheus", + "refresh": "onDashboardLoad", + "regex": "", + "skipUrlSync": false + } + }, + { + "kind": "DatasourceVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "", + "value": "" + }, + "description": "Loki data source holding the management logs.", + "hide": "dontHide", + "includeAll": false, + "label": "Logs data source", + "multi": false, + "name": "logs_datasource", + "options": [], + "pluginId": "loki", + "refresh": "onDashboardLoad", + "regex": "", + "skipUrlSync": false + } + }, + { + "kind": "QueryVariable", + "spec": { + "allValue": ".*", + "allowCustomValue": true, + "current": { + "text": "All", + "value": [ + "$__all" + ] + }, + "definition": "label_values(job)", + "description": "Prometheus scrape job(s) exposing the management metrics.", + "hide": "dontHide", + "includeAll": true, + "label": "job", + "multi": true, + "name": "job", + "options": [], + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "qryType": 1, + "query": "label_values(job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "version": "v0" + }, + "refresh": "onDashboardLoad", + "regex": "", + "regexApplyTo": "value", + "skipUrlSync": false, + "sort": "alphabeticalAsc" + } + }, + { + "kind": "QueryVariable", + "spec": { + "allValue": ".*", + "allowCustomValue": true, + "current": { + "text": "All", + "value": [ + "$__all" + ] + }, + "definition": "label_values(up{job=~\"$job\"},instance)", + "description": "Instance(s) of the selected job(s). Depends on the job variable.", + "hide": "dontHide", + "includeAll": true, + "label": "instance", + "multi": true, + "name": "instance", + "options": [], + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "qryType": 1, + "query": "label_values(up{job=~\"$job\"},instance)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "version": "v0" + }, + "refresh": "onDashboardLoad", + "regex": "", + "regexApplyTo": "value", + "skipUrlSync": false, + "sort": "alphabeticalAsc" + } + }, + { + "kind": "QueryVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "", + "value": "" + }, + "definition": "label_values(job)", + "description": "Loki stream carrying the management logs (adjust the label if your setup does not use `job`).", + "hide": "dontHide", + "includeAll": false, + "label": "log stream", + "multi": false, + "name": "log_job", + "options": [], + "query": { + "datasource": { + "name": "${logs_datasource}" + }, + "group": "loki", + "kind": "DataQuery", + "spec": { + "label": "job", + "refId": "LokiVariableQueryEditor-VariableQuery", + "type": 1 + }, + "version": "v0" + }, + "refresh": "onDashboardLoad", + "regex": "", + "regexApplyTo": "value", + "skipUrlSync": false, + "sort": "alphabeticalAsc" + } + }, + { + "kind": "CustomVariable", + "spec": { + "allValue": "5m", + "allowCustomValue": true, + "current": { + "text": "60s", + "value": "60s" + }, + "hide": "dontHide", + "includeAll": false, + "label": "interval", + "multi": false, + "name": "interval", + "options": [], + "query": "5m,60s", + "skipUrlSync": false, + "valuesFormat": "csv" + } + }, + { + "kind": "QueryVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "All", + "value": "$__all" + }, + "definition": "label_values(management_store_query_count_total,method)", + "hide": "hideVariable", + "includeAll": true, + "multi": true, + "name": "method", + "options": [], + "query": { + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "qryType": 1, + "query": "label_values(management_store_query_count_total,method)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "version": "v0" + }, + "refresh": "onDashboardLoad", + "regex": "", + "regexApplyTo": "value", + "skipUrlSync": false, + "sort": "disabled" + } + }, + { + "kind": "QueryVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "All", + "value": [ + "$__all" + ] + }, + "definition": "label_values(aws_rds_cpuutilization_average,dimension_DBClusterIdentifier)", + "hide": "hideVariable", + "includeAll": true, + "multi": true, + "name": "dimension_DBClusterIdentifier", + "options": [], + "query": { + "datasource": { + "name": "${datasource}" + }, + "group": "prometheus", + "kind": "DataQuery", + "spec": { + "qryType": 1, + "query": "label_values(aws_rds_cpuutilization_average,dimension_DBClusterIdentifier)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "version": "v0" + }, + "refresh": "onDashboardLoad", + "regex": "", + "regexApplyTo": "value", + "skipUrlSync": false, + "sort": "disabled" + } + } + ] +} diff --git a/management/cmd/admin.go b/management/cmd/admin.go new file mode 100644 index 000000000..e5c0f6ac9 --- /dev/null +++ b/management/cmd/admin.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "context" + "fmt" + "path" + "path/filepath" + + "github.com/dexidp/dex/storage" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/formatter/hook" + admincmd "github.com/netbirdio/netbird/management/cmd/admin" + tokencmd "github.com/netbirdio/netbird/management/cmd/token" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/activity" + activitystore "github.com/netbirdio/netbird/management/server/activity/store" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/util" +) + +var adminDatadir string + +// newAdminCommands creates the admin command tree with management-specific resource openers. +func newAdminCommands() *cobra.Command { + cmd := admincmd.NewCommands(admincmd.Openers{ + Resources: withAdminResources, + Store: withAdminStoreOnly, + IDP: withAdminIDPOnly, + }) + cmd.PersistentFlags().StringVar(&adminDatadir, "datadir", "", "Override the data directory from config (used for store.db and the default idp.db)") + return cmd +} + +func newLegacyTokenCommand() *cobra.Command { + cmd := tokencmd.NewCommands(tokencmd.StoreOpener(withAdminStoreOnly)) + cmd.Deprecated = "use 'admin token' instead" + cmd.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location") + return cmd +} + +// withAdminResources initializes logging, loads config, opens the management store +// and embedded IdP storage, and calls fn. +func withAdminResources(cmd *cobra.Command, fn func(ctx context.Context, resources admincmd.Resources) error) error { + return withAdminConfig(cmd, true, func(ctx context.Context, config *nbconfig.Config, datadir string) error { + managementStore, err := openAdminStore(ctx, config, datadir) + if err != nil { + return err + } + defer admincmd.CloseStore(ctx, managementStore) + + idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(config) + if err != nil { + return err + } + defer admincmd.CloseIDPStorage(idpStorage) + + eventStore, esErr := openAdminEventStore(ctx, config, datadir) + if esErr != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: audit events will not be recorded: %v\n", esErr) + } + if eventStore != nil { + defer func() { + if err := eventStore.Close(ctx); err != nil { + log.Debugf("close activity event store: %v", err) + } + }() + } + + return fn(ctx, admincmd.Resources{Store: managementStore, IDPStorage: idpStorage, IDPStorageFile: idpStorageFile, EventStore: eventStore}) + }) +} + +// withAdminStoreOnly opens only the management store for admin subcommands that do not +// need embedded IdP storage. +func withAdminStoreOnly(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { + return withAdminConfig(cmd, false, func(ctx context.Context, config *nbconfig.Config, datadir string) error { + managementStore, err := openAdminStore(ctx, config, datadir) + if err != nil { + return err + } + defer admincmd.CloseStore(ctx, managementStore) + + return fn(ctx, managementStore) + }) +} + +func withAdminIDPOnly(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error { + return withAdminConfig(cmd, true, func(ctx context.Context, config *nbconfig.Config, _ string) error { + idpStorage, idpStorageFile, err := admincmd.OpenIDPStorage(config) + if err != nil { + return err + } + defer admincmd.CloseIDPStorage(idpStorage) + + return fn(ctx, idpStorage, idpStorageFile) + }) +} + +func withAdminConfig(cmd *cobra.Command, applyIDPDefaults bool, fn func(ctx context.Context, config *nbconfig.Config, datadir string) error) error { + if err := util.InitLog("error", "console"); err != nil { + return fmt.Errorf("init log: %w", err) + } + + ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck + + config, datadir, err := loadAdminMgmtConfig(ctx, applyIDPDefaults) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + return fn(ctx, config, datadir) +} + +func loadAdminMgmtConfig(ctx context.Context, applyIDPDefaults bool) (*nbconfig.Config, string, error) { + config := &nbconfig.Config{} + if _, err := util.ReadJsonWithEnvSub(nbconfig.MgmtConfigPath, config); err != nil { + return nil, "", err + } + + if applyIDPDefaults { + if err := ApplyEmbeddedIdPConfig(ctx, config); err != nil { + return nil, "", err + } + } + + datadir := config.Datadir + applyAdminDatadirOverride(config, &datadir) + return config, datadir, nil +} + +func applyAdminDatadirOverride(config *nbconfig.Config, datadir *string) { + if adminDatadir == "" { + return + } + + oldDatadir := *datadir + *datadir = adminDatadir + if config.EmbeddedIdP != nil && config.EmbeddedIdP.Storage.Type == "sqlite3" && isDefaultIDPStorageFile(config.EmbeddedIdP.Storage.Config.File, oldDatadir) { + config.EmbeddedIdP.Storage.Config.File = filepath.Join(*datadir, "idp.db") + } +} + +func isDefaultIDPStorageFile(file, datadir string) bool { + if file == "" { + return true + } + defaultFile := filepath.Join(datadir, "idp.db") + legacyDefaultFile := path.Join(datadir, "idp.db") + legacySlashDefaultFile := path.Join(filepath.ToSlash(datadir), "idp.db") + return filepath.Clean(file) == filepath.Clean(defaultFile) || + file == legacyDefaultFile || + filepath.ToSlash(file) == legacySlashDefaultFile +} + +func openAdminStore(ctx context.Context, config *nbconfig.Config, datadir string) (store.Store, error) { + managementStore, err := store.NewStore(ctx, config.StoreConfig.Engine, datadir, nil, true) + if err != nil { + return nil, fmt.Errorf("create store: %w", err) + } + return managementStore, nil +} + +func openAdminEventStore(ctx context.Context, config *nbconfig.Config, datadir string) (activity.Store, error) { + if config.DataStoreEncryptionKey == "" { + return nil, fmt.Errorf("data store encryption key is not configured") + } + eventStore, err := activitystore.NewSqlStore(ctx, datadir, config.DataStoreEncryptionKey) + if err != nil { + return nil, fmt.Errorf("open activity event store: %w", err) + } + if eventStore == nil { + return nil, fmt.Errorf("open activity event store: returned nil store") + } + return eventStore, nil +} diff --git a/management/cmd/admin/admin.go b/management/cmd/admin/admin.go new file mode 100644 index 000000000..bd56af39b --- /dev/null +++ b/management/cmd/admin/admin.go @@ -0,0 +1,577 @@ +// Package admincmd provides reusable cobra commands for self-hosted administrator helpers. +// Both the management and combined binaries use these commands, each providing +// their own opener to handle config loading and storage initialization. +package admincmd + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "strings" + "time" + + "github.com/dexidp/dex/storage" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "golang.org/x/crypto/bcrypt" + + "github.com/netbirdio/netbird/formatter/hook" + nbdex "github.com/netbirdio/netbird/idp/dex" + "github.com/netbirdio/netbird/management/cmd/proxy" + "github.com/netbirdio/netbird/management/cmd/token" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +// Resources contains the storages required by the admin commands. +type Resources struct { + Store store.Store + IDPStorage storage.Storage + IDPStorageFile string + EventStore activity.Store +} + +// Opener initializes command resources from the command context and calls fn. +type Opener func(cmd *cobra.Command, fn func(ctx context.Context, resources Resources) error) error + +// StoreOpener initializes only the management store from the command context and calls fn. +type StoreOpener func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error + +// IDPOpener initializes only the embedded IdP storage from the command context and calls fn. +type IDPOpener func(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error + +// Openers contains the resource openers needed by the admin command tree. +type Openers struct { + Resources Opener + Store StoreOpener + IDP IDPOpener +} + +type userSelector struct { + email string + userID string +} + +func (s userSelector) normalized() userSelector { + return userSelector{ + email: strings.TrimSpace(s.email), + userID: strings.TrimSpace(s.userID), + } +} + +func (s userSelector) validate() error { + s = s.normalized() + if (s.email == "") == (s.userID == "") { + return fmt.Errorf("provide exactly one of --email or --user-id") + } + return nil +} + +// NewCommands creates the admin command tree with the given resource openers. +func NewCommands(openers Openers) *cobra.Command { + adminCmd := &cobra.Command{ + Use: "admin", + Short: "Self-hosted administrator helpers", + Long: "Administrative helpers for self-hosted deployments using the embedded identity provider.", + } + + userCmd := &cobra.Command{ + Use: "user", + Short: "Manage local embedded IdP users", + } + + var passwordSelector userSelector + var password string + var passwordFile string + passwordCmd := &cobra.Command{ + Use: "change-password (--email email | --user-id id) (--password password | --password-file path)", + Aliases: []string{"set-password"}, + Short: "Change a local user's password", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := passwordSelector.validate(); err != nil { + return err + } + newPassword, err := resolvePasswordInput(cmd, password, passwordFile) + if err != nil { + return err + } + return openers.IDP(cmd, func(ctx context.Context, idpStorage storage.Storage, storageFile string) error { + return runChangePassword(ctx, idpStorage, cmd.OutOrStdout(), passwordSelector, newPassword, storageFile) + }) + }, + } + addUserSelectorFlags(passwordCmd, &passwordSelector) + passwordCmd.Flags().StringVar(&password, "password", "", "New password for the user") + passwordCmd.Flags().StringVar(&passwordFile, "password-file", "", "Read new password from file ('-' for stdin)") + + var resetSelector userSelector + resetMFACmd := &cobra.Command{ + Use: "reset-mfa (--email email | --user-id id)", + Short: "Reset a local user's MFA enrollment", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := resetSelector.validate(); err != nil { + return err + } + return openers.IDP(cmd, func(ctx context.Context, idpStorage storage.Storage, storageFile string) error { + return runResetMFA(ctx, idpStorage, cmd.OutOrStdout(), resetSelector, storageFile) + }) + }, + } + addUserSelectorFlags(resetMFACmd, &resetSelector) + + userCmd.AddCommand(passwordCmd, resetMFACmd) + + mfaCmd := &cobra.Command{ + Use: "mfa", + Short: "Manage local MFA for embedded IdP users", + } + + enableCmd := &cobra.Command{ + Use: "enable", + Short: "Enable MFA for local embedded IdP users", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return openers.Resources(cmd, func(ctx context.Context, resources Resources) error { + return runSetMFAEnabled(ctx, resources, cmd.OutOrStdout(), true) + }) + }, + } + + disableCmd := &cobra.Command{ + Use: "disable", + Short: "Disable MFA for local embedded IdP users", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return openers.Resources(cmd, func(ctx context.Context, resources Resources) error { + return runSetMFAEnabled(ctx, resources, cmd.OutOrStdout(), false) + }) + }, + } + + statusCmd := &cobra.Command{ + Use: "status", + Short: "Show local MFA status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return openers.Resources(cmd, func(ctx context.Context, resources Resources) error { + return runMFAStatus(ctx, resources, cmd.OutOrStdout()) + }) + }, + } + + mfaCmd.AddCommand(enableCmd, disableCmd, statusCmd) + adminCmd.AddCommand(userCmd, mfaCmd) + if openers.Store != nil { + adminCmd.AddCommand(tokencmd.NewCommands(tokencmd.StoreOpener(openers.Store))) + adminCmd.AddCommand(proxycmd.NewCommands(proxycmd.StoreOpener(openers.Store))) + } + return adminCmd +} + +// OpenEmbeddedIDPStorage opens the Dex storage configured for the embedded IdP. +func OpenEmbeddedIDPStorage(cfg *idp.EmbeddedIdPConfig) (storage.Storage, error) { + if cfg == nil || !cfg.Enabled { + return nil, fmt.Errorf("admin commands require the embedded IdP to be enabled") + } + + yamlConfig, err := cfg.ToYAMLConfig() + if err != nil { + return nil, fmt.Errorf("build embedded IdP config: %w", err) + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + st, err := yamlConfig.Storage.OpenStorage(logger) + if err != nil { + return nil, fmt.Errorf("open embedded IdP storage: %w", err) + } + return st, nil +} + +// CloseStore closes the management store and logs cleanup errors at debug level. +func CloseStore(ctx context.Context, s store.Store) { + if s == nil { + return + } + if err := s.Close(ctx); err != nil { + log.Debugf("close store: %v", err) + } +} + +// OpenIDPStorage opens embedded IdP storage and returns its sqlite file path when applicable. +func OpenIDPStorage(config *nbconfig.Config) (storage.Storage, string, error) { + if config == nil { + return nil, "", fmt.Errorf("management config is required") + } + idpStorage, err := OpenEmbeddedIDPStorage(config.EmbeddedIdP) + if err != nil { + return nil, "", err + } + return idpStorage, embeddedIDPStorageFile(config), nil +} + +func embeddedIDPStorageFile(config *nbconfig.Config) string { + if config.EmbeddedIdP == nil || config.EmbeddedIdP.Storage.Type != "sqlite3" { + return "" + } + return config.EmbeddedIdP.Storage.Config.File +} + +// CloseIDPStorage closes embedded IdP storage and logs cleanup errors at debug level. +func CloseIDPStorage(s storage.Storage) { + if s == nil { + return + } + if err := s.Close(); err != nil { + log.Debugf("close embedded IdP storage: %v", err) + } +} + +func addUserSelectorFlags(cmd *cobra.Command, selector *userSelector) { + cmd.Flags().StringVar(&selector.email, "email", "", "User email") + cmd.Flags().StringVar(&selector.userID, "user-id", "", "User ID") +} + +func resolvePasswordInput(cmd *cobra.Command, password, passwordFile string) (string, error) { + if password != "" && passwordFile != "" { + return "", fmt.Errorf("provide only one of --password or --password-file") + } + if passwordFile == "" { + return password, nil + } + + var data []byte + var err error + if passwordFile == "-" { + data, err = io.ReadAll(cmd.InOrStdin()) + } else { + data, err = os.ReadFile(passwordFile) + } + if err != nil { + return "", fmt.Errorf("read password: %w", err) + } + return strings.TrimRight(string(data), "\r\n"), nil +} + +func runChangePassword(ctx context.Context, idpStorage storage.Storage, w io.Writer, selector userSelector, password string, idpStorageFile string) error { + if idpStorage == nil { + return fmt.Errorf("embedded IdP storage is required") + } + selector = selector.normalized() + if err := selector.validate(); err != nil { + return err + } + if password == "" { + return fmt.Errorf("password is required") + } + if err := server.ValidatePassword(password); err != nil { + return fmt.Errorf("invalid password: %w", err) + } + + user, err := findLocalUser(ctx, idpStorage, selector, idpStorageFile) + if err != nil { + return err + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + if err := idpStorage.UpdatePassword(ctx, user.Email, func(old storage.Password) (storage.Password, error) { + old.Hash = hash + return old, nil + }); err != nil { + return fmt.Errorf("update password for %s: %w", user.Email, err) + } + + if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil { + return err + } + + _, _ = fmt.Fprintf(w, "Password updated for %s.\n", user.Email) + return nil +} + +func runResetMFA(ctx context.Context, idpStorage storage.Storage, w io.Writer, selector userSelector, idpStorageFile string) error { + if idpStorage == nil { + return fmt.Errorf("embedded IdP storage is required") + } + selector = selector.normalized() + if err := selector.validate(); err != nil { + return err + } + + user, err := findLocalUser(ctx, idpStorage, selector, idpStorageFile) + if err != nil { + return err + } + + reset := false + err = idpStorage.UpdateUserIdentity(ctx, user.UserID, idp.LocalConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + reset = reset || len(old.MFASecrets) > 0 || len(old.WebAuthnCredentials) > 0 + old.MFASecrets = map[string]*storage.MFASecret{} + old.WebAuthnCredentials = map[string][]storage.WebAuthnCredential{} + return old, nil + }) + if errors.Is(err, storage.ErrNotFound) { + if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil { + return err + } + _, _ = fmt.Fprintf(w, "No MFA enrollment found for %s.\n", user.Email) + return nil + } + if err != nil { + return fmt.Errorf("reset MFA for %s: %w", user.Email, err) + } + + if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil { + return err + } + + if reset { + _, _ = fmt.Fprintf(w, "MFA reset for %s. The user will re-enroll at next login.\n", user.Email) + } else { + _, _ = fmt.Fprintf(w, "No MFA enrollment found for %s.\n", user.Email) + } + return nil +} + +func runSetMFAEnabled(ctx context.Context, resources Resources, w io.Writer, enabled bool) error { + if resources.Store == nil { + return fmt.Errorf("management store is required") + } + if resources.IDPStorage == nil { + return fmt.Errorf("embedded IdP storage is required") + } + + accountID, settings, err := getSingleAccountSettings(ctx, resources.Store) + if err != nil { + return err + } + + oldEnabled := settings.LocalMfaEnabled + newSettings := settings.Copy() + newSettings.LocalMfaEnabled = enabled + + if err := setIDPClientsMFA(ctx, resources.IDPStorage, enabled); err != nil { + return err + } + + if err := resources.Store.SaveAccountSettings(ctx, accountID, newSettings); err != nil { + if rollbackErr := setIDPClientsMFA(ctx, resources.IDPStorage, oldEnabled); rollbackErr != nil { + return fmt.Errorf("save local MFA account setting: %w (also failed to roll back embedded IdP MFA state: %v)", err, rollbackErr) + } + return fmt.Errorf("save local MFA account setting: %w", err) + } + + if err := storeMFAActivity(ctx, resources.EventStore, accountID, enabled); err != nil { + _, _ = fmt.Fprintf(w, "Warning: failed to record audit event: %v\n", err) + } + + state := "disabled" + if enabled { + state = "enabled" + } + _, _ = fmt.Fprintf(w, "Local MFA %s.\n", state) + return nil +} + +func runMFAStatus(ctx context.Context, resources Resources, w io.Writer) error { + if resources.Store == nil { + return fmt.Errorf("management store is required") + } + if resources.IDPStorage == nil { + return fmt.Errorf("embedded IdP storage is required") + } + + _, settings, err := getSingleAccountSettings(ctx, resources.Store) + if err != nil { + return err + } + accountStatus := "disabled" + if settings.LocalMfaEnabled { + accountStatus = "enabled" + } + + clientStatus, err := idpClientsMFAStatus(ctx, resources.IDPStorage) + if err != nil { + return err + } + + _, _ = fmt.Fprintf(w, "Account setting: %s\n", accountStatus) + _, _ = fmt.Fprintf(w, "Embedded IdP clients: %s\n", clientStatus) + return nil +} + +func getSingleAccountSettings(ctx context.Context, s store.Store) (string, *types.Settings, error) { + count, err := s.GetAccountsCounter(ctx) + if err != nil { + return "", nil, fmt.Errorf("count accounts: %w", err) + } + if count != 1 { + return "", nil, fmt.Errorf("expected exactly one account, got %d; local MFA is supported only in single-account embedded IdP deployments", count) + } + + accountID, err := s.GetAnyAccountID(ctx) + if err != nil { + return "", nil, fmt.Errorf("get account ID: %w", err) + } + + settings, err := s.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return "", nil, fmt.Errorf("get account settings: %w", err) + } + if settings == nil { + settings = &types.Settings{} + } + return accountID, settings, nil +} + +func storeMFAActivity(ctx context.Context, eventStore activity.Store, accountID string, enabled bool) error { + if eventStore == nil { + return nil + } + event := activity.AccountLocalMfaDisabled + if enabled { + event = activity.AccountLocalMfaEnabled + } + _, err := eventStore.Save(ctx, &activity.Event{ + Timestamp: time.Now().UTC(), + Activity: event, + InitiatorID: string(hook.SystemSource), + TargetID: accountID, + AccountID: accountID, + }) + if err != nil { + return fmt.Errorf("save local MFA audit event: %w", err) + } + return nil +} + +func findLocalUser(ctx context.Context, idpStorage storage.Storage, selector userSelector, idpStorageFile string) (storage.Password, error) { + selector = selector.normalized() + if err := selector.validate(); err != nil { + return storage.Password{}, err + } + + if selector.email != "" { + user, err := idpStorage.GetPassword(ctx, selector.email) + if errors.Is(err, storage.ErrNotFound) { + if empty, listErr := localUsersEmpty(ctx, idpStorage); listErr != nil { + return storage.Password{}, listErr + } else if empty { + return storage.Password{}, noLocalUsersError(idpStorageFile) + } + return storage.Password{}, fmt.Errorf("local user with email %q not found", selector.email) + } + if err != nil { + return storage.Password{}, fmt.Errorf("get local user by email %q: %w", selector.email, err) + } + return user, nil + } + + rawUserID := selector.userID + if decodedUserID, _, err := nbdex.DecodeDexUserID(selector.userID); err == nil && decodedUserID != "" { + rawUserID = decodedUserID + } + + users, err := idpStorage.ListPasswords(ctx) + if err != nil { + return storage.Password{}, fmt.Errorf("list local users: %w", err) + } + for _, user := range users { + if user.UserID == rawUserID || user.UserID == selector.userID { + return user, nil + } + } + + if len(users) == 0 { + return storage.Password{}, noLocalUsersError(idpStorageFile) + } + + return storage.Password{}, fmt.Errorf("local user with ID %q not found", selector.userID) +} + +func localUsersEmpty(ctx context.Context, idpStorage storage.Storage) (bool, error) { + users, err := idpStorage.ListPasswords(ctx) + if err != nil { + return false, fmt.Errorf("list local users: %w", err) + } + return len(users) == 0, nil +} + +func noLocalUsersError(idpStorageFile string) error { + location := "" + if idpStorageFile != "" { + location = fmt.Sprintf(" (%s)", idpStorageFile) + } + return fmt.Errorf("no local users exist in the embedded IdP storage%s; the management server may never have started with this config, or --datadir points at the wrong location", location) +} + +func deleteLocalAuthSession(ctx context.Context, idpStorage storage.Storage, userID string) error { + err := idpStorage.DeleteAuthSession(ctx, userID, idp.LocalConnectorID) + if err == nil || errors.Is(err, storage.ErrNotFound) { + return nil + } + return fmt.Errorf("delete local auth session for user %s: %w", userID, err) +} + +func setIDPClientsMFA(ctx context.Context, idpStorage storage.Storage, enabled bool) error { + var mfaChain []string + if enabled { + mfaChain = []string{idp.DefaultTOTPAuthenticatorID} + } + + clientIDs := []string{idp.StaticClientCLI, idp.StaticClientDashboard} + if err := nbdex.SetClientsMFAChain(ctx, idpStorage, clientIDs, mfaChain); err != nil { + if errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("embedded IdP client not found; start the management server once before toggling MFA: %w", err) + } + return fmt.Errorf("update MFA chain on embedded IdP clients: %w", err) + } + return nil +} + +func idpClientsMFAStatus(ctx context.Context, idpStorage storage.Storage) (string, error) { + clientIDs := []string{idp.StaticClientCLI, idp.StaticClientDashboard} + enabledCount := 0 + for _, clientID := range clientIDs { + client, err := idpStorage.GetClient(ctx, clientID) + if errors.Is(err, storage.ErrNotFound) { + return "unknown", fmt.Errorf("embedded IdP client %q not found", clientID) + } + if err != nil { + return "unknown", fmt.Errorf("get embedded IdP client %q: %w", clientID, err) + } + if hasAuthenticator(client.MFAChain, idp.DefaultTOTPAuthenticatorID) { + enabledCount++ + } + } + + switch enabledCount { + case 0: + return "disabled", nil + case len(clientIDs): + return "enabled", nil + default: + return "partially enabled", nil + } +} + +func hasAuthenticator(chain []string, authenticatorID string) bool { + for _, id := range chain { + if id == authenticatorID { + return true + } + } + return false +} diff --git a/management/cmd/admin/admin_test.go b/management/cmd/admin/admin_test.go new file mode 100644 index 000000000..dd1b8ed06 --- /dev/null +++ b/management/cmd/admin/admin_test.go @@ -0,0 +1,250 @@ +package admincmd + +import ( + "bytes" + "context" + "io" + "log/slog" + "strings" + "testing" + "time" + + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + + nbdex "github.com/netbirdio/netbird/idp/dex" + "github.com/netbirdio/netbird/management/server/idp" + mgmtstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +func newTestIDPStorage(t *testing.T) storage.Storage { + t.Helper() + + st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + hash, err := bcrypt.GenerateFromPassword([]byte("OldPass1!"), bcrypt.DefaultCost) + require.NoError(t, err) + + require.NoError(t, st.CreatePassword(context.Background(), storage.Password{ + Email: "user@example.com", + Username: "User", + UserID: "user-1", + Hash: hash, + })) + require.NoError(t, st.CreateUserIdentity(context.Background(), storage.UserIdentity{ + UserID: "user-1", + ConnectorID: idp.LocalConnectorID, + MFASecrets: map[string]*storage.MFASecret{ + idp.DefaultTOTPAuthenticatorID: { + AuthenticatorID: idp.DefaultTOTPAuthenticatorID, + Type: "TOTP", + Secret: "otpauth://totp/NetBird:user@example.com?secret=ABC", + Confirmed: true, + CreatedAt: time.Now(), + }, + }, + WebAuthnCredentials: map[string][]storage.WebAuthnCredential{ + "webauthn": {{CredentialID: []byte("credential")}}, + }, + })) + require.NoError(t, st.CreateAuthSession(context.Background(), storage.AuthSession{ + UserID: "user-1", + ConnectorID: idp.LocalConnectorID, + Nonce: "nonce", + })) + require.NoError(t, st.CreateClient(context.Background(), storage.Client{ID: idp.StaticClientCLI, Name: "CLI"})) + require.NoError(t, st.CreateClient(context.Background(), storage.Client{ID: idp.StaticClientDashboard, Name: "Dashboard"})) + + return st +} + +func TestRunChangePassword(t *testing.T) { + ctx := context.Background() + st := newTestIDPStorage(t) + var out bytes.Buffer + + err := runChangePassword(ctx, st, &out, userSelector{email: "user@example.com"}, "NewPass1!", "") + require.NoError(t, err) + require.Contains(t, out.String(), "Password updated") + + user, err := st.GetPassword(ctx, "user@example.com") + require.NoError(t, err) + require.NoError(t, bcrypt.CompareHashAndPassword(user.Hash, []byte("NewPass1!"))) + + _, err = st.GetAuthSession(ctx, "user-1", idp.LocalConnectorID) + require.ErrorIs(t, err, storage.ErrNotFound) +} + +func TestRunChangePasswordValidatesPassword(t *testing.T) { + st := newTestIDPStorage(t) + err := runChangePassword(context.Background(), st, io.Discard, userSelector{email: "user@example.com"}, "short", "") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid password") +} + +func TestRunResetMFA(t *testing.T) { + ctx := context.Background() + st := newTestIDPStorage(t) + var out bytes.Buffer + + encodedUserID := nbdex.EncodeDexUserID("user-1", idp.LocalConnectorID) + err := runResetMFA(ctx, st, &out, userSelector{userID: encodedUserID}, "") + require.NoError(t, err) + require.Contains(t, out.String(), "MFA reset") + + identity, err := st.GetUserIdentity(ctx, "user-1", idp.LocalConnectorID) + require.NoError(t, err) + require.Empty(t, identity.MFASecrets) + require.Empty(t, identity.WebAuthnCredentials) + + _, err = st.GetAuthSession(ctx, "user-1", idp.LocalConnectorID) + require.ErrorIs(t, err, storage.ErrNotFound) +} + +func TestRunResetMFAWithoutEnrollment(t *testing.T) { + ctx := context.Background() + st := newTestIDPStorage(t) + require.NoError(t, st.UpdateUserIdentity(ctx, "user-1", idp.LocalConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + old.MFASecrets = nil + old.WebAuthnCredentials = nil + return old, nil + })) + + var out bytes.Buffer + err := runResetMFA(ctx, st, &out, userSelector{email: "user@example.com"}, "") + require.NoError(t, err) + require.Contains(t, out.String(), "No MFA enrollment found") +} + +func TestSetIDPClientsMFA(t *testing.T) { + ctx := context.Background() + st := newTestIDPStorage(t) + + require.NoError(t, setIDPClientsMFA(ctx, st, true)) + status, err := idpClientsMFAStatus(ctx, st) + require.NoError(t, err) + require.Equal(t, "enabled", status) + + require.NoError(t, setIDPClientsMFA(ctx, st, false)) + status, err = idpClientsMFAStatus(ctx, st) + require.NoError(t, err) + require.Equal(t, "disabled", status) +} + +func newTestManagementStore(t *testing.T, localMFAEnabled bool) mgmtstore.Store { + t.Helper() + ctx := context.Background() + st, err := mgmtstore.NewStore(ctx, types.SqliteStoreEngine, t.TempDir(), nil, false) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, st.Close(ctx)) }) + require.NoError(t, st.SaveAccount(ctx, &types.Account{ + Id: "account-1", + Settings: &types.Settings{LocalMfaEnabled: localMFAEnabled}, + })) + return st +} + +func TestRunSetMFAEnabledDoesNotSaveWhenIDPUpdateFails(t *testing.T) { + ctx := context.Background() + managementStore := newTestManagementStore(t, false) + idpStorage := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + + err := runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage}, io.Discard, true) + require.Error(t, err) + require.Contains(t, err.Error(), "embedded IdP client") + + settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1") + require.NoError(t, err) + require.False(t, settings.LocalMfaEnabled) +} + +func TestRunSetMFAEnabledUpdatesSettingsAfterIDP(t *testing.T) { + ctx := context.Background() + managementStore := newTestManagementStore(t, false) + idpStorage := newTestIDPStorage(t) + + err := runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage}, io.Discard, true) + require.NoError(t, err) + + settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1") + require.NoError(t, err) + require.True(t, settings.LocalMfaEnabled) + clientStatus, err := idpClientsMFAStatus(ctx, idpStorage) + require.NoError(t, err) + require.Equal(t, "enabled", clientStatus) +} + +func TestRunSetMFAEnabledSucceedsWithNilEventStore(t *testing.T) { + ctx := context.Background() + managementStore := newTestManagementStore(t, false) + idpStorage := newTestIDPStorage(t) + var out bytes.Buffer + var err error + + require.NotPanics(t, func() { + err = runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage, EventStore: nil}, &out, true) + }) + require.NoError(t, err) + require.Contains(t, out.String(), "Local MFA enabled") + + settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1") + require.NoError(t, err) + require.True(t, settings.LocalMfaEnabled) +} + +func TestUserSelectorValidate(t *testing.T) { + require.NoError(t, userSelector{email: " user@example.com "}.validate()) + require.NoError(t, userSelector{userID: "user-1"}.validate()) + require.Error(t, userSelector{}.validate()) + require.Error(t, userSelector{email: "user@example.com", userID: "user-1"}.validate()) +} + +func TestFindLocalUserNotFound(t *testing.T) { + st := newTestIDPStorage(t) + _, err := findLocalUser(context.Background(), st, userSelector{email: "missing@example.com"}, "") + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "not found")) +} + +func TestFindLocalUserZeroUsersIncludesStoragePath(t *testing.T) { + st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil))) + _, err := findLocalUser(context.Background(), st, userSelector{email: "missing@example.com"}, "/var/lib/netbird/idp.db") + require.Error(t, err) + require.Contains(t, err.Error(), "no local users exist") + require.Contains(t, err.Error(), "/var/lib/netbird/idp.db") +} + +func TestUserCommandValidatesSelectorBeforeOpeningStorage(t *testing.T) { + opened := false + cmd := NewCommands(Openers{ + IDP: func(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error { + opened = true + return nil + }, + }) + cmd.SetArgs([]string{"user", "change-password", "--password", "NewPass1!"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), "provide exactly one") + require.False(t, opened) +} + +func TestResolvePasswordInputFromStdin(t *testing.T) { + cmd := &cobra.Command{} + cmd.SetIn(strings.NewReader("NewPass1!\n")) + + password, err := resolvePasswordInput(cmd, "", "-") + require.NoError(t, err) + require.Equal(t, "NewPass1!", password) +} + +func TestResolvePasswordInputRejectsMultipleSources(t *testing.T) { + _, err := resolvePasswordInput(&cobra.Command{}, "NewPass1!", "-") + require.Error(t, err) +} diff --git a/management/cmd/admin_config_test.go b/management/cmd/admin_config_test.go new file mode 100644 index 000000000..6da8580a8 --- /dev/null +++ b/management/cmd/admin_config_test.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "context" + "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/idp" +) + +func TestApplyAdminDatadirOverrideRelocatesDefaultIDPStorage(t *testing.T) { + oldDatadir := filepath.Join(t.TempDir(), "old") + newDatadir := filepath.Join(t.TempDir(), "new") + + for _, defaultFile := range []string{ + "", + filepath.Join(oldDatadir, "idp.db"), + path.Join(oldDatadir, "idp.db"), + } { + t.Run(defaultFile, func(t *testing.T) { + cfg := &nbconfig.Config{ + EmbeddedIdP: &idp.EmbeddedIdPConfig{ + Enabled: true, + Storage: idp.EmbeddedStorageConfig{ + Type: "sqlite3", + Config: idp.EmbeddedStorageTypeConfig{ + File: defaultFile, + }, + }, + }, + } + datadir := oldDatadir + oldAdminDatadir := adminDatadir + adminDatadir = newDatadir + t.Cleanup(func() { adminDatadir = oldAdminDatadir }) + + applyAdminDatadirOverride(cfg, &datadir) + + require.Equal(t, newDatadir, datadir) + require.Equal(t, filepath.Join(newDatadir, "idp.db"), cfg.EmbeddedIdP.Storage.Config.File) + }) + } +} + +func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T) { + eventStore, err := openAdminEventStore(context.Background(), &nbconfig.Config{}, t.TempDir()) + require.Error(t, err) + require.Contains(t, err.Error(), "encryption key") + require.Nil(t, eventStore) +} + +func TestApplyAdminDatadirOverrideKeepsExplicitIDPStorage(t *testing.T) { + oldDatadir := filepath.Join(t.TempDir(), "old") + newDatadir := filepath.Join(t.TempDir(), "new") + explicitFile := filepath.Join(t.TempDir(), "custom-idp.db") + cfg := &nbconfig.Config{ + EmbeddedIdP: &idp.EmbeddedIdPConfig{ + Enabled: true, + Storage: idp.EmbeddedStorageConfig{ + Type: "sqlite3", + Config: idp.EmbeddedStorageTypeConfig{ + File: explicitFile, + }, + }, + }, + } + datadir := oldDatadir + oldAdminDatadir := adminDatadir + adminDatadir = newDatadir + t.Cleanup(func() { adminDatadir = oldAdminDatadir }) + + applyAdminDatadirOverride(cfg, &datadir) + + require.Equal(t, newDatadir, datadir) + require.Equal(t, explicitFile, cfg.EmbeddedIdP.Storage.Config.File) +} diff --git a/management/cmd/management.go b/management/cmd/management.go index 27d8055e7..147985314 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -13,6 +13,7 @@ import ( "os" "os/signal" "path" + "path/filepath" "strings" "syscall" @@ -22,9 +23,11 @@ import ( "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/formatter/hook" + agentnetworkpricing "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" "github.com/netbirdio/netbird/management/internals/server" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" nbdomain "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/grpc" "github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/util/crypt" ) @@ -110,6 +113,29 @@ var ( mgmtSingleAccModeDomain = "" } + // Load the management-side LLM pricing defaults file: an + // explicitly configured path is required to load (a typo must + // fail startup — the operator believes those rates are live); + // otherwise /defaults_llm_pricing.yaml is probed and + // may be absent (compiled-in defaults serve). A relative path + // is resolved against the datadir so a bare filename lands + // alongside the store. Either way the path stays watched: the + // reloader picks up edits — and the file appearing later — + // without a restart. + pricingPath := config.AgentNetwork.PricingDefaultsFile + pricingRequired := pricingPath != "" + if !pricingRequired { + pricingPath = agentnetworkpricing.DefaultFileName + } + if !filepath.IsAbs(pricingPath) { + pricingPath = filepath.Join(config.Datadir, pricingPath) + } + log.Infof("loading agent-network pricing defaults from %s (required: %v)", pricingPath, pricingRequired) + if err := agentnetworkpricing.LoadFile(pricingPath, pricingRequired); err != nil { + return fmt.Errorf("load agent-network pricing defaults: %v", err) + } + agentnetworkpricing.StartReloader(ctx, agentnetworkpricing.ReloadInterval) + srv := newServer(&server.Config{ NbConfig: config, DNSDomain: dnsDomain, @@ -153,8 +179,20 @@ func LoadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Confi ApplyCommandLineOverrides(loadedConfig) + err := grpc.ValidateSyncMessageVersion(loadedConfig.HighestSupportedSyncMessageVersion) + if err != nil { + return nil, err + } + + for account, version := range loadedConfig.PerAccountHighestSupportedSyncMessageVersion { + err := grpc.ValidateSyncMessageVersion(&version) + if err != nil { + return nil, fmt.Errorf("unrecognized sync message version for account %s, %w", account, err) + } + } + // Apply EmbeddedIdP config to HttpConfig if embedded IdP is enabled - err := ApplyEmbeddedIdPConfig(ctx, loadedConfig) + err = ApplyEmbeddedIdPConfig(ctx, loadedConfig) if err != nil { return nil, err } @@ -209,7 +247,7 @@ func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error { cfg.EmbeddedIdP.Storage.Type = "sqlite3" } if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" { - cfg.EmbeddedIdP.Storage.Config.File = path.Join(cfg.Datadir, "idp.db") + cfg.EmbeddedIdP.Storage.Config.File = filepath.Join(cfg.Datadir, "idp.db") } issuer := cfg.EmbeddedIdP.Issuer diff --git a/management/cmd/management_test.go b/management/cmd/management_test.go index f0c89dd3f..2c3481213 100644 --- a/management/cmd/management_test.go +++ b/management/cmd/management_test.go @@ -4,6 +4,9 @@ import ( "context" "os" "testing" + + "github.com/netbirdio/netbird/shared/management/grpc" + "github.com/stretchr/testify/assert" ) const ( @@ -20,34 +23,49 @@ const ( "AuthAudience": "https://stageapp/", "AuthIssuer": "https://something.eu.auth0.com/", "OIDCConfigEndpoint": "https://something.eu.auth0.com/.well-known/openid-configuration" + }, + "HighestSupportedSyncMessageVersion": 1, + "PerAccountHighestSupportedSyncMessageVersion": { + "1": 0, + "2": 1 } }` ) -func Test_loadMgmtConfig(t *testing.T) { - tmpFile, err := createConfig() - if err != nil { - t.Fatalf("failed to create config: %s", err) - } +func Test_LoadMgmtConfig(t *testing.T) { + tmpFile, err := createConfig(exampleConfig) + assert.NoError(t, err) cfg, err := LoadMgmtConfig(context.Background(), tmpFile) - if err != nil { - t.Fatalf("failed to load management config: %s", err) - } - if cfg.Relay == nil { - t.Fatalf("config is nil") - } - if len(cfg.Relay.Addresses) == 0 { - t.Fatalf("relay address is empty") - } + assert.NoError(t, err) + assert.NotEmpty(t, cfg.Relay) + assert.NotEmpty(t, cfg.Relay.Addresses) + assert.Equal(t, int(grpc.ComponentNetworkMap), *cfg.HighestSupportedSyncMessageVersion) + assert.Equal(t, map[string]int{"1": int(grpc.Base), "2": int(grpc.ComponentNetworkMap)}, cfg.PerAccountHighestSupportedSyncMessageVersion) } -func createConfig() (string, error) { +func Test_LoadMgmtConfig_Empty(t *testing.T) { + tmpFile, err := createConfig(`{ + "HttpConfig": { + "AuthAudience": "https://stageapp/", + "AuthIssuer": "https://something.eu.auth0.com/", + "OIDCConfigEndpoint": "https://something.eu.auth0.com/.well-known/openid-configuration" + } + }`) + assert.NoError(t, err) + + cfg, err := LoadMgmtConfig(context.Background(), tmpFile) + assert.NoError(t, err) + assert.Nil(t, cfg.HighestSupportedSyncMessageVersion) + assert.Nil(t, cfg.PerAccountHighestSupportedSyncMessageVersion) +} + +func createConfig(config string) (string, error) { tmpfile, err := os.CreateTemp("", "config.json") if err != nil { return "", err } - _, err = tmpfile.Write([]byte(exampleConfig)) + _, err = tmpfile.Write([]byte(config)) if err != nil { return "", err } diff --git a/management/cmd/proxy/proxy.go b/management/cmd/proxy/proxy.go new file mode 100644 index 000000000..73f83b3d6 --- /dev/null +++ b/management/cmd/proxy/proxy.go @@ -0,0 +1,141 @@ +// Package proxycmd provides reusable cobra commands for managing reverse proxy instances. +// Both the management and combined binaries use these commands, each providing +// their own StoreOpener to handle config loading and store initialization. +package proxycmd + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/server/store" +) + +// StoreOpener initializes a store from the command context and calls fn. +type StoreOpener func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error + +const disconnectAllConfirmation = "disconnect all proxies" + +// NewCommands creates the proxy command tree with the given store opener. +// Returns the parent "proxy" command with the disconnect-all subcommand. +func NewCommands(opener StoreOpener) *cobra.Command { + var dryRun bool + var force bool + + proxyCmd := &cobra.Command{ + Use: "proxy", + Short: "Manage reverse proxy instances", + Long: "Commands for inspecting and repairing the reverse proxy instances registered with the management server.", + } + + disconnectAllCmd := &cobra.Command{ + Use: "disconnect-all", + Short: "Force-mark all reverse proxy instances as disconnected", + Long: "Lists all reverse proxy instances and force-marks them as disconnected, regardless of their session state. " + + "Use this to repair stale connection state, e.g. after an unclean management server shutdown. " + + "By default, it asks for manual confirmation before changing state. Use --dry-run to preview without changing state, or --force to skip confirmation. " + + "Run during a maintenance window; affected live proxies may stay hidden until their next heartbeat or reconnect/re-register.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return opener(cmd, func(ctx context.Context, s store.Store) error { + return runDisconnectAll(ctx, s, cmd.OutOrStdout(), cmd.InOrStdin(), dryRun, force) + }) + }, + } + disconnectAllCmd.Flags().BoolVar(&dryRun, "dry-run", false, "List reverse proxy instances that would be disconnected without changing state") + disconnectAllCmd.Flags().BoolVar(&force, "force", false, "Skip the confirmation prompt and apply the repair") + + proxyCmd.AddCommand(disconnectAllCmd) + return proxyCmd +} + +func runDisconnectAll(ctx context.Context, s store.Store, out io.Writer, in io.Reader, dryRun, force bool) error { + proxies, err := s.GetAllProxies(ctx) + if err != nil { + return fmt.Errorf("list proxies: %w", err) + } + + if len(proxies) == 0 { + _, _ = fmt.Fprintln(out, "No reverse proxy instances found.") + return nil + } + + toDisconnect := 0 + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tCLUSTER\tIP\tACCOUNT\tSTATUS\tLAST SEEN") + _, _ = fmt.Fprintln(w, "--\t-------\t--\t-------\t------\t---------") + + for _, p := range proxies { + if p.Status != rpproxy.StatusDisconnected { + toDisconnect++ + } + + account := "-" + if p.AccountID != nil { + account = *p.AccountID + } + + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + p.ID, + p.ClusterAddress, + p.IPAddress, + account, + p.Status, + p.LastSeen.Format("2006-01-02 15:04:05"), + ) + } + if err := w.Flush(); err != nil { + return fmt.Errorf("write proxy list: %w", err) + } + + if dryRun { + _, _ = fmt.Fprintf(out, "\nDry run: would force-mark %d of %d reverse proxy instance(s) as disconnected.\n", toDisconnect, len(proxies)) + return nil + } + + if !force { + confirmed, err := confirmDisconnectAll(out, in) + if err != nil { + return err + } + if !confirmed { + _, _ = fmt.Fprintln(out, "Aborted. No reverse proxy instances were changed.") + return nil + } + } + + disconnected, err := s.DisconnectAllProxies(ctx) + if err != nil { + return fmt.Errorf("disconnect proxies: %w", err) + } + + _, _ = fmt.Fprintf(out, "\nForce-marked %d of %d reverse proxy instance(s) as disconnected.\n", disconnected, len(proxies)) + return nil +} + +func confirmDisconnectAll(out io.Writer, in io.Reader) (bool, error) { + if in == nil { + in = strings.NewReader("") + } + + _, _ = fmt.Fprintln(out, "\nWARNING: This command changes stored reverse proxy state for every non-disconnected instance.") + _, _ = fmt.Fprintln(out, "Run it during a maintenance window; affected live proxies may stay hidden until "+ + "their next heartbeat or reconnect/re-register.") + _, _ = fmt.Fprintf(out, "Type %q to continue: ", disconnectAllConfirmation) + + scanner := bufio.NewScanner(in) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return false, fmt.Errorf("read confirmation: %w", err) + } + return false, nil + } + + return strings.EqualFold(strings.TrimSpace(scanner.Text()), disconnectAllConfirmation), nil +} diff --git a/management/cmd/proxy/proxy_test.go b/management/cmd/proxy/proxy_test.go new file mode 100644 index 000000000..ff0dc8119 --- /dev/null +++ b/management/cmd/proxy/proxy_test.go @@ -0,0 +1,180 @@ +package proxycmd + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/server/store" +) + +func newTestStore(t *testing.T) store.Store { + t.Helper() + + s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + return s +} + +func seedProxies(t *testing.T, ctx context.Context, s store.Store) { + t.Helper() + + accountID := "account-1" + alreadyDisconnectedAt := time.Now().Add(-time.Hour) + seed := []*rpproxy.Proxy{ + { + ID: "proxy-1", + SessionID: "session-1", + ClusterAddress: "cluster-a.example.com", + IPAddress: "10.0.0.1", + LastSeen: time.Now(), + Status: rpproxy.StatusConnected, + }, + { + ID: "proxy-2", + SessionID: "session-2", + ClusterAddress: "cluster-b.example.com", + IPAddress: "10.0.0.2", + AccountID: &accountID, + LastSeen: time.Now(), + Status: rpproxy.StatusConnected, + }, + { + ID: "proxy-3", + SessionID: "session-3", + ClusterAddress: "cluster-a.example.com", + IPAddress: "10.0.0.3", + LastSeen: time.Now().Add(-time.Hour), + Status: rpproxy.StatusDisconnected, + DisconnectedAt: &alreadyDisconnectedAt, + }, + } + for _, p := range seed { + require.NoError(t, s.SaveProxy(ctx, p)) + } +} + +func proxiesByID(t *testing.T, ctx context.Context, s store.Store) map[string]*rpproxy.Proxy { + t.Helper() + + proxies, err := s.GetAllProxies(ctx) + require.NoError(t, err) + require.Len(t, proxies, 3) + + byID := make(map[string]*rpproxy.Proxy, len(proxies)) + for _, p := range proxies { + byID[p.ID] = p + } + return byID +} + +func TestRunDisconnectAllWithConfirmation(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(disconnectAllConfirmation+"\n"), false, false)) + + output := out.String() + require.Contains(t, output, "proxy-1") + require.Contains(t, output, "proxy-2") + require.Contains(t, output, "proxy-3") + require.Contains(t, output, "cluster-a.example.com") + require.Contains(t, output, "account-1") + require.Contains(t, output, "Type \"disconnect all proxies\" to continue") + require.Contains(t, output, "Force-marked 2 of 3 reverse proxy instance(s) as disconnected.") + + for _, p := range proxiesByID(t, ctx, s) { + require.Equal(t, rpproxy.StatusDisconnected, p.Status, "proxy %s should be disconnected", p.ID) + require.NotNil(t, p.DisconnectedAt, "proxy %s should have a disconnected timestamp", p.ID) + } +} + +func TestRunDisconnectAllForceSkipsConfirmation(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), false, true)) + + output := out.String() + require.NotContains(t, output, "Type \"disconnect all proxies\" to continue") + require.Contains(t, output, "Force-marked 2 of 3 reverse proxy instance(s) as disconnected.") +} + +func TestRunDisconnectAllAbortLeavesProxiesUnchanged(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader("no\n"), false, false)) + + output := out.String() + require.Contains(t, output, "Type \"disconnect all proxies\" to continue") + require.Contains(t, output, "Aborted. No reverse proxy instances were changed.") + + byID := proxiesByID(t, ctx, s) + require.Equal(t, rpproxy.StatusConnected, byID["proxy-1"].Status) + require.Equal(t, rpproxy.StatusConnected, byID["proxy-2"].Status) + require.Equal(t, rpproxy.StatusDisconnected, byID["proxy-3"].Status) +} + +func TestRunDisconnectAllDryRunLeavesProxiesUnchanged(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), true, false)) + + output := out.String() + require.Contains(t, output, "Dry run: would force-mark 2 of 3 reverse proxy instance(s) as disconnected.") + require.NotContains(t, output, "Type \"disconnect all proxies\" to continue") + + byID := proxiesByID(t, ctx, s) + require.Equal(t, rpproxy.StatusConnected, byID["proxy-1"].Status) + require.Equal(t, rpproxy.StatusConnected, byID["proxy-2"].Status) + require.Equal(t, rpproxy.StatusDisconnected, byID["proxy-3"].Status) +} + +func TestNewCommandsDisconnectAllDryRun(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + seedProxies(t, ctx, s) + + opened := false + cmd := NewCommands(func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { + opened = true + return fn(cmd.Context(), s) + }) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetIn(strings.NewReader("")) + cmd.SetArgs([]string{"disconnect-all", "--dry-run"}) + + require.NoError(t, cmd.ExecuteContext(ctx)) + require.True(t, opened) + require.Contains(t, out.String(), "Dry run: would force-mark 2 of 3 reverse proxy instance(s) as disconnected.") +} + +func TestRunDisconnectAllEmpty(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), false, false)) + require.Contains(t, out.String(), "No reverse proxy instances found.") +} diff --git a/management/cmd/root.go b/management/cmd/root.go index fc43d315d..969dd60dd 100644 --- a/management/cmd/root.go +++ b/management/cmd/root.go @@ -83,7 +83,8 @@ func init() { rootCmd.AddCommand(migrationCmd) - tc := newTokenCommands() - tc.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location") - rootCmd.AddCommand(tc) + ac := newAdminCommands() + ac.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location") + rootCmd.AddCommand(ac) + rootCmd.AddCommand(newLegacyTokenCommand()) } diff --git a/management/cmd/token.go b/management/cmd/token.go deleted file mode 100644 index 67af1a5f5..000000000 --- a/management/cmd/token.go +++ /dev/null @@ -1,55 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - - log "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - - "github.com/netbirdio/netbird/formatter/hook" - tokencmd "github.com/netbirdio/netbird/management/cmd/token" - nbconfig "github.com/netbirdio/netbird/management/internals/server/config" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/util" -) - -var tokenDatadir string - -// newTokenCommands creates the token command tree with management-specific store opener. -func newTokenCommands() *cobra.Command { - cmd := tokencmd.NewCommands(withTokenStore) - cmd.PersistentFlags().StringVar(&tokenDatadir, "datadir", "", "Override the data directory from config (where store.db is located)") - return cmd -} - -// withTokenStore initializes logging, loads config, opens the store, and calls fn. -func withTokenStore(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error { - if err := util.InitLog("error", "console"); err != nil { - return fmt.Errorf("init log: %w", err) - } - - ctx := context.WithValue(cmd.Context(), hook.ExecutionContextKey, hook.SystemSource) //nolint:staticcheck - - config, err := LoadMgmtConfig(ctx, nbconfig.MgmtConfigPath) - if err != nil { - return fmt.Errorf("load config: %w", err) - } - - datadir := config.Datadir - if tokenDatadir != "" { - datadir = tokenDatadir - } - - s, err := store.NewStore(ctx, config.StoreConfig.Engine, datadir, nil, true) - if err != nil { - return fmt.Errorf("create store: %w", err) - } - defer func() { - if err := s.Close(ctx); err != nil { - log.Debugf("close store: %v", err) - } - }() - - return fn(ctx, s) -} diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index d271c499d..07f1938c5 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -29,6 +29,7 @@ import ( "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/management/server/types" + sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc" "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/util" @@ -56,6 +57,10 @@ type Controller struct { proxyController port_forwarding.Controller integratedPeerValidator integrated_validator.IntegratedValidator + + serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion + + perAccountServerSupportedSyncMessageVersions map[string]sharedgrpc.SyncMessageVersion } type bufferUpdate struct { @@ -90,8 +95,10 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App dnsDomain: dnsDomain, config: config, - proxyController: proxyController, - EphemeralPeersManager: ephemeralPeersManager, + proxyController: proxyController, + EphemeralPeersManager: ephemeralPeersManager, + serverSupportedSyncMessageVersion: sharedgrpc.SyncMessageVersionFromConfig(config.HighestSupportedSyncMessageVersion), + perAccountServerSupportedSyncMessageVersions: sharedgrpc.SyncMessageVersionsFromMap(config.PerAccountHighestSupportedSyncMessageVersion), } } @@ -116,6 +123,24 @@ func (c *Controller) OnPeerDisconnected(ctx context.Context, accountID string, p c.EphemeralPeersManager.OnPeerDisconnected(ctx, peer) } +// injectAllProxyPolicies prepares an account for the per-peer network-map +// computation. It prepends the in-memory agent-network services synthesised +// from the account's current provider/policy state to account.Services so +// the existing InjectProxyPolicies + injectPrivateServicePolicies walks pick +// them up alongside persisted reverse-proxy services. Synthesised services +// are never persisted; the account is loaded fresh per cycle so re-prepending +// is safe and idempotent. Accounts without agent-network providers get an +// empty synth slice — no behaviour change. +func (c *Controller) injectAllProxyPolicies(ctx context.Context, account *types.Account) { + synth, err := c.repo.SynthesizeAgentNetworkServices(ctx, account.Id) + if err != nil { + log.WithContext(ctx).Warnf("synthesise agent-network services for account %s: %v", account.Id, err) + } else if len(synth) > 0 { + account.Services = append(synth, account.Services...) + } + account.InjectProxyPolicies(ctx) +} + func (c *Controller) CountStreams() int { return c.peersUpdateManager.CountStreams() } @@ -150,7 +175,8 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin var wg sync.WaitGroup semaphore := make(chan struct{}, 10) - account.InjectProxyPolicies(ctx) + c.injectAllProxyPolicies(ctx, account) + account.PrecomputePostureValidation(ctx) dnsCache := &cache.DNSConfigCache{} dnsDomain := c.GetDNSDomain(account.Settings) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) @@ -204,18 +230,53 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin c.metrics.CountCalcPostureChecksDuration(time.Since(start)) start = time.Now() - remotePeerNetworkMap := account.GetPeerNetworkMapFromComponents(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + peerGroups := account.GetPeerGroups(p.ID) + proxyNetworkMap := proxyNetworkMaps[p.ID] + var update *proto.SyncResponse + + commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion( + c.perAccountOrGlobalSupportedSyncMessageVersions(accountID), + sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion)) + + log.WithContext(ctx). + WithFields(log.Fields{ + "sync_message_version": commonSyncMessageVersion, + "server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(peer.AccountID), + "peer_sync_message_version": sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion), + }).Debug("common highest sync message version") + + if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap { + components := account.GetPeerNetworkMapComponents( + ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs) + + c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) + + start = time.Now() + // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; + // the client merges it into Calculate()'s output the same + // way the legacy server did via NetworkMap.Merge. + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) + + c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) + + return + } + + nmap := account.GetPeerNetworkMapFromComponents( + ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) - proxyNetworkMap, ok := proxyNetworkMaps[p.ID] - if ok { - remotePeerNetworkMap.Merge(proxyNetworkMap) + if proxyNetworkMap != nil { + nmap.Merge(proxyNetworkMap) } - peerGroups := account.GetPeerGroups(p.ID) start = time.Now() - update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -233,6 +294,13 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin return nil } +func (c *Controller) perAccountOrGlobalSupportedSyncMessageVersions(accountId string) sharedgrpc.SyncMessageVersion { + if perAccount, ok := c.perAccountServerSupportedSyncMessageVersions[accountId]; ok { + return perAccount + } + return c.serverSupportedSyncMessageVersion +} + // UpdatePeers updates all peers that belong to an account. // Should be called when changes have to be synced to peers. func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error { @@ -281,7 +349,16 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s var wg sync.WaitGroup semaphore := make(chan struct{}, 10) - account.InjectProxyPolicies(ctx) + // The affected-peer path MUST mirror sendUpdateAccountPeers (line 171) + // here: injectAllProxyPolicies prepends the synthesised agent-network + // services BEFORE InjectProxyPolicies + private-service policies run. + // Previously this path called only account.InjectProxyPolicies, which + // skipped the synth-services prepend — so peer-level changes + // (proxy restart, embedded peer connect/disconnect) propagated a + // network map that omitted the synth DNS zone, and the agent kept + // resolving against the stale or absent record. + c.injectAllProxyPolicies(ctx, account) + account.PrecomputePostureValidation(ctx) dnsCache := &cache.DNSConfigCache{} dnsDomain := c.GetDNSDomain(account.Settings) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) @@ -326,18 +403,53 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s c.metrics.CountCalcPostureChecksDuration(time.Since(start)) start = time.Now() - remotePeerNetworkMap := account.GetPeerNetworkMapFromComponents(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + peerGroups := account.GetPeerGroups(p.ID) + proxyNetworkMap := proxyNetworkMaps[p.ID] + var update *proto.SyncResponse + + commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion( + c.perAccountOrGlobalSupportedSyncMessageVersions(accountID), + sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion)) + + log.WithContext(ctx). + WithFields(log.Fields{ + "sync_message_version": commonSyncMessageVersion, + "server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(peer.AccountID), + "peer_sync_message_version": sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion), + }).Debug("common highest sync message version") + + if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap { + components := account.GetPeerNetworkMapComponents( + ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs) + + c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) + + start = time.Now() + // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; + // the client merges it into Calculate()'s output the same + // way the legacy server did via NetworkMap.Merge. + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) + + c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) + + return + } + + nmap := account.GetPeerNetworkMapFromComponents( + ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) - proxyNetworkMap, ok := proxyNetworkMaps[p.ID] - if ok { - remotePeerNetworkMap.Merge(proxyNetworkMap) + if proxyNetworkMap != nil { + nmap.Merge(proxyNetworkMap) } - peerGroups := account.GetPeerGroups(p.ID) start = time.Now() - update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -399,7 +511,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe return fmt.Errorf("failed to get validated peers: %v", err) } - account.InjectProxyPolicies(ctx) + c.injectAllProxyPolicies(ctx, account) dnsCache := &cache.DNSConfigCache{} dnsDomain := c.GetDNSDomain(account.Settings) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) @@ -425,13 +537,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe return err } - remotePeerNetworkMap := account.GetPeerNetworkMapFromComponents(ctx, peerId, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) - - proxyNetworkMap, ok := proxyNetworkMaps[peer.ID] - if ok { - remotePeerNetworkMap.Merge(proxyNetworkMap) - } - + proxyNetworkMap := proxyNetworkMaps[peer.ID] extraSettings, err := c.settingsManager.GetExtraSettings(ctx, peer.AccountID) if err != nil { return fmt.Errorf("failed to get extra settings: %v", err) @@ -440,7 +546,45 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe peerGroups := account.GetPeerGroups(peerId) dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion) - update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort) + var update *proto.SyncResponse + + commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion( + c.perAccountOrGlobalSupportedSyncMessageVersions(accountId), + sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion)) + + log.WithContext(ctx). + WithFields(log.Fields{ + "sync_message_version": commonSyncMessageVersion, + "server_sync_message_version": c.perAccountOrGlobalSupportedSyncMessageVersions(peer.AccountID), + "peer_sync_message_version": sharedgrpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion), + }).Debug("common highest sync message version") + + if commonSyncMessageVersion == sharedgrpc.ComponentNetworkMap { + components := account.GetPeerNetworkMapComponents( + ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs) + + // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; + // the client merges it into Calculate()'s output the same + // way the legacy server did via NetworkMap.Merge. + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort) + + c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) + + return nil + } + + nmap := account.GetPeerNetworkMapFromComponents( + ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) + + if proxyNetworkMap != nil { + nmap.Merge(proxyNetworkMap) + } + + update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort) + c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{ Update: update, MessageType: network_map.MessageTypeNetworkMap, @@ -487,6 +631,65 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str return nil } +// GetValidatedPeerWithComponents is the components-format counterpart of +// GetValidatedPeerWithMap. It returns raw NetworkMapComponents for capable +// peers along with the proxy NetworkMap fragment (BYOP / port-forwarding +// data the legacy server folds in via NetworkMap.Merge). The gRPC layer +// encodes both into the wire envelope. Callers must gate on capability +// themselves before dispatching here — this method does NOT branch on it. +func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) { + if isRequiresApproval { + network, err := c.repo.GetAccountNetwork(ctx, accountID) + if err != nil { + return nil, nil, nil, nil, 0, err + } + return peer, &types.NetworkMapComponents{Network: network.Copy()}, nil, nil, 0, nil + } + + account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) + if err != nil { + return nil, nil, nil, nil, 0, err + } + + c.injectAllProxyPolicies(ctx, account) + + approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) + if err != nil { + return nil, nil, nil, nil, 0, err + } + + postureChecks, err := c.getPeerPostureChecks(account, peer.ID) + if err != nil { + return nil, nil, nil, nil, 0, err + } + + accountZones, err := c.repo.GetAccountZones(ctx, account.Id) + if err != nil { + return nil, nil, nil, nil, 0, err + } + + // Fetch the proxy network map fragment for this peer alongside the + // components — same single-account-load path the streaming controller + // uses, so initial-sync delivers BYOP/forwarding patches synchronously + // instead of waiting for the next streaming push. + proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers) + if err != nil { + log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) + return nil, nil, nil, nil, 0, err + } + + dnsDomain := c.GetDNSDomain(account.Settings) + peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) + + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + components := account.GetPeerNetworkMapComponents(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs) + dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion) + + return peer, components, proxyNetworkMaps[peer.ID], postureChecks, dnsFwdPort, nil +} + // BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval. func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error { if len(peerIDs) == 0 { @@ -497,7 +700,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{}), @@ -603,19 +806,17 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr return nil, nil, 0, err } - account.InjectProxyPolicies(ctx) + c.injectAllProxyPolicies(ctx, account) approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) if err != nil { return nil, nil, 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 { @@ -823,7 +1024,7 @@ func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerI FirewallRules: []*proto.FirewallRule{}, FirewallRulesIsEmpty: true, DNSConfig: &proto.DNSConfig{ - ForwarderPort: dnsFwdPort, + ForwarderPort: dnsFwdPort, //nolint:staticcheck }, }, }, @@ -876,7 +1077,7 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N return nil, err } - account.InjectProxyPolicies(ctx) + c.injectAllProxyPolicies(ctx, account) resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() groupIDToUserIDs := account.GetActiveGroupUsers() diff --git a/management/internals/controllers/network_map/controller/repository.go b/management/internals/controllers/network_map/controller/repository.go index caef362cb..c0fcefc7d 100644 --- a/management/internals/controllers/network_map/controller/repository.go +++ b/management/internals/controllers/network_map/controller/repository.go @@ -3,7 +3,9 @@ package controller import ( "context" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" @@ -16,6 +18,10 @@ type Repository interface { GetPeersByIDs(ctx context.Context, accountID string, peerIDs []string) (map[string]*peer.Peer, error) GetPeerByID(ctx context.Context, accountID string, peerID string) (*peer.Peer, error) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) + // SynthesizeAgentNetworkServices returns the in-memory reverse-proxy + // services synthesised from the account's agent-network provider/policy + // state. Empty for accounts without agent-network providers. + SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error) } type repository struct { @@ -50,6 +56,10 @@ func (r *repository) GetPeerByID(ctx context.Context, accountID string, peerID s return r.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) } +func (r *repository) SynthesizeAgentNetworkServices(ctx context.Context, accountID string) ([]*service.Service, error) { + return agentnetwork.SynthesizeServices(ctx, r.store, accountID) +} + func (r *repository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) { return r.store.GetAccountZones(ctx, store.LockingStrengthNone, accountID) } diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go index 14b12aba6..b535321d1 100644 --- a/management/internals/controllers/network_map/interface.go +++ b/management/internals/controllers/network_map/interface.go @@ -1,6 +1,6 @@ package network_map -//go:generate go run go.uber.org/mock/mockgen -package network_map -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod +//go:generate go tool mockgen -package network_map -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod import ( "context" @@ -24,6 +24,7 @@ type Controller interface { UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) + GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) GetDNSDomain(settings *types.Settings) string StartWarmup(context.Context) GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go index bfff32e6f..42051f172 100644 --- a/management/internals/controllers/network_map/interface_mock.go +++ b/management/internals/controllers/network_map/interface_mock.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: management/internals/controllers/network_map/interface.go +// Source: ./interface.go // // Generated by this command: // -// mockgen -package network_map -destination=management/internals/controllers/network_map/interface_mock.go -source=management/internals/controllers/network_map/interface.go -build_flags=-mod=mod +// mockgen -package network_map -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod // // Package network_map is a generated GoMock package. @@ -126,8 +126,27 @@ func (mr *MockControllerMockRecorder) GetNetworkMap(ctx, peerID any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkMap", reflect.TypeOf((*MockController)(nil).GetNetworkMap), ctx, peerID) } +// GetValidatedPeerWithComponents mocks base method. +func (m *MockController) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetValidatedPeerWithComponents", ctx, isRequiresApproval, accountID, p) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(*types.NetworkMapComponents) + ret2, _ := ret[2].(*types.NetworkMap) + ret3, _ := ret[3].([]*posture.Checks) + ret4, _ := ret[4].(int64) + ret5, _ := ret[5].(error) + return ret0, ret1, ret2, ret3, ret4, ret5 +} + +// GetValidatedPeerWithComponents indicates an expected call of GetValidatedPeerWithComponents. +func (mr *MockControllerMockRecorder) GetValidatedPeerWithComponents(ctx, isRequiresApproval, accountID, p any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeerWithComponents", reflect.TypeOf((*MockController)(nil).GetValidatedPeerWithComponents), ctx, isRequiresApproval, accountID, p) +} + // GetValidatedPeerWithMap mocks base method. -func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) { +func (m *MockController) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID, peerID string) (*types.NetworkMap, []*posture.Checks, int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, peerID) ret0, _ := ret[0].(*types.NetworkMap) @@ -171,7 +190,7 @@ func (mr *MockControllerMockRecorder) OnPeerDisconnected(ctx, accountID, peerID } // OnPeersAdded mocks base method. -func (m *MockController) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error { +func (m *MockController) OnPeersAdded(ctx context.Context, accountID string, peerIDs, affectedPeerIDs []string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OnPeersAdded", ctx, accountID, peerIDs, affectedPeerIDs) ret0, _ := ret[0].(error) @@ -185,7 +204,7 @@ func (mr *MockControllerMockRecorder) OnPeersAdded(ctx, accountID, peerIDs, affe } // OnPeersDeleted mocks base method. -func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error { +func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs, affectedPeerIDs []string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OnPeersDeleted", ctx, accountID, peerIDs, affectedPeerIDs) ret0, _ := ret[0].(error) @@ -199,7 +218,7 @@ func (mr *MockControllerMockRecorder) OnPeersDeleted(ctx, accountID, peerIDs, af } // OnPeersUpdated mocks base method. -func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string, affectedPeerIDs []string) error { +func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs, affectedPeerIDs []string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OnPeersUpdated", ctx, accountId, peerIDs, affectedPeerIDs) ret0, _ := ret[0].(error) diff --git a/management/internals/modules/agentnetwork/accesslog_ingest.go b/management/internals/modules/agentnetwork/accesslog_ingest.go new file mode 100644 index 000000000..ecc1780f3 --- /dev/null +++ b/management/internals/modules/agentnetwork/accesslog_ingest.go @@ -0,0 +1,230 @@ +package agentnetwork + +import ( + "context" + "math" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/server/store" +) + +// Metadata keys the proxy stamps on agent-network access-log entries. These +// mirror the constants in proxy/internal/middleware/keys.go and form the wire +// contract between the proxy and management; management flattens them into +// queryable columns. Keep in sync with the proxy side. +const ( + metaKeyProvider = "llm.provider" + metaKeyModel = "llm.model" + metaKeyResolvedProviderID = "llm.resolved_provider_id" + metaKeySelectedPolicyID = "llm.selected_policy_id" + metaKeyPolicyDecision = "llm_policy.decision" + metaKeyPolicyReason = "llm_policy.reason" + metaKeyInputTokens = "llm.input_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyOutputTokens = "llm.output_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyTotalTokens = "llm.total_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyCachedInputTokens = "llm.cached_input_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyCacheCreationTokens = "llm.cache_creation_tokens" //nolint:gosec // metadata key name, not a credential + metaKeyCostUSDInput = "cost.usd_input" + metaKeyCostUSDCachedInput = "cost.usd_cached_input" + metaKeyCostUSDCacheCreate = "cost.usd_cache_creation" + metaKeyCostUSDOutput = "cost.usd_output" + metaKeyStream = "llm.stream" + metaKeySessionID = "llm.session_id" + metaKeyAuthorisingGroups = "llm.authorising_groups" + metaKeyRequestPrompt = "llm.request_prompt" + metaKeyResponseCompletion = "llm.response_completion" +) + +// IngestAccessLog flattens the metadata-bearing reverse-proxy access-log entry +// and persists it in the dedicated agent-network tables (instead of the shared +// reverse-proxy table), in two parts: +// +// - The stripped usage record is written unconditionally — usage/cost is +// collected on every request regardless of the account's log-collection +// toggle (the proxy ships a usage-only entry when logging is disabled). +// - The full access-log row (with request detail + prompt) is written only +// when the account's EnableLogCollection setting is on. This setting read +// is the authoritative gate; the proxy-side strip is defense in depth. +func IngestAccessLog(ctx context.Context, s store.Store, logEntry *accesslogs.AccessLogEntry) error { + entry, groups := flattenAccessLog(logEntry) + + usage, usageGroups := usageFromFlattenedLog(entry, groups) + if err := s.CreateAgentNetworkUsage(ctx, usage, usageGroups); err != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "account_id": entry.AccountID, + "model": entry.Model, + }).Errorf("failed to save agent-network usage: %v", err) + return err + } + + settings, err := s.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, entry.AccountID) + if err != nil { + // No settings row (or a transient read error) means we can't confirm + // log collection is enabled — usage is already saved, so skip the full + // row rather than fail the whole ingest. + log.WithContext(ctx).Debugf("skipping full agent-network access-log row for account %s: %v", entry.AccountID, err) + return nil + } + if !settings.EnableLogCollection { + return nil + } + + if err := s.CreateAgentNetworkAccessLog(ctx, entry, groups); err != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "account_id": entry.AccountID, + "service_id": entry.ServiceID, + "model": entry.Model, + "status": entry.StatusCode, + }).Errorf("failed to save agent-network access log: %v", err) + return err + } + return nil +} + +// flattenAccessLog converts a reverse-proxy AccessLogEntry (whose LLM +// dimensions live in the opaque Metadata map) into the flattened +// agent-network row + authorising-group child rows. +func flattenAccessLog(e *accesslogs.AccessLogEntry) (*types.AgentNetworkAccessLog, []types.AgentNetworkAccessLogGroup) { + meta := e.Metadata + + var sourceIP string + if e.GeoLocation.ConnectionIP != nil { + sourceIP = e.GeoLocation.ConnectionIP.String() + } + + entry := &types.AgentNetworkAccessLog{ + ID: e.ID, + AccountID: e.AccountID, + ServiceID: e.ServiceID, + Timestamp: e.Timestamp, + UserID: e.UserId, + SourceIP: sourceIP, + Method: e.Method, + Host: e.Host, + Path: e.Path, + Duration: e.Duration, + StatusCode: e.StatusCode, + AuthMethod: e.AuthMethodUsed, + BytesUpload: e.BytesUpload, + BytesDownload: e.BytesDownload, + + Provider: meta[metaKeyProvider], + Model: meta[metaKeyModel], + SessionID: meta[metaKeySessionID], + ResolvedProviderID: meta[metaKeyResolvedProviderID], + SelectedPolicyID: meta[metaKeySelectedPolicyID], + Decision: meta[metaKeyPolicyDecision], + DenyReason: meta[metaKeyPolicyReason], + InputTokens: parseMetaInt(meta, metaKeyInputTokens), + OutputTokens: parseMetaInt(meta, metaKeyOutputTokens), + TotalTokens: parseMetaInt(meta, metaKeyTotalTokens), + CachedInputTokens: parseMetaInt(meta, metaKeyCachedInputTokens), + CacheCreationTokens: parseMetaInt(meta, metaKeyCacheCreationTokens), + InputCostUSD: parseMetaFloat(meta, metaKeyCostUSDInput), + CachedInputCostUSD: parseMetaFloat(meta, metaKeyCostUSDCachedInput), + CacheCreationCostUSD: parseMetaFloat(meta, metaKeyCostUSDCacheCreate), + OutputCostUSD: parseMetaFloat(meta, metaKeyCostUSDOutput), + Stream: parseMetaBool(meta, metaKeyStream), + RequestPrompt: meta[metaKeyRequestPrompt], + ResponseCompletion: meta[metaKeyResponseCompletion], + } + + var groups []types.AgentNetworkAccessLogGroup + for _, gid := range parseGroupCSV(meta[metaKeyAuthorisingGroups]) { + groups = append(groups, types.AgentNetworkAccessLogGroup{ + LogID: entry.ID, + GroupID: gid, + AccountID: entry.AccountID, + }) + } + return entry, groups +} + +// usageFromFlattenedLog derives the stripped usage record (and its group child +// rows) from an already-flattened access-log entry. The usage row shares the +// log's ID so the two correlate. +func usageFromFlattenedLog(e *types.AgentNetworkAccessLog, groups []types.AgentNetworkAccessLogGroup) (*types.AgentNetworkUsage, []types.AgentNetworkUsageGroup) { + usage := &types.AgentNetworkUsage{ + ID: e.ID, + AccountID: e.AccountID, + Timestamp: e.Timestamp, + UserID: e.UserID, + ResolvedProviderID: e.ResolvedProviderID, + Provider: e.Provider, + Model: e.Model, + SessionID: e.SessionID, + InputTokens: e.InputTokens, + OutputTokens: e.OutputTokens, + TotalTokens: e.TotalTokens, + CachedInputTokens: e.CachedInputTokens, + CacheCreationTokens: e.CacheCreationTokens, + InputCostUSD: e.InputCostUSD, + CachedInputCostUSD: e.CachedInputCostUSD, + CacheCreationCostUSD: e.CacheCreationCostUSD, + OutputCostUSD: e.OutputCostUSD, + } + + usageGroups := make([]types.AgentNetworkUsageGroup, 0, len(groups)) + for _, g := range groups { + usageGroups = append(usageGroups, types.AgentNetworkUsageGroup{ + UsageID: usage.ID, + GroupID: g.GroupID, + AccountID: g.AccountID, + }) + } + return usage, usageGroups +} + +// parseMetaInt parses a non-negative token count. Negative or unparseable +// values are clamped to 0 so a malformed metric can't persist a negative +// counter. +func parseMetaInt(meta map[string]string, key string) int64 { + if v, err := strconv.ParseInt(strings.TrimSpace(meta[key]), 10, 64); err == nil && v >= 0 { + return v + } + return 0 +} + +// parseMetaFloat parses a non-negative, finite cost. Negative, NaN, Inf, or +// unparseable values are clamped to 0 so a malformed metric can't poison the +// stored cost. +func parseMetaFloat(meta map[string]string, key string) float64 { + if v, err := strconv.ParseFloat(strings.TrimSpace(meta[key]), 64); err == nil && v >= 0 && !math.IsInf(v, 0) { + return v + } + return 0 +} + +func parseMetaBool(meta map[string]string, key string) bool { + v, _ := strconv.ParseBool(strings.TrimSpace(meta[key])) + return v +} + +// parseGroupCSV splits the comma-separated authorising-group id list the proxy +// emits, trimming blanks and de-duplicating. Dedup matters because the group +// rows are keyed by (log_id, group_id) / (usage_id, group_id): a repeated id +// in the CSV would otherwise produce a duplicate primary key and fail the +// insert transaction. +func parseGroupCSV(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + if _, dup := seen[p]; dup { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + } + return out +} diff --git a/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go b/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go new file mode 100644 index 000000000..cd81cfbe4 --- /dev/null +++ b/management/internals/modules/agentnetwork/accesslog_ingest_realstore_test.go @@ -0,0 +1,149 @@ +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/server/store" +) + +// newIngestTestEntry builds an agent-network reverse-proxy access-log entry whose +// LLM dimensions live in the opaque Metadata map, as the proxy ships it. +func newIngestTestEntry() *accesslogs.AccessLogEntry { + return &accesslogs.AccessLogEntry{ + ID: "log-1", + AccountID: testAccountID, + ServiceID: "svc-1", + Timestamp: time.Now().UTC(), + Method: "POST", + Host: testEndpoint, + Path: "/v1/chat/completions", + StatusCode: 200, + UserId: "user-1", + AgentNetwork: true, + Metadata: map[string]string{ + metaKeyProvider: "openai", + metaKeyModel: "gpt-5.4", + metaKeyResolvedProviderID: "prov-1", + metaKeySessionID: "sess-1", + metaKeyInputTokens: "100", + metaKeyOutputTokens: "50", + metaKeyTotalTokens: "1174", + metaKeyCachedInputTokens: "256", + metaKeyCacheCreationTokens: "768", + metaKeyCostUSDInput: "0.0071", + metaKeyCostUSDCachedInput: "0.0009", + metaKeyCostUSDCacheCreate: "0.0020", + metaKeyCostUSDOutput: "0.0023", + metaKeyStream: "true", + metaKeyRequestPrompt: "hello", + metaKeyResponseCompletion: "world", + // repeated id must be de-duplicated before the group rows insert. + metaKeyAuthorisingGroups: "grp-eng,grp-eng,grp-ops", + }, + } +} + +// TestIngestAccessLog_RealStore_LogCollectionOff persists the usage ledger +// unconditionally but skips the full access-log row when the account hasn't +// opted into log collection. +func TestIngestAccessLog_RealStore_LogCollectionOff(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + settings := newSynthTestSettings() + settings.EnableLogCollection = false + require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings)) + + require.NoError(t, IngestAccessLog(ctx, s, newIngestTestEntry())) + + usage, err := s.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + require.Len(t, usage, 1, "usage row must be written even with log collection off") + assert.Equal(t, int64(100), usage[0].InputTokens, "input tokens must round-trip from metadata") + assert.Equal(t, int64(50), usage[0].OutputTokens, "output tokens must round-trip from metadata") + assert.Equal(t, int64(256), usage[0].CachedInputTokens, "cache-read tokens must round-trip from metadata") + assert.Equal(t, int64(768), usage[0].CacheCreationTokens, "cache-write tokens must round-trip from metadata") + // The per-bucket breakdown is the only cost state stored, and must survive + // the write/read cycle as real columns — usage rows are the only cost + // record for accounts with log collection off, so a dropped column here + // loses the split permanently. + assert.InDelta(t, 0.0071, usage[0].InputCostUSD, 1e-9, "input cost must round-trip from metadata") + assert.InDelta(t, 0.0009, usage[0].CachedInputCostUSD, 1e-9, "cache-read cost must round-trip from metadata") + assert.InDelta(t, 0.0020, usage[0].CacheCreationCostUSD, 1e-9, "cache-write cost must round-trip from metadata") + assert.InDelta(t, 0.0023, usage[0].OutputCostUSD, 1e-9, "output cost must round-trip from metadata") + // Aggregates are derived from the stored columns, never stored themselves. + assert.InDelta(t, 0.0123, usage[0].TotalCostUSD(), 1e-9, "total is derived from the stored buckets") + assert.InDelta(t, 0.0029, usage[0].CacheCostUSD(), 1e-9, "cache cost is derived from the two cache buckets") + + logs, _, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + assert.Empty(t, logs, "full access-log row must be skipped while log collection is off") +} + +// TestIngestAccessLog_RealStore_LogCollectionOn writes both the usage ledger and +// the full access-log row once the account opts in, carrying the request detail +// and prompt through. +func TestIngestAccessLog_RealStore_LogCollectionOn(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + settings := newSynthTestSettings() + settings.EnableLogCollection = true + require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings)) + + require.NoError(t, IngestAccessLog(ctx, s, newIngestTestEntry())) + + usage, err := s.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + require.Len(t, usage, 1, "usage row must be written when log collection is on") + + logs, total, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + require.Equal(t, int64(1), total, "exactly one access-log row expected") + require.Len(t, logs, 1, "full access-log row must be written when log collection is on") + assert.Equal(t, "gpt-5.4", logs[0].Model, "model must flatten from metadata") + assert.Equal(t, int64(256), logs[0].CachedInputTokens, "cache-read tokens must flatten from metadata") + assert.Equal(t, int64(768), logs[0].CacheCreationTokens, "cache-write tokens must flatten from metadata") + assert.InDelta(t, 0.0029, logs[0].CacheCostUSD(), 1e-9, "cache cost is derived from the two cache buckets") + assert.InDelta(t, 0.0123, logs[0].TotalCostUSD(), 1e-9, "total is derived from the stored buckets") + assert.InDelta(t, 0.0071, logs[0].InputCostUSD, 1e-9, "input cost must flatten from metadata") + assert.InDelta(t, 0.0009, logs[0].CachedInputCostUSD, 1e-9, "cache-read cost must flatten from metadata") + assert.InDelta(t, 0.0020, logs[0].CacheCreationCostUSD, 1e-9, "cache-write cost must flatten from metadata") + assert.InDelta(t, 0.0023, logs[0].OutputCostUSD, 1e-9, "output cost must flatten from metadata") + assert.Equal(t, "hello", logs[0].RequestPrompt, "prompt must be retained when log collection is on") + assert.Equal(t, "world", logs[0].ResponseCompletion, "completion must be retained when log collection is on") + assert.True(t, logs[0].Stream, "stream flag must flatten from metadata") +} + +func TestParseGroupCSV_DedupAndTrim(t *testing.T) { + assert.Nil(t, parseGroupCSV(""), "empty CSV yields no groups") + assert.Equal(t, []string{"a", "b"}, parseGroupCSV(" a , b , a ,"), + "group CSV must trim, drop blanks, and de-duplicate preserving first-seen order") +} + +func TestParseMetaInt_ClampsNegativeAndJunk(t *testing.T) { + meta := map[string]string{"ok": " 42 ", "neg": "-5", "junk": "abc"} + assert.Equal(t, int64(42), parseMetaInt(meta, "ok"), "valid count parses with surrounding space trimmed") + assert.Equal(t, int64(0), parseMetaInt(meta, "neg"), "negative count clamps to 0") + assert.Equal(t, int64(0), parseMetaInt(meta, "junk"), "unparseable count clamps to 0") + assert.Equal(t, int64(0), parseMetaInt(meta, "missing"), "missing key clamps to 0") +} + +func TestParseMetaFloat_ClampsNegativeInfAndJunk(t *testing.T) { + meta := map[string]string{"ok": "1.5", "neg": "-1", "inf": "Inf", "junk": "x"} + assert.InDelta(t, 1.5, parseMetaFloat(meta, "ok"), 1e-9, "valid cost parses") + assert.Equal(t, float64(0), parseMetaFloat(meta, "neg"), "negative cost clamps to 0") + assert.Equal(t, float64(0), parseMetaFloat(meta, "inf"), "non-finite cost clamps to 0") + assert.Equal(t, float64(0), parseMetaFloat(meta, "junk"), "unparseable cost clamps to 0") +} diff --git a/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go b/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go new file mode 100644 index 000000000..94518c2f7 --- /dev/null +++ b/management/internals/modules/agentnetwork/accesslog_sessions_realstore_test.go @@ -0,0 +1,343 @@ +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/store" +) + +// baseTime is a fixed reference so session timestamps (and therefore the +// default MAX(timestamp) DESC ordering) are deterministic across runs. +var baseTime = time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + +// accessLogRow builds an agent-network access-log row for the shared test +// account. Functional options tweak the LLM dimensions a given test cares +// about; everything else gets a sane, allow/200 default. +func accessLogRow(id, sessionID string, ts time.Time, opts ...func(*types.AgentNetworkAccessLog)) *types.AgentNetworkAccessLog { + e := &types.AgentNetworkAccessLog{ + ID: id, + AccountID: testAccountID, + ServiceID: "svc-1", + Timestamp: ts, + UserID: "user-1", + SessionID: sessionID, + Method: "POST", + Host: testEndpoint, + Path: "/v1/chat/completions", + StatusCode: 200, + Decision: "allow", + Provider: "openai", + Model: "gpt-5.4", + ResolvedProviderID: "prov-1", + InputTokens: 100, + OutputTokens: 50, + TotalTokens: 150, + InputCostUSD: 0.01, + } + for _, o := range opts { + o(e) + } + return e +} + +func withUser(u string) func(*types.AgentNetworkAccessLog) { + return func(e *types.AgentNetworkAccessLog) { e.UserID = u } +} + +func withModel(m string) func(*types.AgentNetworkAccessLog) { + return func(e *types.AgentNetworkAccessLog) { e.Model = m } +} + +func withProvider(vendor, resolvedID string) func(*types.AgentNetworkAccessLog) { + return func(e *types.AgentNetworkAccessLog) { + e.Provider = vendor + e.ResolvedProviderID = resolvedID + } +} + +func withDeny(reason string) func(*types.AgentNetworkAccessLog) { + return func(e *types.AgentNetworkAccessLog) { + e.Decision = "deny" + e.DenyReason = reason + e.StatusCode = 403 + } +} + +func withTokens(in, out, total int64, cost float64) func(*types.AgentNetworkAccessLog) { + return func(e *types.AgentNetworkAccessLog) { + e.InputTokens = in + e.OutputTokens = out + e.TotalTokens = total + e.InputCostUSD = cost + } +} + +func withGroups(gids ...string) func(*types.AgentNetworkAccessLog) { + return func(e *types.AgentNetworkAccessLog) { e.GroupIDs = gids } +} + +// seedAccessLogs writes rows (and their authorising-group child rows) directly +// into the store, bypassing ingest so a test can control every dimension. +func seedAccessLogs(t *testing.T, s store.Store, rows ...*types.AgentNetworkAccessLog) { + t.Helper() + ctx := context.Background() + for _, r := range rows { + var groups []types.AgentNetworkAccessLogGroup + for _, g := range r.GroupIDs { + groups = append(groups, types.AgentNetworkAccessLogGroup{ + LogID: r.ID, + GroupID: g, + AccountID: r.AccountID, + }) + } + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, r, groups), "seed access-log row %s", r.ID) + } +} + +func newSessionsTestStore(t *testing.T) store.Store { + t.Helper() + s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + t.Cleanup(cleanup) + return s +} + +// sessionIDs projects the session ids from a page of session summaries, in +// order, for concise ordering assertions. +func sessionIDs(sessions []*types.AgentNetworkAccessLogSession) []string { + out := make([]string, 0, len(sessions)) + for _, s := range sessions { + out = append(out, s.SessionID) + } + return out +} + +// TestAccessLogSessions_FoldAndAggregate verifies that multiple entries sharing +// a session id fold into one summary with summed usage, distinct +// provider/model lists, a deny rollup, and correct first/last activity bounds. +func TestAccessLogSessions_FoldAndAggregate(t *testing.T) { + ctx := context.Background() + s := newSessionsTestStore(t) + + // sess-a: three entries spanning 3 minutes, two providers/models, one deny. + seedAccessLogs(t, s, + accessLogRow("a1", "sess-a", baseTime, + withProvider("openai", "prov-openai"), withModel("gpt-5.4"), + withTokens(100, 50, 150, 0.01), withGroups("grp-eng")), + accessLogRow("a2", "sess-a", baseTime.Add(1*time.Minute), + withProvider("anthropic", "prov-anthropic"), withModel("claude-haiku-4-5"), + withTokens(200, 80, 280, 0.02), withGroups("grp-eng", "grp-ops")), + accessLogRow("a3", "sess-a", baseTime.Add(2*time.Minute), + withProvider("openai", "prov-openai"), withModel("gpt-5.4"), + withTokens(10, 5, 15, 0.001), withDeny("llm_policy.token_cap_exceeded")), + // sess-b: a single allow entry. + accessLogRow("b1", "sess-b", baseTime.Add(30*time.Minute), + withTokens(1, 2, 3, 0.5)), + ) + + sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + require.Equal(t, int64(2), total, "two distinct sessions") + require.Len(t, sessions, 2) + + // Default sort is last-activity DESC, so sess-b (12:30) precedes sess-a (12:02). + require.Equal(t, []string{"sess-b", "sess-a"}, sessionIDs(sessions)) + + a := sessions[1] + assert.Equal(t, "sess-a", a.SessionID) + assert.Equal(t, 3, a.RequestCount, "three requests folded") + assert.Equal(t, int64(310), a.InputTokens, "input tokens summed") + assert.Equal(t, int64(135), a.OutputTokens, "output tokens summed") + assert.Equal(t, int64(445), a.TotalTokens, "total tokens summed") + assert.InDelta(t, 0.031, a.TotalCostUSD(), 1e-9, "cost summed") + assert.Equal(t, "deny", a.Decision, "any deny makes the session a deny") + assert.ElementsMatch(t, []string{"openai", "anthropic"}, a.Providers, "distinct providers") + assert.ElementsMatch(t, []string{"gpt-5.4", "claude-haiku-4-5"}, a.Models, "distinct models") + assert.ElementsMatch(t, []string{"grp-eng", "grp-ops"}, a.GroupIDs, "union of authorising groups") + assert.Equal(t, baseTime, a.StartedAt.UTC(), "started at is the earliest entry") + assert.Equal(t, baseTime.Add(2*time.Minute), a.EndedAt.UTC(), "ended at is the latest entry") + assert.Len(t, a.Entries, 3, "entries carried through") + + b := sessions[0] + assert.Equal(t, "sess-b", b.SessionID) + assert.Equal(t, 1, b.RequestCount) + assert.Equal(t, "allow", b.Decision) +} + +// TestAccessLogSessions_SessionlessRowsAreSingletons verifies that entries with +// no session id each form their own singleton session keyed by the row id. +func TestAccessLogSessions_SessionlessRowsAreSingletons(t *testing.T) { + ctx := context.Background() + s := newSessionsTestStore(t) + + seedAccessLogs(t, s, + accessLogRow("solo-1", "", baseTime), + accessLogRow("solo-2", "", baseTime.Add(time.Minute)), + // A real session with two entries, to prove they don't merge with the singletons. + accessLogRow("g1", "sess-x", baseTime.Add(2*time.Minute)), + accessLogRow("g2", "sess-x", baseTime.Add(3*time.Minute)), + ) + + sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + require.Equal(t, int64(3), total, "two singletons + one grouped session") + require.Len(t, sessions, 3) + + for _, sess := range sessions { + if sess.SessionID == "sess-x" { + assert.Equal(t, 2, sess.RequestCount, "grouped session folds both entries") + } else { + assert.Empty(t, sess.SessionID, "singleton carries no session id") + assert.Equal(t, 1, sess.RequestCount, "singleton has exactly one request") + } + } +} + +// TestAccessLogSessions_Pagination verifies that paging returns the correct +// slice of sessions in stable order, with a stable total across pages and no +// overlap between pages. +func TestAccessLogSessions_Pagination(t *testing.T) { + ctx := context.Background() + s := newSessionsTestStore(t) + + // Five sessions, each a single entry, with increasing timestamps so the + // default MAX(timestamp) DESC order is sess-5, sess-4, sess-3, sess-2, sess-1. + rows := make([]*types.AgentNetworkAccessLog, 0, 5) + for i := 1; i <= 5; i++ { + rows = append(rows, accessLogRow( + "row-"+itoa(i), "sess-"+itoa(i), baseTime.Add(time.Duration(i)*time.Minute))) + } + seedAccessLogs(t, s, rows...) + + page := func(p int) []*types.AgentNetworkAccessLogSession { + sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, + types.AgentNetworkAccessLogFilter{Page: p, PageSize: 2}) + require.NoError(t, err) + require.Equal(t, int64(5), total, "total session count is stable across pages") + return sessions + } + + assert.Equal(t, []string{"sess-5", "sess-4"}, sessionIDs(page(1)), "page 1: two newest") + assert.Equal(t, []string{"sess-3", "sess-2"}, sessionIDs(page(2)), "page 2: next two") + assert.Equal(t, []string{"sess-1"}, sessionIDs(page(3)), "page 3: remaining one") + assert.Empty(t, page(4), "page 4: past the end is empty") +} + +// TestAccessLogSessions_Filtering verifies each filter is applied before +// grouping, so the session set (and total) reflect only matching entries. +func TestAccessLogSessions_Filtering(t *testing.T) { + ctx := context.Background() + s := newSessionsTestStore(t) + + seedAccessLogs(t, s, + accessLogRow("r1", "sess-1", baseTime.Add(1*time.Minute), + withUser("alice"), withProvider("openai", "prov-openai"), withModel("gpt-5.4")), + accessLogRow("r2", "sess-2", baseTime.Add(2*time.Minute), + withUser("bob"), withProvider("anthropic", "prov-anthropic"), withModel("claude-haiku-4-5"), + withDeny("llm_policy.no_authorized_provider"), withGroups("grp-ops")), + accessLogRow("r3", "sess-3", baseTime.Add(3*time.Minute), + withUser("alice"), withProvider("openai", "prov-openai"), withModel("gpt-5.4"), + withGroups("grp-eng")), + ) + + filterCases := []struct { + name string + filter types.AgentNetworkAccessLogFilter + wantIDs []string + wantTot int64 + }{ + { + name: "by session id", + filter: types.AgentNetworkAccessLogFilter{SessionID: strp("sess-2")}, + wantIDs: []string{"sess-2"}, + wantTot: 1, + }, + { + name: "by user id", + filter: types.AgentNetworkAccessLogFilter{UserID: strp("alice")}, + wantIDs: []string{"sess-3", "sess-1"}, // last-activity DESC + wantTot: 2, + }, + { + name: "by model", + filter: types.AgentNetworkAccessLogFilter{Models: []string{"claude-haiku-4-5"}}, + wantIDs: []string{"sess-2"}, + wantTot: 1, + }, + { + name: "by resolved provider id", + filter: types.AgentNetworkAccessLogFilter{ProviderIDs: []string{"prov-openai"}}, + wantIDs: []string{"sess-3", "sess-1"}, + wantTot: 2, + }, + { + name: "by decision deny", + filter: types.AgentNetworkAccessLogFilter{Decision: strp("deny")}, + wantIDs: []string{"sess-2"}, + wantTot: 1, + }, + { + name: "by authorising group", + filter: types.AgentNetworkAccessLogFilter{GroupIDs: []string{"grp-eng"}}, + wantIDs: []string{"sess-3"}, + wantTot: 1, + }, + { + name: "by date range excludes earlier", + filter: types.AgentNetworkAccessLogFilter{ + StartDate: tp(baseTime.Add(90 * time.Second)), // after r1 (12:01), before r2 (12:02) + }, + wantIDs: []string{"sess-3", "sess-2"}, + wantTot: 2, + }, + } + + for _, tc := range filterCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, tc.filter) + require.NoError(t, err) + assert.Equal(t, tc.wantTot, total, "filtered total") + assert.Equal(t, tc.wantIDs, sessionIDs(sessions), "filtered session ids in order") + }) + } +} + +// TestAccessLogSessions_SortByCost verifies session-level aggregate sorting: +// ordering by summed cost, ascending and descending. +func TestAccessLogSessions_SortByCost(t *testing.T) { + ctx := context.Background() + s := newSessionsTestStore(t) + + // cheap: 0.01 total; mid: 0.05 total; pricey: 0.20 total (two entries). + seedAccessLogs(t, s, + accessLogRow("c1", "cheap", baseTime.Add(1*time.Minute), withTokens(1, 1, 2, 0.01)), + accessLogRow("m1", "mid", baseTime.Add(2*time.Minute), withTokens(1, 1, 2, 0.05)), + accessLogRow("p1", "pricey", baseTime.Add(3*time.Minute), withTokens(1, 1, 2, 0.15)), + accessLogRow("p2", "pricey", baseTime.Add(4*time.Minute), withTokens(1, 1, 2, 0.05)), + ) + + desc, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, + types.AgentNetworkAccessLogFilter{SortBy: "cost_usd", SortOrder: "desc"}) + require.NoError(t, err) + require.Equal(t, int64(3), total) + assert.Equal(t, []string{"pricey", "mid", "cheap"}, sessionIDs(desc), "descending by summed cost") + + asc, _, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, + types.AgentNetworkAccessLogFilter{SortBy: "cost_usd", SortOrder: "asc"}) + require.NoError(t, err) + assert.Equal(t, []string{"cheap", "mid", "pricey"}, sessionIDs(asc), "ascending by summed cost") +} + +// strp / tp / itoa are tiny local helpers to keep the filter table terse. +func strp(s string) *string { return &s } + +func tp(t time.Time) *time.Time { return &t } + +func itoa(i int) string { return string(rune('0' + i)) } diff --git a/management/internals/modules/agentnetwork/affectedpeers_hook.go b/management/internals/modules/agentnetwork/affectedpeers_hook.go new file mode 100644 index 000000000..58347666d --- /dev/null +++ b/management/internals/modules/agentnetwork/affectedpeers_hook.go @@ -0,0 +1,15 @@ +package agentnetwork + +import "github.com/netbirdio/netbird/management/server/affectedpeers" + +// init registers the agent-network service synthesiser with the affectedpeers +// resolver. Agent-network reverse-proxy services are synthesised on demand and +// never persisted, so the resolver can't load them from the store; without them +// it can't fold the embedded proxy peer into the affected set on a client +// group/peer change, and the proxy never learns a newly authorised client until +// it reconnects. Registered here (rather than via a direct +// affectedpeers→agentnetwork import) to avoid an import cycle +// (agentnetwork → account → affectedpeers). +func init() { + affectedpeers.SetAgentNetworkSynthesizer(SynthesizeServices) +} diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go new file mode 100644 index 000000000..3c7b995e5 --- /dev/null +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -0,0 +1,989 @@ +// Package catalog defines the static set of Agent Network providers +// recognized by the management server. The catalog is consulted both to +// validate provider_id on create/update and to surface the available +// providers (and their models) to the dashboard. +package catalog + +import "github.com/netbirdio/netbird/shared/management/http/api" + +// Model is the in-memory representation of a catalog model. +// +// The three cache rates mirror the proxy cost meter's Entry semantics +// (USD per 1k tokens; 0 = no rate configured, that bucket bills at +// InputPer1k): +// - CachedInputPer1k: OpenAI-shape rate for cached prompt tokens +// (a SUBSET of input tokens). Typically 0.1-0.5x input. +// - CacheReadPer1k / CacheCreationPer1k: Anthropic-shape rates for +// the two ADDITIVE prompt-cache buckets. Typically 0.1x / 1.25x +// input. +// +// The catalog is the single default-pricing source: the agentnetwork +// pricing package folds these models into per-surface tables that the +// synthesizer ships to the proxy's cost_meter. +type Model struct { + ID string + Label string + InputPer1k float64 + OutputPer1k float64 + CachedInputPer1k float64 + CacheReadPer1k float64 + CacheCreationPer1k float64 + ContextWindow int +} + +// ProviderKind groups catalog entries for UI presentation. The split +// is semantic, not technical: +// - KindProvider: the upstream is a vendor's first-party API (OpenAI, +// Anthropic, Mistral, Bedrock, etc.) — NetBird talks straight to +// the model provider. +// - KindGateway: the upstream is itself a routing / aggregation layer +// in front of multiple providers (LiteLLM, Portkey, Helicone, …). +// These typically need NetBird identity stamped onto upstream +// requests so the gateway's analytics and budgets attribute to the +// real caller; that's what IdentityInjection is for. +// - KindCustom: the catch-all "OpenAI-compatible self-hosted endpoint" +// entry (vLLM, Ollama, custom inference servers). +// +// Frontend uses Kind to group the provider Select in the modal so an +// operator can spot at a glance which catalog entries proxy other +// providers vs. talk straight to one. Backend doesn't dispatch on Kind +// today; it's purely a presentation hint. +type ProviderKind string + +const ( + KindProvider ProviderKind = "provider" + KindGateway ProviderKind = "gateway" + KindCustom ProviderKind = "custom" +) + +// Provider is the in-memory representation of a catalog provider. +type Provider struct { + ID string + Name string + Description string + DefaultHost string + // Kind groups this entry for UI presentation; see ProviderKind. + Kind ProviderKind + // AuthHeaderName is the HTTP header the provider's API expects + // the credential under (e.g. "Authorization" for OpenAI, + // "x-api-key" for Anthropic). Combined with AuthHeaderTemplate + // at synthesis time to inject the auth header on every upstream + // request. + AuthHeaderName string + AuthHeaderTemplate string + DefaultContentType string + BrandColor string + // ParserID names the proxy LLM parser surface this provider + // speaks (matches llm.Parser.ProviderName: "openai", + // "anthropic"). Multiple catalog ids may share a parser surface + // (e.g. azure_openai_api and mistral_api both speak the OpenAI + // shape). Empty when no parser is yet implemented for the + // surface — the proxy middleware then falls back to URL sniffing + // or skips request-side enrichment. + ParserID string + // PricingSurfaces names the cost-meter pricing surfaces this + // provider's Models are priced under ("openai", "anthropic", + // "bedrock" — the llm.Parser surface the request parser stamps as + // llm.provider at billing time). NOT derivable from ParserID: + // bedrock_api and vertex_ai_api leave ParserID empty (URL-sniffed) + // yet price under "bedrock" / "anthropic", and kimi_api serves two + // body shapes so it prices under both. Nil for gateway/custom + // entries, which declare no models. Same (surface, model) pair + // contributed by two providers must carry identical rates — the + // pricing package's tests enforce that. + PricingSurfaces []string + // IdentityInjection, when non-nil, instructs the proxy to stamp + // the caller's NetBird identity onto upstream requests under the + // configured header names. Used for gateways like LiteLLM that + // key budgets and attribution off request headers (the gateway + // otherwise has no way to learn which user / group made the call). + // The proxy strips the same header names from the inbound request + // before stamping ours, so an app can't spoof identity by setting + // these headers itself. + IdentityInjection *IdentityInjection + // ExtraHeaders is a catalog-declared list of additional per- + // provider routing/config headers the proxy stamps on every + // upstream request. Distinct from AuthHeaderName/Template (which + // always carries the API_KEY) and from IdentityInjection (caller + // identity). Each entry surfaces an optional input on the + // dashboard's provider modal whose value lives on the provider + // record's ExtraValues map (keyed by ExtraHeader.Name). Empty + // list = no extra inputs rendered. Used today by Portkey for + // "x-portkey-config: pc-..." (a saved-config id that resolves + // upstream provider + credentials on Portkey's hosted side). + ExtraHeaders []ExtraHeader + Models []Model + // Discovery, when non-nil, describes how to ask this vendor which + // models the operator's own credential can actually reach, so the + // provider form can offer a live list instead of only the hand-curated + // Models above. Nil for entries with no listing endpoint (gateways + // vary too much) — those keep free-text entry. + Discovery *Discovery +} + +// ListingShape names the response envelope a vendor returns its model +// listing in. Every vendor invented its own, and none of them can be +// guessed from the request, so the catalog states it. +type ListingShape string + +const ( + // ShapeOpenAIData is {"data":[{"id":…}]} — OpenAI, and Anthropic, which + // adopted the same envelope. + ShapeOpenAIData ListingShape = "openai_data" + // ShapeBedrockInferenceProfiles is + // {"inferenceProfileSummaries":[{"inferenceProfileId":…}]}. The ids carry + // the region prefix that makes them invocable, which is exactly what an + // operator cannot reconstruct by hand. + ShapeBedrockInferenceProfiles ListingShape = "bedrock_inference_profiles" + // ShapeVertexPublisherModels is {"publisherModels":[{"name":…}]}, where + // name is a resource path and the invocable id is its last segment joined + // to a separate versionId field. + ShapeVertexPublisherModels ListingShape = "vertex_publisher_models" +) + +// Discovery describes one vendor's model-listing endpoint. +// +// Host is deliberately separate from the provider record's upstream URL: +// Bedrock serves listings from the control plane (bedrock.) while +// inference must go to the runtime host (bedrock-runtime.), so the +// two cannot be the same value. Empty Host means "use the record's own +// upstream", which is right for every vendor that serves both from one host. +// +// The regionPlaceholder in Host is substituted from the provider record's +// region. Deriving the discovery host from the catalog rather than accepting +// one from the caller is also what keeps this from being an open proxy: the +// only hosts management will dial are the ones written here. +type Discovery struct { + Host string + Path string + Query string + Shape ListingShape + // Headers are static headers the vendor requires beyond the credential + // (Anthropic versions its API through one and rejects a request without + // it). The auth header itself comes from AuthHeaderName/Template. + Headers map[string]string +} + +// RegionPlaceholder is replaced in Discovery.Host by the provider record's +// configured region. +const RegionPlaceholder = "" + +// ExtraHeader names a single optional per-provider routing/config +// header. Catalog declares N of these per provider type; the operator +// fills any subset on the provider record (see Provider.ExtraValues). +// At synth time, only entries with a non-empty operator value are +// stamped; the proxy's identity-inject middleware applies anti-spoof +// (Remove + Add) so a client can't supply these headers themselves. +// +// UI copy (label / help text / tooltip) for each known Name lives on +// the dashboard, not here — the backend's job is just to declare +// which wire headers are accepted. New provider needs an extra +// header? Add the Name here AND the matching UI copy on the dashboard. +type ExtraHeader struct { + // Name is the wire header name, e.g. "x-portkey-config". + Name string +} + +// IdentityInjection describes how the proxy stamps NetBird identity onto +// upstream gateway requests. Exactly one shape must be set — they're +// mutually exclusive and dispatched by the inject middleware. +// +// Shape choice tracks the wire convention the upstream gateway uses, +// not the vendor name. New gateways with a known shape become a catalog +// entry, not a new code path. +type IdentityInjection struct { + // HeaderPair emits separate headers per identity dimension + // (end-user id, tags as CSV). LiteLLM and OpenAI-compatible + // self-hosted gateways that read identity from dedicated headers. + HeaderPair *HeaderPairInjection + // JSONMetadata emits a single header carrying a JSON object with + // reserved keys for user / groups / etc. Portkey, Helicone-style + // metadata headers, anything that wants a structured envelope. + JSONMetadata *JSONMetadataInjection +} + +// HeaderPairInjection is the LiteLLM-style wire convention. +type HeaderPairInjection struct { + // Customizable, when true, marks the wire header names as + // operator-overridable: the dashboard surfaces EndUserIDHeader + // and TagsHeader as editable inputs (defaults shown as + // placeholders) and the synthesizer pulls the actual values from + // the provider record's IdentityHeader* fields rather than from + // these defaults. An empty operator value disables stamping for + // that dimension. Used today for Bifrost, whose log-metadata / + // telemetry header prefix (x-bf-lh-* vs x-bf-dim-*) is a + // per-operator choice; LiteLLM and similar gateways with a fixed + // wire protocol leave this false so the catalog defaults are + // authoritative. + Customizable bool + // EndUserIDHeader receives the caller's display identity (user + // email when the peer is attached to a user, else peer.Name), + // e.g. "x-litellm-end-user-id". + EndUserIDHeader string + // TagsHeader receives the caller's NetBird group display names + // as a CSV, e.g. "x-litellm-tags". + TagsHeader string + // TagsInBody, when true, additionally writes the tag list into + // the request body's metadata.tags array (a JSON path the + // gateway parses for budget enforcement). LiteLLM only honours + // metadata.tags for tag-budget gating — its x-litellm-tags + // header path feeds spend tracking but bypasses + // _tag_max_budget_check entirely. Body inject is skipped when + // the request body is empty, truncated, non-JSON, or when an + // existing metadata field is a non-object value (defensive: we + // never clobber a client-supplied non-object). The header path + // remains a robust fallback for spend tracking in those cases. + TagsInBody bool + // EndUserIDInBody, when true, additionally writes the display + // identity into the request body's top-level "user" field (the + // OpenAI-standard end-user identifier). LiteLLM resolves the end + // user id from headers first then body, so for LiteLLM this is + // belt-and-suspenders. It matters when an OpenAI-compatible + // gateway downstream of LiteLLM (or OpenAI direct, bypassing + // LiteLLM) only reads the body, and as anti-spoof: client- + // supplied "user" values are overwritten with our trusted + // identity. Same skip rules as TagsInBody. + EndUserIDInBody bool +} + +// JSONMetadataInjection is the Portkey-style wire convention: a single +// header carrying a JSON object. NetBird identity fields land under the +// configured reserved keys; missing keys (empty string) are skipped at +// emit time. +type JSONMetadataInjection struct { + // Customizable, when true, marks the JSON keys as operator- + // overridable. The dashboard surfaces UserKey and GroupsKey as + // editable inputs (the catalog values shown as placeholders) and + // the synthesizer pulls the actual JSON-key names from the + // provider record's IdentityHeader* fields. Same field reuse as + // HeaderPair's customizable path — the dimensions (user identity, + // groups) are the same, only the wire encoding differs (JSON key + // vs HTTP header name). An empty operator value disables emission + // for that dimension. Used today for Cloudflare AI Gateway, whose + // cf-aig-metadata header accepts arbitrary JSON keys; Portkey + // leaves this false because its keys are reserved by the Portkey + // schema. + Customizable bool + // Header is the wire header name carrying the JSON payload, e.g. + // "x-portkey-metadata". + Header string + // UserKey is the JSON key for the caller's display identity. + // Portkey reserves "_user" for this dimension. + UserKey string + // GroupsKey is the JSON key for the caller's NetBird groups, + // emitted as a CSV string value (Portkey requires string values). + GroupsKey string + // MaxValueLength caps each emitted JSON value, in bytes. Portkey + // enforces a 128-char limit per value; oversized values are + // truncated rather than failing the request. 0 disables the cap. + MaxValueLength int + // Sanitize, when true, replaces characters outside the destination's + // accepted set with '_' before emitting each value. AWS Bedrock's + // X-Amzn-Bedrock-Request-Metadata restricts values to a limited character + // class, so unsanitized group display names (e.g. containing spaces) would + // make Bedrock reject the request with 400. + Sanitize bool +} + +// providers is the canonical list of supported Agent Network providers. +// Update this list together with the dashboard's PROVIDER_CATALOG. +var providers = []Provider{ + { + ID: "openai_api", + Kind: KindProvider, + Name: "OpenAI API", + Description: "GPT, Responses API, and Embeddings", + DefaultHost: "api.openai.com", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#10A37F", + Discovery: &Discovery{ + Path: "/v1/models", + Shape: ShapeOpenAIData, + }, + ParserID: "openai", + PricingSurfaces: []string{"openai"}, + // Pricing + context windows cross-checked against LiteLLM's + // model_prices_and_context_window.json. Notable corrections from + // earlier values: o4-mini repriced from $4/$16 to $1.10/$4.40 + // per MTok, gpt-4o from $5/$15 to $2.50/$10, and the GPT-5 + // family context windows split between 1.05M for full-size + // models and 272K for mini/nano/codex variants. + Models: []Model{ + {ID: "gpt-5.5", Label: "GPT-5.5", InputPer1k: 0.005, OutputPer1k: 0.030, CachedInputPer1k: 0.0005, ContextWindow: 1050000}, + {ID: "gpt-5.5-pro", Label: "GPT-5.5 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, CachedInputPer1k: 0.003, ContextWindow: 1050000}, + {ID: "gpt-5.4", Label: "GPT-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015, CachedInputPer1k: 0.00025, ContextWindow: 1050000}, + {ID: "gpt-5.4-pro", Label: "GPT-5.4 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, CachedInputPer1k: 0.003, ContextWindow: 1050000}, + {ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini", InputPer1k: 0.00075, OutputPer1k: 0.0045, CachedInputPer1k: 0.000075, ContextWindow: 272000}, + {ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano", InputPer1k: 0.0002, OutputPer1k: 0.00125, CachedInputPer1k: 0.00002, ContextWindow: 272000}, + {ID: "gpt-5.3-codex", Label: "GPT-5.3 Codex", InputPer1k: 0.00175, OutputPer1k: 0.014, CachedInputPer1k: 0.000175, ContextWindow: 272000}, + {ID: "gpt-5.3-chat-latest", Label: "GPT-5.3 Chat", InputPer1k: 0.00175, OutputPer1k: 0.014, CachedInputPer1k: 0.000175, ContextWindow: 128000}, + {ID: "o4-mini", Label: "o4-mini", InputPer1k: 0.0011, OutputPer1k: 0.0044, CachedInputPer1k: 0.000275, ContextWindow: 200000}, + {ID: "gpt-4.1", Label: "GPT-4.1", InputPer1k: 0.002, OutputPer1k: 0.008, CachedInputPer1k: 0.0005, ContextWindow: 1047576}, + {ID: "gpt-4.1-mini", Label: "GPT-4.1 mini", InputPer1k: 0.0004, OutputPer1k: 0.0016, CachedInputPer1k: 0.0001, ContextWindow: 1047576}, + {ID: "gpt-4.1-nano", Label: "GPT-4.1 nano", InputPer1k: 0.0001, OutputPer1k: 0.0004, CachedInputPer1k: 0.000025, ContextWindow: 1047576}, + {ID: "gpt-4o", Label: "GPT-4o", InputPer1k: 0.0025, OutputPer1k: 0.010, CachedInputPer1k: 0.00125, ContextWindow: 128000}, + {ID: "gpt-4o-mini", Label: "GPT-4o mini", InputPer1k: 0.00015, OutputPer1k: 0.0006, CachedInputPer1k: 0.000075, ContextWindow: 128000}, + {ID: "gpt-4-turbo", Label: "GPT-4 Turbo", InputPer1k: 0.01, OutputPer1k: 0.03, ContextWindow: 128000}, + {ID: "gpt-3.5-turbo", Label: "GPT-3.5 Turbo", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 16385}, + {ID: "text-embedding-3-large", Label: "text-embedding-3-large", InputPer1k: 0.00013, OutputPer1k: 0, ContextWindow: 8191}, + {ID: "text-embedding-3-small", Label: "text-embedding-3-small", InputPer1k: 0.00002, OutputPer1k: 0, ContextWindow: 8191}, + }, + }, + { + ID: "anthropic_api", + Kind: KindProvider, + Name: "Anthropic API", + Description: "Claude Messages API", + DefaultHost: "api.anthropic.com", + AuthHeaderName: "x-api-key", + AuthHeaderTemplate: "${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#D97757", + Discovery: &Discovery{ + Path: "/v1/models", + // The default page is short and a picker wants the whole + // catalogue in one call. + Query: "limit=1000", + Shape: ShapeOpenAIData, + // Anthropic versions its API through a header and refuses a + // request that omits it, listing included. + Headers: map[string]string{"anthropic-version": "2023-06-01"}, + }, + ParserID: "anthropic", + PricingSurfaces: []string{"anthropic"}, + // Per Anthropic's current model lineup. Pricing in USD per 1k + // tokens. Context windows: 4.6+ family is 1M; Haiku 4.5 stays at + // 200K. claude-3-7-sonnet and claude-3-5-haiku retired + // 2026-02-19 — dropped from the catalog. claude-opus-4-1 + // deprecated, retires 2026-08-05 — kept until the cutover. + // claude-mythos-5 omitted: Project Glasswing access only, not a + // general-availability target. claude-fable-5 requires the + // account to be on >= 30-day data retention or all requests + // 400. + Models: []Model{ + {ID: "claude-opus-5", Label: "Claude Opus 5", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-sonnet-5", Label: "Claude Sonnet 5", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000}, + {ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000}, + {ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-opus-4-6", Label: "Claude Opus 4.6", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (deprecated, retires 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000}, + {ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000}, + {ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000}, + {ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000}, + }, + }, + { + ID: "azure_openai_api", + Kind: KindProvider, + Name: "Azure OpenAI API", + Description: "Azure-hosted OpenAI deployments", + DefaultHost: ".openai.azure.com", + AuthHeaderName: "api-key", + AuthHeaderTemplate: "${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#0078D4", + ParserID: "openai", + PricingSurfaces: []string{"openai"}, + // Mirrors openai_api pricing — Azure resells OpenAI models at the + // same per-token rates, just under different deployment names. + Models: []Model{ + {ID: "gpt-5.5", Label: "GPT-5.5 (Azure)", InputPer1k: 0.005, OutputPer1k: 0.030, CachedInputPer1k: 0.0005, ContextWindow: 1050000}, + {ID: "gpt-5.4", Label: "GPT-5.4 (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.015, CachedInputPer1k: 0.00025, ContextWindow: 1050000}, + {ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini (Azure)", InputPer1k: 0.00075, OutputPer1k: 0.0045, CachedInputPer1k: 0.000075, ContextWindow: 272000}, + {ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano (Azure)", InputPer1k: 0.0002, OutputPer1k: 0.00125, CachedInputPer1k: 0.00002, ContextWindow: 272000}, + {ID: "o4-mini", Label: "o4-mini (Azure)", InputPer1k: 0.0011, OutputPer1k: 0.0044, CachedInputPer1k: 0.000275, ContextWindow: 200000}, + {ID: "gpt-4.1", Label: "GPT-4.1 (Azure)", InputPer1k: 0.002, OutputPer1k: 0.008, CachedInputPer1k: 0.0005, ContextWindow: 1047576}, + {ID: "gpt-4.1-mini", Label: "GPT-4.1 mini (Azure)", InputPer1k: 0.0004, OutputPer1k: 0.0016, CachedInputPer1k: 0.0001, ContextWindow: 1047576}, + {ID: "gpt-4o", Label: "GPT-4o (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.010, CachedInputPer1k: 0.00125, ContextWindow: 128000}, + {ID: "gpt-4o-mini", Label: "GPT-4o mini (Azure)", InputPer1k: 0.00015, OutputPer1k: 0.0006, CachedInputPer1k: 0.000075, ContextWindow: 128000}, + {ID: "gpt-35-turbo", Label: "GPT-3.5 Turbo (Azure)", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 16385}, + }, + }, + { + ID: "bedrock_api", + Kind: KindProvider, + Name: "AWS Bedrock API", + Description: "Anthropic, Meta, Cohere via Bedrock", + DefaultHost: "bedrock-runtime..amazonaws.com", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#FF9900", + // Listings come from the CONTROL PLANE, not the runtime host in + // DefaultHost above: ListInferenceProfiles is not an operation + // bedrock-runtime implements, and answers + // there. Inference has to go to the runtime host, so the two hosts + // genuinely differ and Discovery.Host carries the difference. + // + // Inference profiles rather than foundation models because the profile + // id is the invocable one: it carries the region prefix (eu., us., + // global.) that AWS requires and that cannot be derived from the + // configured region — an eu-central-1 account legitimately holds + // global.* profiles. + Discovery: &Discovery{ + Host: "bedrock." + RegionPlaceholder + ".amazonaws.com", + Path: "/inference-profiles", + Shape: ShapeBedrockInferenceProfiles, + }, + // ParserID stays empty (path-style dispatch via IsBedrockPathStyle); + // the request parser meters these under the "bedrock" surface. + PricingSurfaces: []string{"bedrock"}, + // Anthropic models on Bedrock take the anthropic.* prefix and + // follow the same lineup / pricing as the first-party Anthropic + // catalog entry above. claude-3-7-sonnet and claude-3-5-haiku + // were retired upstream on 2026-02-19 — dropped from the + // Bedrock list too. Amazon Nova entries cross-checked against + // LiteLLM (added Nova Micro + the new Nova 2 Lite preview). + // Llama 3.3 70B entry kept unchanged — LiteLLM tracks only + // per-region Llama 3 entries; standalone 3.3 not yet listed. + Models: []Model{ + {ID: "anthropic.claude-opus-5", Label: "Claude Opus 5 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "anthropic.claude-sonnet-5", Label: "Claude Sonnet 5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000}, + {ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "anthropic.claude-opus-4-1", Label: "Claude Opus 4.1 (Bedrock, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000}, + {ID: "anthropic.claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000}, + {ID: "anthropic.claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000}, + {ID: "anthropic.claude-haiku-4-5", Label: "Claude Haiku 4.5 (Bedrock)", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000}, + {ID: "meta.llama3-3-70b-instruct", Label: "Llama 3.3 70B (Bedrock)", InputPer1k: 0.00072, OutputPer1k: 0.00072, ContextWindow: 128000}, + {ID: "amazon.nova-2-lite", Label: "Amazon Nova 2 Lite (Bedrock, preview)", InputPer1k: 0.0003, OutputPer1k: 0.0025, ContextWindow: 1000000}, + {ID: "amazon.nova-pro", Label: "Amazon Nova Pro (Bedrock)", InputPer1k: 0.0008, OutputPer1k: 0.0032, ContextWindow: 300000}, + {ID: "amazon.nova-lite", Label: "Amazon Nova Lite (Bedrock)", InputPer1k: 0.00006, OutputPer1k: 0.00024, ContextWindow: 300000}, + {ID: "amazon.nova-micro", Label: "Amazon Nova Micro (Bedrock)", InputPer1k: 0.000035, OutputPer1k: 0.00014, ContextWindow: 128000}, + }, + // Bedrock accepts a cost-allocation metadata header; stamp the caller's + // user + authorizing group so spend can be attributed in AWS Cost + // Management. Sanitized because Bedrock restricts the value character set. + IdentityInjection: &IdentityInjection{ + JSONMetadata: &JSONMetadataInjection{ + Header: "X-Amzn-Bedrock-Request-Metadata", + UserKey: "user", + GroupsKey: "group", + MaxValueLength: 256, + Sanitize: true, + }, + }, + }, + { + ID: "vertex_ai_api", + Kind: KindProvider, + Name: "Google Vertex AI API", + Description: "Anthropic Claude models hosted on Vertex AI", + DefaultHost: "-aiplatform.googleapis.com", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#4285F4", + // Only the v1beta1 publisher listing answers: the v1 form and the + // project-scoped form under BOTH versions return 404. That means the + // list is publisher-global — it cannot say which models this project + // has enabled — so it is offered as a suggestion beside the catalog + // rather than replacing it. See the discovery e2e for the probes. + Discovery: &Discovery{ + Path: "/v1beta1/publishers/anthropic/models", + Shape: ShapeVertexPublisherModels, + }, + // ParserID stays empty (path-style dispatch via IsVertexPathStyle); + // Anthropic-on-Vertex requests are metered under the "anthropic" + // surface with the bare, unversioned model id. + PricingSurfaces: []string{"anthropic"}, + // Vertex carries the model in the URL path and authenticates with a + // service-account-minted OAuth token (api_key = "keyfile::"). + // Only Anthropic-on-Vertex is metered today: the request parser maps the + // anthropic publisher to the Anthropic parser, so the lineup + prices + // mirror the first-party Anthropic catalog (LiteLLM vertex_ai/claude-* + // confirms the same per-token rates; cross-region profiles in eu/apac + // carry a ~10% premium that base pricing does not model). Gemini (the + // google publisher) is intentionally omitted until a Gemini parser + // exists — the router denies unmeterable publishers rather than forward + // them uncounted. + Models: []Model{ + {ID: "claude-opus-5", Label: "Claude Opus 5 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-sonnet-5", Label: "Claude Sonnet 5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000}, + {ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000}, + {ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-opus-4-6", Label: "Claude Opus 4.6 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000}, + {ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (Vertex, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000}, + {ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000}, + {ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000}, + {ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5 (Vertex)", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000}, + }, + }, + { + ID: "mistral_api", + Kind: KindProvider, + Name: "Mistral API", + Description: "Mistral cloud API", + DefaultHost: "api.mistral.ai", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#FF7000", + ParserID: "openai", + PricingSurfaces: []string{"openai"}, + // Pricing + context windows cross-checked against LiteLLM. Key + // gotchas the marketing page hides: + // - `mistral-medium-latest` aliases to Medium 3.1 ($0.40/$2), + // NOT Medium 3.5 ($1.50/$7.50). Catalog exposes both. + // - `mistral-large-latest` aliases to Large 3 — 262K context, + // cheaper than Medium 3.5. + // - Magistral models are tuned for reasoning but cap context + // at only 40K (vs 128K-262K elsewhere). + // - `codestral-latest` still routes to the old 2405 build + // ($1/$3) per LiteLLM; the newer codestral-2508 is both + // cheaper and longer-context. Both exposed. + // - Pixtral was folded into the main Large/Medium series; no + // standalone vision entry. + Models: []Model{ + {ID: "mistral-large-latest", Label: "Mistral Large 3", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 262144}, + {ID: "mistral-medium-latest", Label: "Mistral Medium 3.1", InputPer1k: 0.0004, OutputPer1k: 0.002, ContextWindow: 131072}, + {ID: "mistral-medium-3-5", Label: "Mistral Medium 3.5", InputPer1k: 0.0015, OutputPer1k: 0.0075, ContextWindow: 262144}, + {ID: "mistral-small-latest", Label: "Mistral Small 3.2", InputPer1k: 0.00006, OutputPer1k: 0.00018, ContextWindow: 131072}, + {ID: "magistral-medium-latest", Label: "Magistral Medium (reasoning)", InputPer1k: 0.002, OutputPer1k: 0.005, ContextWindow: 40000}, + {ID: "magistral-small-latest", Label: "Magistral Small (reasoning)", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 40000}, + {ID: "devstral-medium-latest", Label: "Devstral Medium 2 (coding)", InputPer1k: 0.0004, OutputPer1k: 0.002, ContextWindow: 256000}, + {ID: "devstral-small-latest", Label: "Devstral Small 2 (coding)", InputPer1k: 0.0001, OutputPer1k: 0.0003, ContextWindow: 256000}, + {ID: "codestral-2508", Label: "Codestral 2508", InputPer1k: 0.0003, OutputPer1k: 0.0009, ContextWindow: 256000}, + {ID: "codestral-latest", Label: "Codestral (legacy 2405)", InputPer1k: 0.001, OutputPer1k: 0.003, ContextWindow: 32000}, + {ID: "ministral-3-14b-2512", Label: "Ministral 3 14B", InputPer1k: 0.0002, OutputPer1k: 0.0002, ContextWindow: 262144}, + {ID: "ministral-8b-latest", Label: "Ministral 8B", InputPer1k: 0.00015, OutputPer1k: 0.00015, ContextWindow: 262144}, + {ID: "ministral-3-3b-2512", Label: "Ministral 3 3B", InputPer1k: 0.0001, OutputPer1k: 0.0001, ContextWindow: 131072}, + {ID: "mistral-embed", Label: "Mistral Embed", InputPer1k: 0.0001, OutputPer1k: 0, ContextWindow: 8192}, + }, + }, + { + ID: "kimi_api", + Kind: KindProvider, + Name: "Kimi (Moonshot AI) API", + Description: "Kimi K3 / K2 models via the Moonshot AI platform", + DefaultHost: "api.moonshot.ai", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#1A1A2E", + // ParserID empty on purpose: Moonshot serves two body shapes on + // the same host and key, and the proxy's URL sniffer dispatches + // both (same pattern as Bifrost). /v1/chat/completions matches + // OpenAIParser; the Anthropic-compatible endpoint the official + // Claude Code guide uses (/anthropic/v1/messages) contains + // "/v1/messages" and matches AnthropicParser. Pinning "openai" + // here would misparse the Claude Code path — the primary way + // teams consume Kimi for coding today. Both endpoints accept the + // same Moonshot key via Authorization: Bearer (Claude Code's + // ANTHROPIC_AUTH_TOKEN rides that header too). + // + // api.moonshot.ai is the international platform; mainland-China + // accounts live on api.moonshot.cn with separate billing — + // operators there override the host on the provider record. The + // kimi.com subscription coding endpoint (api.kimi.com/coding, + // model id "k3") is account-bound seat licensing rather than a + // meterable platform key, so it's deliberately not the default. + ParserID: "", + // Both body shapes are metered: /v1/chat/completions under + // "openai", /anthropic/v1/messages under "anthropic" — so the + // K3 entry is priced on both surfaces. + PricingSurfaces: []string{"openai", "anthropic"}, + // Pricing per Moonshot's platform rates at K3 launch (July 2026): + // $3/$15 per MTok with $0.30 cached input, flat across the 1M-token + // window. kimi-k3 is the ONLY model the platform serves newer + // accounts — K2-era ids (kimi-k2-thinking) and even the kimi-latest + // alias return resource_not_found_error, verified live 2026-07-21 — + // so it's the only catalog entry. Grandfathered accounts with K2 + // access can still type those ids on the provider's model rows. + // The consumer app's "K3 Swarm Max" mode is not an API SKU, so it + // doesn't appear here. + Models: []Model{ + // Carries both cache shapes: Moonshot reports cache hits + // OpenAI-style on /v1/chat/completions (CachedInputPer1k) + // and Anthropic-style on /anthropic/v1/messages + // (CacheReadPer1k) — $0.30/MTok either way. Each surface's + // cost formula reads only its own field, so the superset + // entry prices both endpoints correctly. No cache-creation + // rate published; writes bill at the input rate. + {ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, CachedInputPer1k: 0.0003, CacheReadPer1k: 0.0003, ContextWindow: 1000000}, + }, + }, + { + ID: "litellm_proxy", + Kind: KindGateway, + Name: "LiteLLM Proxy", + Description: "Bring your own LiteLLM proxy with NetBird identity stamped on every request", + DefaultHost: "", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#0EA5E9", + ParserID: "openai", + // IdentityInjection requires a LiteLLM virtual key minted with + // metadata.allow_client_tags=true; the master key silently drops + // caller tags. Tags go out via both the x-litellm-tags header and + // body metadata.tags: LiteLLM enforces budgets from the body only, + // so the header is the spend-tracking fallback when body injection + // can't run. See the Agent Network provider docs for key setup. + IdentityInjection: &IdentityInjection{ + HeaderPair: &HeaderPairInjection{ + EndUserIDHeader: "x-litellm-end-user-id", + TagsHeader: "x-litellm-tags", + TagsInBody: true, + EndUserIDInBody: true, + }, + }, + Models: []Model{}, + }, + { + ID: "portkey", + Kind: KindGateway, + Name: "Portkey AI Gateway", + Description: "Portkey AI Gateway with NetBird identity stamped via x-portkey-metadata", + DefaultHost: "api.portkey.ai", + // Portkey hosted requires x-portkey-api-key (account key) + // plus a routing decision per request. The simplest routing + // path is a saved Portkey config id stamped via + // x-portkey-config — operators paste the pc-... id once and + // Portkey resolves the upstream provider + virtual key from + // it. ExtraHeaders below surfaces the input. Alternative: + // callers author "@org/model" in the body; both flows + // coexist (per-request authoring still works without a + // configured value). + AuthHeaderName: "x-portkey-api-key", + AuthHeaderTemplate: "${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#FF5C00", + ParserID: "openai", + IdentityInjection: &IdentityInjection{ + JSONMetadata: &JSONMetadataInjection{ + Header: "x-portkey-metadata", + UserKey: "_user", + GroupsKey: "groups", + MaxValueLength: 128, + }, + }, + ExtraHeaders: []ExtraHeader{ + {Name: "x-portkey-config"}, + }, + Models: []Model{}, + }, + { + ID: "bifrost", + Kind: KindGateway, + Name: "Bifrost", + Description: "Maxim AI's Bifrost gateway. Point upstream URL at /openai/v1 or /anthropic/v1 on your Bifrost host depending on which body shape your apps use.", + DefaultHost: "", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#7C3AED", + // ParserID empty: the proxy's request parser sniffs the URL + // path. Bifrost's /openai/v1/... contains "/v1/chat/completions" + // (matches OpenAIParser.DetectFromURL); /anthropic/v1/messages + // contains "/v1/messages" (matches AnthropicParser). Operators + // who paste a different prefix get no usage parsing and the + // cost meter skips with skipMissingProvider — degraded but + // non-fatal. + ParserID: "", + // Identity-injection headers are operator-customisable. The + // HeaderPair values below are PLACEHOLDERS surfaced by the + // dashboard; the actual values stamped on the wire come from + // the provider record's IdentityHeaderUserID / + // IdentityHeaderGroups fields. An empty operator value + // disables stamping for that dimension (the inject middleware + // already no-ops on empty header names). Defaulting to the + // x-bf-dim- family so the values land in Bifrost's + // Prometheus/OTEL pipelines when the operator declares the + // label names in their client.prometheus_labels config — see + // docs.getbifrost.ai/features/telemetry. Operators who use + // the always-on x-bf-lh- log-metadata family (no Bifrost-side + // declaration required) just edit the inputs. + // + // Bifrost virtual keys (sk-bf-*) ride Authorization: Bearer. + // Operators provision the VK on their Bifrost (UI / + // config.json / POST /api/governance/virtual-keys) and paste + // the returned sk-bf-... as ${API_KEY}. Pin v1.4+ to avoid + // the v1.3.0 x-bf-vk regression (maximhq/bifrost#632). + IdentityInjection: &IdentityInjection{ + HeaderPair: &HeaderPairInjection{ + EndUserIDHeader: "x-bf-dim-netbird_user_id", + TagsHeader: "x-bf-dim-netbird_groups", + Customizable: true, + }, + }, + Models: []Model{}, + }, + { + ID: "cloudflare_ai_gateway", + Kind: KindGateway, + Name: "Cloudflare AI Gateway", + Description: "Cloudflare AI Gateway. Operator pastes the gateway URL (with the upstream provider slug like /openai or /anthropic so the URL sniffer dispatches to the right parser) and a per-gateway authentication token. Recommended setup is BYOK / Stored Keys: Cloudflare manages the upstream provider credential and the gateway token is the only secret NetBird needs.", + DefaultHost: "", + AuthHeaderName: "cf-aig-authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#F38020", + // ParserID empty: like Bifrost, the proxy's parser-detect + // sniffs the URL path. /openai/... contains the OpenAI hint + // substrings; /anthropic/v1/messages contains /v1/messages + // (matches AnthropicParser). The /compat universal endpoint + // also speaks OpenAI shape so OpenAIParser handles it. + // Operators who paste a different prefix degrade to no-cost + // (skipMissingProvider) but the request still flows. + ParserID: "", + // cf-aig-metadata is a single header carrying a JSON object; + // up to five string/number/boolean values per request. NetBird + // occupies two slots (user id + groups CSV) and leaves three + // for operator-added context. JSON keys are operator- + // customisable so Cloudflare-side log filters can use the + // operator's existing label conventions instead of NetBird's + // defaults — hence Customizable=true. The dashboard surfaces + // the catalog values as placeholders; only the values stored + // on the provider record's IdentityHeader* fields land on the + // wire (empty operator value = key is omitted from the JSON, + // since applyJSONMetadata already skips empty keys). + IdentityInjection: &IdentityInjection{ + JSONMetadata: &JSONMetadataInjection{ + Header: "cf-aig-metadata", + UserKey: "netbird_user_id", + GroupsKey: "netbird_groups", + Customizable: true, + // Cloudflare's docs don't specify a per-value cap; + // leaving 0 disables the truncate path. Header-level + // constraint is "5 entries max" rather than length. + MaxValueLength: 0, + }, + }, + Models: []Model{}, + }, + { + ID: "vercel_ai_gateway", + Kind: KindGateway, + Name: "Vercel AI Gateway", + Description: "Vercel's unified API for hundreds of models. Single endpoint, OpenAI-compatible body, model dispatch via prefix (openai/..., anthropic/..., google/..., xai/...). Per-user / per-tag attribution lands in Vercel's Custom Reporting API and observability dashboard.", + DefaultHost: "", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#000000", + // Vercel always speaks OpenAI shape on /v1/chat/completions — + // the model prefix in the body picks the upstream provider. + // No URL sniffing needed; pin the parser directly. + ParserID: "openai", + // HeaderPair shape with fixed wire names dictated by Vercel's + // Custom Reporting API contract. Customizable=false because + // renaming the headers makes Vercel silently stop attributing + // — the gateway's reporting endpoint only matches its own + // header names. Same fixed-protocol position as LiteLLM. + // + // Caveats operators should know: + // - up to 10 tags total per request (deduped); 11+ → HTTP 400 + // - each tag must be 1-64 chars + // - user up to 256 chars (NetBird user emails fit) + // - $0.075 per 1k unique user/tag values written + // We don't enforce the caps in the inject middleware today; + // operators in groups beyond the 10-tag limit will see Vercel + // 400s and need to re-scope their group memberships. + IdentityInjection: &IdentityInjection{ + HeaderPair: &HeaderPairInjection{ + EndUserIDHeader: "ai-reporting-user", + TagsHeader: "ai-reporting-tags", + }, + }, + Models: []Model{}, + }, + { + ID: "openrouter", + Kind: KindGateway, + Name: "OpenRouter", + Description: "OpenRouter's unified API for hundreds of models. Single endpoint at openrouter.ai/api/v1, OpenAI-compatible body, model dispatch via prefix (anthropic/claude-..., openai/gpt-..., google/gemini-..., etc.). Per-user attribution lands in OpenRouter's analytics via the OpenAI-standard `user` body field; OpenRouter has no groups / tags dimension at request time.", + DefaultHost: "openrouter.ai/api/v1", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#6F4FF2", + // OpenRouter is single-endpoint OpenAI-shape on /api/v1/chat/completions — + // model prefix in the body picks the upstream provider. + // Pinning the parser saves URL sniffing. + ParserID: "openai", + // HeaderPair shape with EndUserIDInBody as the only active + // dimension. OpenRouter's per-user attribution is the + // OpenAI-standard `user` body field, not a header — and + // OpenRouter offers no per-request groups / tags dimension at + // all. Customizable=false because the field name is locked by + // OpenAI's spec; renaming would just defeat the inject. + IdentityInjection: &IdentityInjection{ + HeaderPair: &HeaderPairInjection{ + EndUserIDInBody: true, + }, + }, + // HTTP-Referer + X-OpenRouter-Title surface in OpenRouter's + // app rankings and per-app analytics. Operators paste their + // own app URL + display name on the provider record so their + // requests show under their brand instead of "no app". Both + // are static per-deployment, not per-request, hence the + // ExtraHeaders mechanism (operator-typed value, stamped on + // every request to this provider). Skip X-OpenRouter-Categories + // for now — the marketplace-categories dimension is + // niche-enough that we'd add it on demand. + ExtraHeaders: []ExtraHeader{ + {Name: "HTTP-Referer"}, + {Name: "X-OpenRouter-Title"}, + }, + Models: []Model{}, + }, + { + // vLLM is an OpenAI-compatible self-hosted server. It behaves like + // the generic custom entry; it gets its own catalog id purely so it + // surfaces as a named "vLLM" choice in the provider picker. + ID: "vllm", + Kind: KindCustom, + Name: "vLLM", + Description: "Self-hosted vLLM (OpenAI-compatible)", + DefaultHost: "", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#30A2FF", + Models: []Model{}, + }, + { + ID: "custom", + Kind: KindCustom, + Name: "Custom / Self-hosted", + Description: "OpenAI-compatible endpoint (vLLM, Ollama, …)", + DefaultHost: "", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#9CA3AF", + Models: []Model{}, + }, +} + +// All returns a copy of the full catalog. +func All() []Provider { + out := make([]Provider, len(providers)) + copy(out, providers) + return out +} + +// Lookup returns the catalog entry with the given id, if any. +func Lookup(id string) (Provider, bool) { + for _, p := range providers { + if p.ID == id { + return p, true + } + } + return Provider{}, false +} + +// IsKnown reports whether the given id refers to a catalog entry. +func IsKnown(id string) bool { + _, ok := Lookup(id) + return ok +} + +// IsVertexPathStyle reports whether a provider uses the Google Vertex AI +// request shape — the model is carried in the URL path +// (/v1/projects/{p}/locations/{r}/publishers/{pub}/models/{model}:{action}) +// rather than the body, so the proxy routes it by path instead of by model. +func IsVertexPathStyle(providerID string) bool { + return providerID == "vertex_ai_api" +} + +// IsBedrockPathStyle reports whether a provider uses the AWS Bedrock request +// shape — the model is carried in the URL path (/model/{modelId}/{action}, +// action being invoke, invoke-with-response-stream, converse, or +// converse-stream) rather than the body, so the proxy routes it by path. +func IsBedrockPathStyle(providerID string) bool { + return providerID == "bedrock_api" +} + +// ToAPIResponse renders a catalog provider as the API representation. +func (p Provider) ToAPIResponse() api.AgentNetworkCatalogProvider { + models := make([]api.AgentNetworkCatalogModel, 0, len(p.Models)) + for _, m := range p.Models { + am := api.AgentNetworkCatalogModel{ + Id: m.ID, + Label: m.Label, + InputPer1k: m.InputPer1k, + OutputPer1k: m.OutputPer1k, + ContextWindow: m.ContextWindow, + } + // Cache rates are emitted only when configured so the dashboard + // can prefill them; 0 stays off the wire (absent = no rate). + if m.CachedInputPer1k > 0 { + v := m.CachedInputPer1k + am.CachedInputPer1k = &v + } + if m.CacheReadPer1k > 0 { + v := m.CacheReadPer1k + am.CacheReadPer1k = &v + } + if m.CacheCreationPer1k > 0 { + v := m.CacheCreationPer1k + am.CacheCreationPer1k = &v + } + models = append(models, am) + } + kind := api.AgentNetworkCatalogProviderKindProvider + switch p.Kind { + case KindGateway: + kind = api.AgentNetworkCatalogProviderKindGateway + case KindCustom: + kind = api.AgentNetworkCatalogProviderKindCustom + } + resp := api.AgentNetworkCatalogProvider{ + Id: p.ID, + Name: p.Name, + Description: p.Description, + DefaultHost: p.DefaultHost, + Kind: kind, + AuthHeaderTemplate: p.AuthHeaderTemplate, + DefaultContentType: p.DefaultContentType, + BrandColor: p.BrandColor, + Models: models, + } + if len(p.PricingSurfaces) > 0 { + surfaces := append([]string(nil), p.PricingSurfaces...) + resp.PricingSurfaces = &surfaces + } + if len(p.ExtraHeaders) > 0 { + extras := make([]api.AgentNetworkCatalogExtraHeader, 0, len(p.ExtraHeaders)) + for _, h := range p.ExtraHeaders { + extras = append(extras, api.AgentNetworkCatalogExtraHeader{ + Name: h.Name, + }) + } + resp.ExtraHeaders = &extras + } + // Surface IdentityInjection so the dashboard can decide whether + // to render editable inputs vs. a read-only mappings strip per + // shape's customizable flag. HeaderPair (Bifrost) and + // JSONMetadata (Cloudflare, Portkey) are mutually exclusive on a + // given catalog entry; emit whichever shape is set. + if p.IdentityInjection != nil { + injection := &api.AgentNetworkCatalogIdentityInjection{} + if hp := p.IdentityInjection.HeaderPair; hp != nil { + injection.HeaderPair = &api.AgentNetworkCatalogHeaderPairInjection{ + Customizable: hp.Customizable, + EndUserIdHeader: hp.EndUserIDHeader, + TagsHeader: hp.TagsHeader, + } + } + if jm := p.IdentityInjection.JSONMetadata; jm != nil { + injection.JsonMetadata = &api.AgentNetworkCatalogJSONMetadataInjection{ + Customizable: jm.Customizable, + Header: jm.Header, + UserKey: jm.UserKey, + GroupsKey: jm.GroupsKey, + } + } + if injection.HeaderPair != nil || injection.JsonMetadata != nil { + resp.IdentityInjection = injection + } + } + return resp +} diff --git a/management/internals/modules/agentnetwork/catalog/catalog_test.go b/management/internals/modules/agentnetwork/catalog/catalog_test.go new file mode 100644 index 000000000..e4e887e6f --- /dev/null +++ b/management/internals/modules/agentnetwork/catalog/catalog_test.go @@ -0,0 +1,36 @@ +package catalog + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestClaudeLineupSelectable pins the models Claude Code resolves to by +// default. A model absent from the lineup can't be ticked on a provider +// record, so llm_router denies it as not-routable and the operator has no +// way to authorise the client's own default. +func TestClaudeLineupSelectable(t *testing.T) { + for providerID, wanted := range map[string][]string{ + "anthropic_api": {"claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"}, + "bedrock_api": {"anthropic.claude-opus-5", "anthropic.claude-sonnet-5", "anthropic.claude-haiku-4-5"}, + "vertex_ai_api": {"claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"}, + } { + provider, ok := Lookup(providerID) + require.True(t, ok, "catalog must define %s", providerID) + + selectable := make(map[string]Model, len(provider.Models)) + for _, m := range provider.Models { + selectable[m.ID] = m + } + for _, id := range wanted { + model, found := selectable[id] + require.True(t, found, "%s must offer %s", providerID, id) + assert.NotEmpty(t, model.Label, "%s/%s needs a label for the picker", providerID, id) + assert.Positive(t, model.InputPer1k, "%s/%s needs an input rate", providerID, id) + assert.Positive(t, model.OutputPer1k, "%s/%s needs an output rate", providerID, id) + assert.Positive(t, model.ContextWindow, "%s/%s needs a context window", providerID, id) + } + } +} diff --git a/management/internals/modules/agentnetwork/handlers/access_log_handler.go b/management/internals/modules/agentnetwork/handlers/access_log_handler.go new file mode 100644 index 000000000..4484d8c91 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/access_log_handler.go @@ -0,0 +1,134 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +// addAccessLogEndpoints registers the read-only, server-side-filtered +// agent-network access-log listing and the aggregated usage overview. +func (h *handler) addAccessLogEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/access-logs", h.listAccessLogs).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/access-log-sessions", h.listAccessLogSessions).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/usage/overview", h.getUsageOverview).Methods("GET", "OPTIONS") +} + +func (h *handler) getUsageOverview(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + // Reuse the access-log filter for the shared date/user/group/provider/model + // params; pagination/sort/search are irrelevant for an aggregate. + var filter types.AgentNetworkAccessLogFilter + if err := filter.ParseFromRequest(r); err != nil { + util.WriteError(r.Context(), err, w) + return + } + // Bound the aggregation window so an unbounded or over-wide query can't load + // an account's entire usage history into memory. + filter.ApplyUsageOverviewBounds(time.Now()) + granularity := types.ParseUsageGranularity(r.URL.Query().Get("granularity")) + + buckets, err := h.manager.GetUsageOverview(r.Context(), userAuth.AccountId, userAuth.UserId, filter, granularity) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + out := make([]api.AgentNetworkUsageBucket, 0, len(buckets)) + for _, b := range buckets { + out = append(out, b.ToAPIResponse()) + } + util.WriteJSONObject(r.Context(), w, out) +} + +func (h *handler) listAccessLogs(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var filter types.AgentNetworkAccessLogFilter + if err := filter.ParseFromRequest(r); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + rows, total, err := h.manager.ListAccessLogs(r.Context(), userAuth.AccountId, userAuth.UserId, filter) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + data := make([]api.AgentNetworkAccessLog, 0, len(rows)) + for _, row := range rows { + data = append(data, row.ToAPIResponse()) + } + + pageSize := filter.GetLimit() + totalPages := 0 + if pageSize > 0 { + totalPages = int((total + int64(pageSize) - 1) / int64(pageSize)) + } + + util.WriteJSONObject(r.Context(), w, api.AgentNetworkAccessLogsResponse{ + Data: data, + Page: filter.Page, + PageSize: pageSize, + TotalRecords: int(total), + TotalPages: totalPages, + }) +} + +// listAccessLogSessions returns the access logs grouped by session: the page +// unit is a session (total counts sessions), each carrying an aggregate summary +// and its ordered entries. Accepts the same filters as listAccessLogs. +func (h *handler) listAccessLogSessions(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var filter types.AgentNetworkAccessLogFilter + if err := filter.ParseFromRequest(r); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + sessions, total, err := h.manager.ListAccessLogSessions(r.Context(), userAuth.AccountId, userAuth.UserId, filter) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + data := make([]api.AgentNetworkAccessLogSession, 0, len(sessions)) + for _, sess := range sessions { + data = append(data, sess.ToAPIResponse()) + } + + pageSize := filter.GetLimit() + totalPages := 0 + if pageSize > 0 { + totalPages = int((total + int64(pageSize) - 1) / int64(pageSize)) + } + + util.WriteJSONObject(r.Context(), w, api.AgentNetworkAccessLogSessionsResponse{ + Data: data, + Page: filter.Page, + PageSize: pageSize, + TotalRecords: int(total), + TotalPages: totalPages, + }) +} diff --git a/management/internals/modules/agentnetwork/handlers/budget_handler.go b/management/internals/modules/agentnetwork/handlers/budget_handler.go new file mode 100644 index 000000000..5630de17f --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/budget_handler.go @@ -0,0 +1,172 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +// addBudgetRuleEndpoints registers the account-level budget rule routes. +func (h *handler) addBudgetRuleEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/budget-rules", h.getAllBudgetRules).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/budget-rules", h.createBudgetRule).Methods("POST", "OPTIONS") + router.HandleFunc("/agent-network/budget-rules/{ruleId}", h.getBudgetRule).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/budget-rules/{ruleId}", h.updateBudgetRule).Methods("PUT", "OPTIONS") + router.HandleFunc("/agent-network/budget-rules/{ruleId}", h.deleteBudgetRule).Methods("DELETE", "OPTIONS") +} + +func (h *handler) getAllBudgetRules(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + rules, err := h.manager.GetAllBudgetRules(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + out := make([]*api.AgentNetworkBudgetRule, 0, len(rules)) + for _, rule := range rules { + out = append(out, rule.ToAPIResponse()) + } + util.WriteJSONObject(r.Context(), w, out) +} + +func (h *handler) getBudgetRule(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + ruleID := mux.Vars(r)["ruleId"] + if ruleID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "budget rule ID is required"), w) + return + } + + rule, err := h.manager.GetBudgetRule(r.Context(), userAuth.AccountId, userAuth.UserId, ruleID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, rule.ToAPIResponse()) +} + +func (h *handler) createBudgetRule(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.AgentNetworkBudgetRuleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validateBudgetRule(&req); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + rule := types.NewAccountBudgetRule(userAuth.AccountId) + rule.FromAPIRequest(&req) + + created, err := h.manager.CreateBudgetRule(r.Context(), userAuth.UserId, rule) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, created.ToAPIResponse()) +} + +func (h *handler) updateBudgetRule(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + ruleID := mux.Vars(r)["ruleId"] + if ruleID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "budget rule ID is required"), w) + return + } + + var req api.AgentNetworkBudgetRuleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validateBudgetRule(&req); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + rule := &types.AccountBudgetRule{ID: ruleID, AccountID: userAuth.AccountId} + rule.FromAPIRequest(&req) + + updated, err := h.manager.UpdateBudgetRule(r.Context(), userAuth.UserId, rule) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse()) +} + +func (h *handler) deleteBudgetRule(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + ruleID := mux.Vars(r)["ruleId"] + if ruleID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "budget rule ID is required"), w) + return + } + + if err := h.manager.DeleteBudgetRule(r.Context(), userAuth.AccountId, userAuth.UserId, ruleID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +// validateBudgetRule rejects malformed budget rules. It reuses the policy limit +// validation since the cap shape is identical, and rejects empty target entries. +func validateBudgetRule(req *api.AgentNetworkBudgetRuleRequest) error { + if strings.TrimSpace(req.Name) == "" { + return status.Errorf(status.InvalidArgument, "name is required") + } + if req.TargetGroups != nil { + for _, id := range *req.TargetGroups { + if strings.TrimSpace(id) == "" { + return status.Errorf(status.InvalidArgument, "target_groups must not contain empty entries") + } + } + } + if req.TargetUsers != nil { + for _, id := range *req.TargetUsers { + if strings.TrimSpace(id) == "" { + return status.Errorf(status.InvalidArgument, "target_users must not contain empty entries") + } + } + } + return validatePolicyLimits(req.Limits) +} diff --git a/management/internals/modules/agentnetwork/handlers/budget_handler_test.go b/management/internals/modules/agentnetwork/handlers/budget_handler_test.go new file mode 100644 index 000000000..3a7709461 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/budget_handler_test.go @@ -0,0 +1,131 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestBudgetRuleHandler_RoundTrip seeds a budget rule via the store and asserts +// the GET wire shape carries targets and the reused PolicyLimits cap shape. The +// create/update/delete success paths go through accountManager.StoreEvent which +// this fixture doesn't wire — they are covered by the manager-level no-mock +// test (TestAgentNetwork_BudgetRuleCRUD_RealManager). +func TestBudgetRuleHandler_RoundTrip(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rule := &agentNetworkTypes.AccountBudgetRule{ + ID: "ainbud_test", + AccountID: testAccountID, + Name: "org-monthly", + Enabled: true, + TargetGroups: []string{"grp-eng"}, + TargetUsers: []string{"user-alice"}, + Limits: agentNetworkTypes.PolicyLimits{ + TokenLimit: agentNetworkTypes.PolicyTokenLimit{Enabled: true, GroupCap: 100000, UserCap: 10000, WindowSeconds: 2_592_000}, + BudgetLimit: agentNetworkTypes.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 500, WindowSeconds: 2_592_000}, + }, + } + require.NoError(t, f.store.SaveAgentNetworkBudgetRule(context.Background(), rule)) + + rec := f.do(t, http.MethodGet, "/agent-network/budget-rules/"+rule.ID, "") + require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String()) + + var got api.AgentNetworkBudgetRule + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Equal(t, "org-monthly", got.Name, "name must round-trip") + assert.Equal(t, []string{"grp-eng"}, got.TargetGroups, "target groups must round-trip") + assert.Equal(t, []string{"user-alice"}, got.TargetUsers, "target users must round-trip") + assert.Equal(t, int64(100000), got.Limits.TokenLimit.GroupCap, "token group cap must round-trip") + assert.Equal(t, int64(2_592_000), got.Limits.BudgetLimit.WindowSeconds, "budget window must round-trip") +} + +// TestBudgetRuleHandler_ListReturnsArray asserts the list endpoint returns a +// JSON array (never null) for an account with no rules. +func TestBudgetRuleHandler_ListReturnsArray(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodGet, "/agent-network/budget-rules", "") + require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String()) + assert.Equal(t, "[]", trimSpace(rec.Body.String()), "empty account must return an empty array, not null") +} + +// TestBudgetRuleHandler_RejectsMissingName covers the validation path (which +// runs before the manager call, so it works without a wired accountManager). +func TestBudgetRuleHandler_RejectsMissingName(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + body := `{ + "name": "", + "limits": { + "token_limit": {"enabled": false, "group_cap": 0, "user_cap": 0, "window_seconds": 0}, + "budget_limit": {"enabled": false, "group_cap_usd": 0, "user_cap_usd": 0, "window_seconds": 0} + } + }` + rec := f.do(t, http.MethodPost, "/agent-network/budget-rules", body) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, + "missing name must be rejected as a validation error (not a route/auth 4xx): got %d body=%s", rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "name", + "rejection body must name the offending field, proving the validation path: %s", rec.Body.String()) +} + +// TestBudgetRuleHandler_RejectsSubMinuteWindow proves budget rules reuse the +// policy-limit validation (enabled limit needs window >= 60s). +func TestBudgetRuleHandler_RejectsSubMinuteWindow(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + body := `{ + "name": "bad-window", + "limits": { + "token_limit": {"enabled": true, "group_cap": 1000, "user_cap": 0, "window_seconds": 30}, + "budget_limit": {"enabled": false, "group_cap_usd": 0, "user_cap_usd": 0, "window_seconds": 0} + } + }` + rec := f.do(t, http.MethodPost, "/agent-network/budget-rules", body) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, + "sub-minute window must be rejected as a validation error (not a route/auth 4xx): got %d body=%s", rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "window_seconds", + "rejection body must name the offending window_seconds field, proving the validation path: %s", rec.Body.String()) +} + +// TestSettingsHandler_GetExposesCollectionToggles asserts the GET settings wire +// shape carries the account-level collection toggles after a store seed. +func TestSettingsHandler_GetExposesCollectionToggles(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + require.NoError(t, f.store.SaveAgentNetworkSettings(context.Background(), &agentNetworkTypes.Settings{ + AccountID: testAccountID, + Domain: "violet.eu.proxy.netbird.io", + ProxyAddress: "eu.proxy.netbird.io", + EnableLogCollection: true, + EnablePromptCollection: true, + RedactPii: false, + })) + + rec := f.do(t, http.MethodGet, "/agent-network/settings", "") + require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String()) + + var got api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.True(t, got.EnableLogCollection, "log collection toggle must surface on the wire") + assert.True(t, got.EnablePromptCollection, "prompt collection toggle must surface on the wire") + assert.False(t, got.RedactPii, "redact toggle must surface its false value") + assert.Equal(t, "violet.eu.proxy.netbird.io", got.Endpoint, "endpoint stays computed from immutable cluster+subdomain") +} + +func trimSpace(s string) string { + for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\r') { + s = s[:len(s)-1] + } + for len(s) > 0 && (s[0] == '\n' || s[0] == ' ' || s[0] == '\t' || s[0] == '\r') { + s = s[1:] + } + return s +} diff --git a/management/internals/modules/agentnetwork/handlers/consumption_handler.go b/management/internals/modules/agentnetwork/handlers/consumption_handler.go new file mode 100644 index 000000000..654f23109 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/consumption_handler.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +// addConsumptionEndpoints registers the read-only Agent Network +// consumption listing — backs the dashboard's basic counter view. +func (h *handler) addConsumptionEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/consumption", h.listConsumption).Methods("GET", "OPTIONS") +} + +func (h *handler) listConsumption(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + rows, err := h.manager.ListConsumption(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + out := make([]api.AgentNetworkConsumption, 0, len(rows)) + for _, row := range rows { + out = append(out, consumptionToAPI(row)) + } + util.WriteJSONObject(r.Context(), w, out) +} + +func consumptionToAPI(c *types.Consumption) api.AgentNetworkConsumption { + windowStart := c.WindowStartUTC + updatedAt := c.UpdatedAt + return api.AgentNetworkConsumption{ + DimensionKind: api.AgentNetworkConsumptionDimensionKind(c.DimensionKind), + DimensionId: c.DimensionID, + WindowSeconds: c.WindowSeconds, + WindowStartUtc: windowStart, + TokensInput: c.TokensInput, + TokensOutput: c.TokensOutput, + CostUsd: c.CostUSD, + UpdatedAt: &updatedAt, + } +} diff --git a/management/internals/modules/agentnetwork/handlers/guardrails_handler.go b/management/internals/modules/agentnetwork/handlers/guardrails_handler.go new file mode 100644 index 000000000..81f19b9f1 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/guardrails_handler.go @@ -0,0 +1,171 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +// addGuardrailEndpoints registers all Agent Network guardrail routes. +func (h *handler) addGuardrailEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/guardrails", h.getAllGuardrails).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/guardrails", h.createGuardrail).Methods("POST", "OPTIONS") + router.HandleFunc("/agent-network/guardrails/{guardrailId}", h.getGuardrail).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/guardrails/{guardrailId}", h.updateGuardrail).Methods("PUT", "OPTIONS") + router.HandleFunc("/agent-network/guardrails/{guardrailId}", h.deleteGuardrail).Methods("DELETE", "OPTIONS") +} + +func (h *handler) getAllGuardrails(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + guardrails, err := h.manager.GetAllGuardrails(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + out := make([]*api.AgentNetworkGuardrail, 0, len(guardrails)) + for _, g := range guardrails { + out = append(out, g.ToAPIResponse()) + } + util.WriteJSONObject(r.Context(), w, out) +} + +func (h *handler) getGuardrail(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + guardrailID := mux.Vars(r)["guardrailId"] + if guardrailID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "guardrail ID is required"), w) + return + } + + guardrail, err := h.manager.GetGuardrail(r.Context(), userAuth.AccountId, userAuth.UserId, guardrailID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, guardrail.ToAPIResponse()) +} + +func (h *handler) createGuardrail(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.AgentNetworkGuardrailRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validateGuardrail(&req); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + guardrail := types.NewGuardrail(userAuth.AccountId) + guardrail.FromAPIRequest(&req) + + created, err := h.manager.CreateGuardrail(r.Context(), userAuth.UserId, guardrail) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, created.ToAPIResponse()) +} + +func (h *handler) updateGuardrail(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + guardrailID := mux.Vars(r)["guardrailId"] + if guardrailID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "guardrail ID is required"), w) + return + } + + var req api.AgentNetworkGuardrailRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validateGuardrail(&req); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + guardrail := &types.Guardrail{ + ID: guardrailID, + AccountID: userAuth.AccountId, + } + guardrail.FromAPIRequest(&req) + + updated, err := h.manager.UpdateGuardrail(r.Context(), userAuth.UserId, guardrail) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse()) +} + +func (h *handler) deleteGuardrail(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + guardrailID := mux.Vars(r)["guardrailId"] + if guardrailID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "guardrail ID is required"), w) + return + } + + if err := h.manager.DeleteGuardrail(r.Context(), userAuth.AccountId, userAuth.UserId, guardrailID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +func validateGuardrail(req *api.AgentNetworkGuardrailRequest) error { + if strings.TrimSpace(req.Name) == "" { + return status.Errorf(status.InvalidArgument, "name is required") + } + + c := req.Checks + if c.ModelAllowlist.Enabled { + for _, id := range c.ModelAllowlist.Models { + if strings.TrimSpace(id) == "" { + return status.Errorf(status.InvalidArgument, "model_allowlist.models must not contain empty entries") + } + } + } + return nil +} diff --git a/management/internals/modules/agentnetwork/handlers/handlers_test.go b/management/internals/modules/agentnetwork/handlers/handlers_test.go new file mode 100644 index 000000000..6d1be3562 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/handlers_test.go @@ -0,0 +1,270 @@ +package handlers + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "testing" + + "go.uber.org/mock/gomock" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/account" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" + nbtypes "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +const ( + testAccountID = "acc-1" + testUserID = "user-bob" +) + +// agentNetworkHandlerFixture builds a real agentnetwork.Manager with +// a sqlite store and an always-allow permissions mock, then exposes +// the HTTP handlers via a gorilla router. Tests issue requests +// through httptest and assert on the wire shape — the same path the +// dashboard exercises. +type agentNetworkHandlerFixture struct { + store store.Store + manager agentnetwork.Manager + router *mux.Router +} + +func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("sqlite store not properly supported on Windows yet") + } + t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine)) + + st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanUp) + + ctrl := gomock.NewController(t) + perms := permissions.NewMockManager(ctrl) + // Always-allow: the handler tests are about wire shape, not + // authz. Authz is covered by the manager's own tests. + perms.EXPECT(). + ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, context.Background(), nil). + AnyTimes() + + // Swallow activity events so the mutation paths (create/update/delete) + // are exercisable through the HTTP layer. + accounts := account.NewMockManager(ctrl) + accounts.EXPECT(). + StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + AnyTimes() + accounts.EXPECT(). + UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()). + AnyTimes() + + manager := agentnetwork.NewManager(st, perms, accounts, nil) + h := &handler{manager: manager} + + router := mux.NewRouter() + router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST") + router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET") + router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT") + h.addPolicyEndpoints(router) + h.addConsumptionEndpoints(router) + h.addBudgetRuleEndpoints(router) + h.addSettingsEndpoints(router) + + return &agentNetworkHandlerFixture{ + store: st, + manager: manager, + router: router, + } +} + +func (f *agentNetworkHandlerFixture) do(t *testing.T, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, reader) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: testUserID, + AccountId: testAccountID, + }) + rec := httptest.NewRecorder() + f.router.ServeHTTP(rec, req) + return rec +} + +// seedProvider persists a minimal provider record so policy create +// passes the manager's destination_provider_ids existence check. +func (f *agentNetworkHandlerFixture) seedProvider(t *testing.T, id string) { + t.Helper() + require.NoError(t, f.store.SaveAgentNetworkProvider(context.Background(), &agentNetworkTypes.Provider{ + ID: id, + AccountID: testAccountID, + ProviderID: "openai_api", + Name: "test-" + id, + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test", + Enabled: true, + SessionPrivateKey: "test-priv-key", + SessionPublicKey: "test-pub-key", + })) +} + +// TestPolicyHandler_WindowSecondsRoundTrip ports bash 10 to Go: +// assert that a policy with window_seconds on both Token + Budget +// halves round-trips through GET unchanged AND that legacy +// window_hours / window_days are absent from the JSON response. We +// seed the policy directly via the store rather than POST-ing +// because the create path goes through the manager's +// accountManager.StoreEvent which we don't wire in this fixture; the +// on-wire shape is what matters here, and the POST validation path +// is covered separately by the RejectsSubMinuteWindow test. +func TestPolicyHandler_WindowSecondsRoundTrip(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + policy := &agentNetworkTypes.Policy{ + ID: "ainpol_test", + AccountID: testAccountID, + Name: "round-trip", + Enabled: true, + SourceGroups: []string{"grp-engineers"}, + DestinationProviderIDs: []string{"prov-1"}, + Limits: agentNetworkTypes.PolicyLimits{ + TokenLimit: agentNetworkTypes.PolicyTokenLimit{Enabled: true, GroupCap: 10000, UserCap: 5000, WindowSeconds: 86_400}, + BudgetLimit: agentNetworkTypes.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 10.0, UserCapUsd: 2.5, WindowSeconds: 2_592_000}, + }, + } + require.NoError(t, f.store.SaveAgentNetworkPolicy(context.Background(), policy)) + + rec := f.do(t, http.MethodGet, "/agent-network/policies/"+policy.ID, "") + require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String()) + + var got api.AgentNetworkPolicy + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Equal(t, int64(86_400), got.Limits.TokenLimit.WindowSeconds, "token_limit.window_seconds must round-trip") + assert.Equal(t, int64(2_592_000), got.Limits.BudgetLimit.WindowSeconds, "budget_limit.window_seconds must round-trip") + + // Legacy field names must NOT appear in the response — would + // signal that the management server is still emitting the old + // shape and would fool a v1 dashboard into rendering days/hours. + assert.NotContains(t, rec.Body.String(), "window_hours", + "legacy window_hours field must be absent from the on-wire response") + assert.NotContains(t, rec.Body.String(), "window_days", + "legacy window_days field must be absent from the on-wire response") +} + +// TestPolicyHandler_RejectsSubMinuteWindow ports bash 20 to Go: an +// enabled limit with window_seconds < 60 must surface as a 4xx +// because anything finer than per-minute produces an untenable +// volume of consumption rows for a feature whose value comes from +// per-window cap enforcement. +func TestPolicyHandler_RejectsSubMinuteWindow(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + f.seedProvider(t, "prov-1") + + body := `{ + "name": "sub-minute-window", + "enabled": true, + "source_groups": ["grp-engineers"], + "destination_provider_ids": ["prov-1"], + "guardrail_ids": [], + "limits": { + "token_limit": {"enabled": true, "group_cap": 10000, "user_cap": 5000, "window_seconds": 30}, + "budget_limit": {"enabled": false, "group_cap_usd": 0, "user_cap_usd": 0, "window_seconds": 0} + } + }` + rec := f.do(t, http.MethodPost, "/agent-network/policies", body) + // 422 specifically (InvalidArgument) proves the window-validation path — + // a route miss would be 404 and an auth failure 403, so a generic 4xx + // would let those false-pass. + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, + "enabled token_limit with window_seconds<60 must be rejected as a validation error: got %d body=%s", rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "window_seconds", + "rejection body must name the offending window_seconds field, proving it's the validation path: %s", rec.Body.String()) +} + +// TestConsumptionHandler_EmptyAccountReturnsArray ports bash 30 to +// Go: GET /agent-network/consumption on a clean account always +// returns a JSON array (possibly empty), never a 404 / 500. The +// dashboard depends on this shape to render its empty state. +func TestConsumptionHandler_EmptyAccountReturnsArray(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodGet, "/agent-network/consumption", "") + require.Equal(t, http.StatusOK, rec.Code) + + var rows []api.AgentNetworkConsumption + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rows), + "response must always be a JSON array — even when empty: %s", rec.Body.String()) + assert.Empty(t, rows) +} + +// TestConsumptionHandler_PopulatedAccountListsRows mirrors the +// /consumption read after a few RecordConsumption calls. Validates +// the wire shape carries every field the dashboard reads (dim_kind, +// dim_id, window_seconds, window_start_utc, tokens, cost_usd) and +// rows are ordered window-newest-first. +func TestConsumptionHandler_PopulatedAccountListsRows(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + require.NoError(t, f.manager.RecordConsumption( + context.Background(), testAccountID, + agentNetworkTypes.DimensionGroup, "grp-engineers", + 86_400, 100, 50, 0.0125, + )) + require.NoError(t, f.manager.RecordConsumption( + context.Background(), testAccountID, + agentNetworkTypes.DimensionUser, testUserID, + 86_400, 100, 50, 0.0125, + )) + + rec := f.do(t, http.MethodGet, "/agent-network/consumption", "") + require.Equal(t, http.StatusOK, rec.Code) + + var rows []api.AgentNetworkConsumption + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rows)) + require.Len(t, rows, 2, "two RecordConsumption calls must yield two rows") + + // Index by dim_kind so we can assert the full wire shape of each row, + // including the dimension id and the aligned window start the dashboard + // keys on. Both rows share totals and window. + byKind := make(map[string]api.AgentNetworkConsumption, len(rows)) + for _, row := range rows { + assert.Equal(t, int64(100), row.TokensInput) + assert.Equal(t, int64(50), row.TokensOutput) + assert.InDelta(t, 0.0125, row.CostUsd, 1e-9) + assert.Equal(t, int64(86_400), row.WindowSeconds) + assert.False(t, row.WindowStartUtc.IsZero(), "window_start_utc must be set on every row") + byKind[string(row.DimensionKind)] = row + } + + groupRow, ok := byKind["group"] + require.True(t, ok, "group dimension must surface") + assert.Equal(t, "grp-engineers", groupRow.DimensionId, "group row must carry the source group id as dimension_id") + + userRow, ok := byKind["user"] + require.True(t, ok, "user dimension must surface") + assert.Equal(t, testUserID, userRow.DimensionId, "user row must carry the user id as dimension_id") + + // Both rows fall in the same aligned window (same length, recorded + // together), so window_start_utc must match across them. + assert.Equal(t, groupRow.WindowStartUtc, userRow.WindowStartUtc, + "rows recorded in the same window must share the aligned window_start_utc") +} diff --git a/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go b/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go new file mode 100644 index 000000000..389c2ae50 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/model_discovery_handler_test.go @@ -0,0 +1,178 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// discoveryManagerStub records what the handler asked for and returns a canned +// answer. The Manager interface is embedded rather than implemented: only the +// one method is reachable from this handler, and a call to any other should +// fail loudly rather than silently return a zero value. +type discoveryManagerStub struct { + agentnetwork.Manager + + gotReq modeldiscovery.Request + gotRecordID string + models []modeldiscovery.Model + err error +} + +func (s *discoveryManagerStub) DiscoverProviderModels( + _ context.Context, _, _ string, req modeldiscovery.Request, recordID string, +) ([]modeldiscovery.Model, error) { + s.gotReq = req + s.gotRecordID = recordID + return s.models, s.err +} + +// postDiscovery drives the handler with an authenticated request. +func postDiscovery(t *testing.T, stub *discoveryManagerStub, body string) *httptest.ResponseRecorder { + t.Helper() + h := &handler{manager: stub} + + req := httptest.NewRequest(http.MethodPost, "/agent-network/catalog/providers/models", strings.NewReader(body)) + req = req.WithContext(nbcontext.SetUserAuthInContext(req.Context(), auth.UserAuth{ + AccountId: "acc-1", + UserId: "user-1", + })) + + rec := httptest.NewRecorder() + h.discoverProviderModels(rec, req) + return rec +} + +func TestDiscoverModelsReturnsTheVendorList(t *testing.T) { + stub := &discoveryManagerStub{models: []modeldiscovery.Model{ + {ID: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", Label: "EU Claude Haiku 4.5", PricingKnown: true}, + {ID: "global.cohere.embed-v4:0", Label: "Global Cohere Embed v4"}, + // A vendor that supplies no display name at all. Bedrock does for + // every profile, but the OpenAI listing carries none. + {ID: "gpt-4o-mini", PricingKnown: true}, + }} + + rec := postDiscovery(t, stub, `{ + "catalog_provider_id":"bedrock_api", + "upstream_url":"https://bedrock-runtime.eu-central-1.amazonaws.com", + "api_key":"aws-bearer" + }`) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var out api.AgentNetworkModelDiscoveryResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + require.Len(t, out.Models, 3) + + assert.Equal(t, "eu.anthropic.claude-haiku-4-5-20251001-v1:0", out.Models[0].Id) + assert.True(t, out.Models[0].PricingKnown) + // An unpriced model must say so rather than arriving indistinguishable + // from a priced one: registering it silently would meter at zero. + assert.False(t, out.Models[1].PricingKnown) + + require.NotNil(t, out.Models[0].Label, "the vendor supplied a display name") + assert.Equal(t, "EU Claude Haiku 4.5", *out.Models[0].Label) + // A vendor that supplies no name must omit the key rather than send an + // empty string: the dashboard falls back to the id on absence, and would + // render a blank row for "". + assert.Nil(t, out.Models[2].Label, "an absent label must not serialize") + assert.NotContains(t, rec.Body.String(), `"label":""`) + + assert.Equal(t, "bedrock_api", stub.gotReq.CatalogID) + assert.Equal(t, "aws-bearer", stub.gotReq.APIKey) + // The upstream is what the region is read back out of for Bedrock, so + // losing it here would break discovery for every regional provider. + assert.Equal(t, "https://bedrock-runtime.eu-central-1.amazonaws.com", stub.gotReq.UpstreamURL) + assert.Empty(t, stub.gotRecordID) +} + +func TestDiscoverModelsUsesAStoredRecordWithoutAKey(t *testing.T) { + stub := &discoveryManagerStub{} + + rec := postDiscovery(t, stub, `{"catalog_provider_id":"openai_api","provider_id":"prov-42"}`) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + // The dashboard refreshes a saved provider's list without ever holding + // the credential, so the record id has to reach the manager. + assert.Equal(t, "prov-42", stub.gotRecordID) + assert.Empty(t, stub.gotReq.APIKey) +} + +// TestDiscoverModelsRefusesMixedCredentials covers the case where a caller +// names a saved provider AND supplies a key. Accepting it would run an +// arbitrary credential under the identity of a record the caller may only be +// permitted to read. +func TestDiscoverModelsRefusesMixedCredentials(t *testing.T) { + stub := &discoveryManagerStub{} + + rec := postDiscovery(t, stub, `{ + "catalog_provider_id":"openai_api", + "provider_id":"prov-42", + "api_key":"sk-attacker" + }`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Empty(t, stub.gotRecordID, "the request must be refused before it reaches the manager") +} + +// TestDiscoverModelsReportsNoDiscoveryDistinctly matters because the caller +// falls back to the catalog's own model list on this outcome. Collapsing it +// into a generic 500 would turn "this provider has no listing endpoint" into +// "something went wrong", and the form would show an error instead of a list. +func TestDiscoverModelsReportsNoDiscoveryDistinctly(t *testing.T) { + stub := &discoveryManagerStub{err: modeldiscovery.ErrNoDiscovery} + + rec := postDiscovery(t, stub, `{"catalog_provider_id":"litellm_proxy","upstream_url":"https://gw.example.com","api_key":"sk"}`) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code) +} + +// TestDiscoverModelsTrimsTheCatalogID pins that the id the emptiness check +// accepts is the id the manager receives. A padded value that clears the check +// but reaches the catalog untrimmed misses the lookup, and the operator is told +// their provider does not exist. +func TestDiscoverModelsTrimsTheCatalogID(t *testing.T) { + stub := &discoveryManagerStub{} + + rec := postDiscovery(t, stub, `{"catalog_provider_id":" openai_api ","api_key":"sk"}`) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, "openai_api", stub.gotReq.CatalogID) +} + +// TestDiscoverModelsReportsCallerInputAsBadRequest covers the other half of the +// error mapping. These failures are all reachable from a well-formed request +// with a bad field value, so answering 500 both misinforms the operator and +// puts their typo into the server's error rate. +func TestDiscoverModelsReportsCallerInputAsBadRequest(t *testing.T) { + stub := &discoveryManagerStub{ + err: fmt.Errorf("%w: unknown catalog provider %q", modeldiscovery.ErrInvalidRequest, "nope"), + } + + rec := postDiscovery(t, stub, `{"catalog_provider_id":"nope","api_key":"sk"}`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "unknown catalog provider") +} + +func TestDiscoverModelsRejectsMalformedRequests(t *testing.T) { + for name, body := range map[string]string{ + "not json": `{`, + "no catalog provider": `{"api_key":"sk"}`, + "blank catalog provider": `{"catalog_provider_id":" ","api_key":"sk"}`, + } { + t.Run(name, func(t *testing.T) { + stub := &discoveryManagerStub{} + rec := postDiscovery(t, stub, body) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + } +} diff --git a/management/internals/modules/agentnetwork/handlers/policies_handler.go b/management/internals/modules/agentnetwork/handlers/policies_handler.go new file mode 100644 index 000000000..b821a5295 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/policies_handler.go @@ -0,0 +1,228 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +// minWindowSeconds is the floor enforced on enabled token / budget +// limit windows. One minute is short enough for fine-grained burst +// control without producing untenable consumption-row volume at scale. +const minWindowSeconds int64 = 60 + +// addPolicyEndpoints registers all Agent Network policy routes on the +// shared handler. +func (h *handler) addPolicyEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/policies", h.getAllPolicies).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/policies", h.createPolicy).Methods("POST", "OPTIONS") + router.HandleFunc("/agent-network/policies/{policyId}", h.getPolicy).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/policies/{policyId}", h.updatePolicy).Methods("PUT", "OPTIONS") + router.HandleFunc("/agent-network/policies/{policyId}", h.deletePolicy).Methods("DELETE", "OPTIONS") +} + +func (h *handler) getAllPolicies(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + policies, err := h.manager.GetAllPolicies(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + out := make([]*api.AgentNetworkPolicy, 0, len(policies)) + for _, p := range policies { + out = append(out, p.ToAPIResponse()) + } + util.WriteJSONObject(r.Context(), w, out) +} + +func (h *handler) getPolicy(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + policyID := mux.Vars(r)["policyId"] + if policyID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "policy ID is required"), w) + return + } + + policy, err := h.manager.GetPolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policyID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, policy.ToAPIResponse()) +} + +func (h *handler) createPolicy(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.AgentNetworkPolicyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validatePolicy(&req); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + policy := types.NewPolicy(userAuth.AccountId) + policy.FromAPIRequest(&req) + + created, err := h.manager.CreatePolicy(r.Context(), userAuth.UserId, policy) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, created.ToAPIResponse()) +} + +func (h *handler) updatePolicy(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + policyID := mux.Vars(r)["policyId"] + if policyID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "policy ID is required"), w) + return + } + + var req api.AgentNetworkPolicyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validatePolicy(&req); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + policy := &types.Policy{ + ID: policyID, + AccountID: userAuth.AccountId, + } + policy.FromAPIRequest(&req) + + updated, err := h.manager.UpdatePolicy(r.Context(), userAuth.UserId, policy) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse()) +} + +func (h *handler) deletePolicy(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + policyID := mux.Vars(r)["policyId"] + if policyID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "policy ID is required"), w) + return + } + + if err := h.manager.DeletePolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policyID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +func validatePolicy(req *api.AgentNetworkPolicyRequest) error { + if strings.TrimSpace(req.Name) == "" { + return status.Errorf(status.InvalidArgument, "name is required") + } + if len(req.SourceGroups) == 0 { + return status.Errorf(status.InvalidArgument, "source_groups must contain at least one group id") + } + for _, id := range req.SourceGroups { + if strings.TrimSpace(id) == "" { + return status.Errorf(status.InvalidArgument, "source_groups must not contain empty entries") + } + } + if len(req.DestinationProviderIds) == 0 { + return status.Errorf(status.InvalidArgument, "destination_provider_ids must contain at least one provider id") + } + for _, id := range req.DestinationProviderIds { + if strings.TrimSpace(id) == "" { + return status.Errorf(status.InvalidArgument, "destination_provider_ids must not contain empty entries") + } + } + if req.GuardrailIds != nil { + for _, id := range *req.GuardrailIds { + if strings.TrimSpace(id) == "" { + return status.Errorf(status.InvalidArgument, "guardrail_ids must not contain empty entries") + } + } + } + if req.Limits != nil { + if err := validatePolicyLimits(*req.Limits); err != nil { + return err + } + } + return nil +} + +func validatePolicyLimits(l api.AgentNetworkPolicyLimits) error { + if l.TokenLimit.Enabled { + if l.TokenLimit.WindowSeconds < minWindowSeconds { + return status.Errorf(status.InvalidArgument, "limits.token_limit.window_seconds must be at least %d (one minute) when enabled", minWindowSeconds) + } + if l.TokenLimit.GroupCap < 0 { + return status.Errorf(status.InvalidArgument, "limits.token_limit.group_cap must not be negative") + } + if l.TokenLimit.UserCap < 0 { + return status.Errorf(status.InvalidArgument, "limits.token_limit.user_cap must not be negative") + } + if l.TokenLimit.GroupCap == 0 && l.TokenLimit.UserCap == 0 { + return status.Errorf(status.InvalidArgument, "limits.token_limit requires group_cap or user_cap to be greater than zero when enabled") + } + } + if l.BudgetLimit.Enabled { + if l.BudgetLimit.WindowSeconds < minWindowSeconds { + return status.Errorf(status.InvalidArgument, "limits.budget_limit.window_seconds must be at least %d (one minute) when enabled", minWindowSeconds) + } + if l.BudgetLimit.GroupCapUsd < 0 { + return status.Errorf(status.InvalidArgument, "limits.budget_limit.group_cap_usd must not be negative") + } + if l.BudgetLimit.UserCapUsd < 0 { + return status.Errorf(status.InvalidArgument, "limits.budget_limit.user_cap_usd must not be negative") + } + if l.BudgetLimit.GroupCapUsd == 0 && l.BudgetLimit.UserCapUsd == 0 { + return status.Errorf(status.InvalidArgument, "limits.budget_limit requires group_cap_usd or user_cap_usd to be greater than zero when enabled") + } + } + return nil +} diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler.go b/management/internals/modules/agentnetwork/handlers/providers_handler.go new file mode 100644 index 000000000..645d1da61 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/providers_handler.go @@ -0,0 +1,376 @@ +// Package handlers serves the Agent Network HTTP API. +// +// All persistence is delegated to agentnetwork.Manager so this layer only +// translates between the wire format (api.AgentNetworkProvider*) and the +// domain types. +package handlers + +import ( + "encoding/json" + "errors" + "math" + "net/http" + "net/url" + "strings" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +type handler struct { + manager agentnetwork.Manager +} + +// RegisterEndpoints registers all Agent Network routes. +func RegisterEndpoints(manager agentnetwork.Manager, router *mux.Router) { + h := &handler{manager: manager} + router.HandleFunc("/agent-network/catalog/providers", h.getCatalogProviders).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/catalog/providers/models", h.discoverProviderModels).Methods("POST", "OPTIONS") + router.HandleFunc("/agent-network/providers", h.getAllProviders).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST", "OPTIONS") + router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT", "OPTIONS") + router.HandleFunc("/agent-network/providers/{providerId}", h.deleteProvider).Methods("DELETE", "OPTIONS") + h.addPolicyEndpoints(router) + h.addGuardrailEndpoints(router) + h.addSettingsEndpoints(router) + h.addConsumptionEndpoints(router) + h.addAccessLogEndpoints(router) + h.addBudgetRuleEndpoints(router) +} + +func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) { + if _, err := nbcontext.GetUserAuthFromContext(r.Context()); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + entries := catalog.All() + out := make([]api.AgentNetworkCatalogProvider, 0, len(entries)) + for _, e := range entries { + resp := e.ToAPIResponse() + applyDefaultPricing(e, &resp) + out = append(out, resp) + } + util.WriteJSONObject(r.Context(), w, out) +} + +// discoverProviderModels asks the vendor which models the operator's own +// credential can reach, so the provider form can offer a live list rather than +// only the static catalog. +func (h *handler) discoverProviderModels(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var body api.AgentNetworkModelDiscoveryRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + util.WriteErrorResponse("invalid json", http.StatusBadRequest, w) + return + } + // Trimmed once and carried, not trimmed for the emptiness test and then + // discarded: a padded " openai_api " would clear the check here and miss + // the catalog lookup, reporting the provider as unknown. + catalogID := strings.TrimSpace(body.CatalogProviderId) + if catalogID == "" { + util.WriteErrorResponse("catalog_provider_id is required", http.StatusBadRequest, w) + return + } + + recordID := strValue(body.ProviderId) + req := modeldiscovery.Request{ + CatalogID: catalogID, + UpstreamURL: strValue(body.UpstreamUrl), + APIKey: strValue(body.ApiKey), + } + // One source of credential or the other, never a mix: taking a key from + // the request while addressing a saved record would let a caller run an + // arbitrary credential against a provider they can only read. + if recordID != "" && req.APIKey != "" { + util.WriteErrorResponse("provide either provider_id or api_key, not both", http.StatusBadRequest, w) + return + } + + models, err := h.manager.DiscoverProviderModels(r.Context(), userAuth.AccountId, userAuth.UserId, req, recordID) + if err != nil { + // A provider with no listing endpoint is a fact about the catalog + // entry, not a failure: the caller falls back to the catalog's own + // models, so it must be able to tell the two apart. + if errors.Is(err, modeldiscovery.ErrNoDiscovery) { + util.WriteErrorResponse(err.Error(), http.StatusUnprocessableEntity, w) + return + } + // An unknown provider, an unusable upstream, a missing region or a + // missing key are all things the caller sent, reachable from a + // well-formed request. Reporting them as 500 tells the operator the + // server broke and buries genuine faults in the error rate. + if errors.Is(err, modeldiscovery.ErrInvalidRequest) { + util.WriteErrorResponse(err.Error(), http.StatusBadRequest, w) + return + } + util.WriteError(r.Context(), err, w) + return + } + + out := api.AgentNetworkModelDiscoveryResponse{Models: make([]api.AgentNetworkDiscoveredModel, 0, len(models))} + for _, m := range models { + entry := api.AgentNetworkDiscoveredModel{ + Id: m.ID, + PricingKnown: m.PricingKnown, + // Sent even when zero: the form prefills every discovered model as + // an editable row, and an unpriced one is shown at zero and flagged + // rather than left out. + InputPer1k: m.InputPer1k, + OutputPer1k: m.OutputPer1k, + // Cache rates stay absent when unset, matching the catalog + // response — a zero would read as "free", not "not applicable". + CachedInputPer1k: positiveRatePtr(m.CachedInputPer1k), + CacheReadPer1k: positiveRatePtr(m.CacheReadPer1k), + CacheCreationPer1k: positiveRatePtr(m.CacheCreationPer1k), + } + if m.Label != "" { + label := m.Label + entry.Label = &label + } + out.Models = append(out.Models, entry) + } + util.WriteJSONObject(r.Context(), w, out) +} + +// strValue reads an optional string field, treating absent as empty. +func strValue(v *string) string { + if v == nil { + return "" + } + return strings.TrimSpace(*v) +} + +// applyDefaultPricing overwrites the catalog response's model rates with +// the LIVE default pricing table, which may differ from the compiled-in +// catalog rates when the operator provides a defaults_llm_pricing.yaml. +// This keeps the dashboard's model-row prefill identical to what the +// proxy will actually bill — the same table the synthesizer ships. +func applyDefaultPricing(cp catalog.Provider, resp *api.AgentNetworkCatalogProvider) { + if len(cp.PricingSurfaces) == 0 { + return + } + for i := range resp.Models { + m := &resp.Models[i] + e, ok := pricing.LookupDefault(cp.PricingSurfaces, m.Id) + if !ok { + continue + } + m.InputPer1k = e.InputPer1k + m.OutputPer1k = e.OutputPer1k + m.CachedInputPer1k = positiveRatePtr(e.CachedInputPer1k) + m.CacheReadPer1k = positiveRatePtr(e.CacheReadPer1k) + m.CacheCreationPer1k = positiveRatePtr(e.CacheCreationPer1k) + } +} + +// positiveRatePtr renders a cache rate for the API: absent (nil) when +// unset, matching the catalog response convention. +func positiveRatePtr(v float64) *float64 { + if v <= 0 { + return nil + } + return &v +} + +func (h *handler) getAllProviders(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + providers, err := h.manager.GetAllProviders(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + out := make([]*api.AgentNetworkProvider, 0, len(providers)) + for _, p := range providers { + out = append(out, p.ToAPIResponse()) + } + util.WriteJSONObject(r.Context(), w, out) +} + +func (h *handler) getProvider(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + providerID := mux.Vars(r)["providerId"] + if providerID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "provider ID is required"), w) + return + } + + provider, err := h.manager.GetProvider(r.Context(), userAuth.AccountId, userAuth.UserId, providerID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, provider.ToAPIResponse()) +} + +func (h *handler) createProvider(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.AgentNetworkProviderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validate(&req, true); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + provider := types.NewProvider(userAuth.AccountId) + provider.FromAPIRequest(&req) + + created, err := h.manager.CreateProvider(r.Context(), userAuth.UserId, provider) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, created.ToAPIResponse()) +} + +func (h *handler) updateProvider(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + providerID := mux.Vars(r)["providerId"] + if providerID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "provider ID is required"), w) + return + } + + var req api.AgentNetworkProviderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if err := validate(&req, false); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + provider := &types.Provider{ + ID: providerID, + AccountID: userAuth.AccountId, + } + provider.FromAPIRequest(&req) + + updated, err := h.manager.UpdateProvider(r.Context(), userAuth.UserId, provider) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse()) +} + +func (h *handler) deleteProvider(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + providerID := mux.Vars(r)["providerId"] + if providerID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "provider ID is required"), w) + return + } + + if err := h.manager.DeleteProvider(r.Context(), userAuth.AccountId, userAuth.UserId, providerID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +func validate(req *api.AgentNetworkProviderRequest, requireAPIKey bool) error { + if strings.TrimSpace(req.ProviderId) == "" { + return status.Errorf(status.InvalidArgument, "provider_id is required") + } + if !catalog.IsKnown(req.ProviderId) { + return status.Errorf(status.InvalidArgument, "provider_id %q is not a known catalog provider", req.ProviderId) + } + if strings.TrimSpace(req.Name) == "" { + return status.Errorf(status.InvalidArgument, "name is required") + } + if strings.TrimSpace(req.UpstreamUrl) == "" { + return status.Errorf(status.InvalidArgument, "upstream_url is required") + } + u, err := url.Parse(strings.TrimSpace(req.UpstreamUrl)) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return status.Errorf(status.InvalidArgument, "upstream_url must be a full http(s) URL") + } + if requireAPIKey && (req.ApiKey == nil || strings.TrimSpace(*req.ApiKey) == "") { + return status.Errorf(status.InvalidArgument, "api_key is required") + } + if req.Models != nil { + for i, m := range *req.Models { + if err := validateModel(i, m); err != nil { + return err + } + } + } + return nil +} + +// validateModel is the single ingress guard for operator-entered pricing: +// these rates are synthesized into the proxy's cost_meter config verbatim, +// and a negative or non-finite rate there would poison every cost the +// proxy records, so reject at the API boundary. +func validateModel(i int, m api.AgentNetworkProviderModel) error { + if strings.TrimSpace(m.Id) == "" { + return status.Errorf(status.InvalidArgument, "models[%d]: id is required", i) + } + rates := map[string]*float64{ + "input_per_1k": &m.InputPer1k, + "output_per_1k": &m.OutputPer1k, + "cached_input_per_1k": m.CachedInputPer1k, + "cache_read_per_1k": m.CacheReadPer1k, + "cache_creation_per_1k": m.CacheCreationPer1k, + } + for field, v := range rates { + if v == nil { + continue + } + if *v < 0 || math.IsNaN(*v) || math.IsInf(*v, 0) { + return status.Errorf(status.InvalidArgument, "models[%d] (%s): %s must be a finite, non-negative USD rate", i, m.Id, field) + } + } + return nil +} diff --git a/management/internals/modules/agentnetwork/handlers/providers_handler_test.go b/management/internals/modules/agentnetwork/handlers/providers_handler_test.go new file mode 100644 index 000000000..05024cde9 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/providers_handler_test.go @@ -0,0 +1,102 @@ +package handlers + +import ( + "encoding/json" + "math" + nethttp "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func f(v float64) *float64 { return &v } + +// TestValidate_ModelRates guards the single ingress point for operator-entered +// pricing. These rates flow verbatim into the proxy's cost_meter config at +// synthesis time; the proxy treats a bad rate as a chain-build failure, so +// rejecting here is what keeps an account's gateway from going down. +func TestValidate_ModelRates(t *testing.T) { + base := func(models ...api.AgentNetworkProviderModel) *api.AgentNetworkProviderRequest { + key := "sk-test" + return &api.AgentNetworkProviderRequest{ + ProviderId: "openai_api", + Name: "OpenAI", + UpstreamUrl: "https://api.openai.com", + ApiKey: &key, + Models: &models, + } + } + + valid := api.AgentNetworkProviderModel{ + Id: "gpt-4o", InputPer1k: 0.0025, OutputPer1k: 0.01, + CachedInputPer1k: f(0.00125), + } + require.NoError(t, validate(base(valid), true), "finite non-negative rates must pass") + + zeroRates := api.AgentNetworkProviderModel{Id: "self-hosted-llama", InputPer1k: 0, OutputPer1k: 0} + require.NoError(t, validate(base(zeroRates), true), "explicit zero prices are allowed (free / self-hosted models)") + + cases := map[string]api.AgentNetworkProviderModel{ + "empty id": {Id: " ", InputPer1k: 0.001, OutputPer1k: 0.002}, + "negative input": {Id: "m", InputPer1k: -0.001, OutputPer1k: 0.002}, + "negative output": {Id: "m", InputPer1k: 0.001, OutputPer1k: -0.002}, + "NaN input": {Id: "m", InputPer1k: math.NaN(), OutputPer1k: 0.002}, + "Inf output": {Id: "m", InputPer1k: 0.001, OutputPer1k: math.Inf(1)}, + "negative cached": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CachedInputPer1k: f(-1)}, + "NaN cache read": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CacheReadPer1k: f(math.NaN())}, + "Inf cache creation": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CacheCreationPer1k: f(math.Inf(-1))}, + } + for name, m := range cases { + assert.Error(t, validate(base(m), true), "case %q must be rejected", name) + } +} + +// TestProviderHandler_UpdateReplacesFullState pins the update contract shared +// with the other PUT endpoints: the request replaces the provider's mutable +// state, so optional fields absent from the JSON land as their zero values. +// The two exceptions are server-side: the api_key (a secret — omitted means +// "not rotated") and the session keypair, both preserved by the manager. The +// identity headers stay on the wire as explicit empty strings so a cleared +// value round-trips. +func TestProviderHandler_UpdateReplacesFullState(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + create := `{ + "provider_id": "openai_api", + "name": "openai", + "upstream_url": "https://api.openai.com", + "api_key": "sk-test", + "enabled": true, + "metadata_disabled": true, + "skip_tls_verification": true, + "extra_values": {"x-portkey-config": "pc-prod-3f2a"}, + "identity_header_user_id": "x-bf-dim-netbird_user_id", + "models": [{"id": "gpt-4o", "input_per_1k": 0.0025, "output_per_1k": 0.01}] + }` + rec := f.do(t, nethttp.MethodPost, "/agent-network/providers", create) + require.Equal(t, nethttp.StatusOK, rec.Code, "create must succeed: %s", rec.Body.String()) + + var created api.AgentNetworkProvider + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) + + // Minimal update: only the required fields, no api_key. Everything + // optional must land as its zero value. + update := `{"provider_id": "openai_api", "name": "openai-renamed", "upstream_url": "https://api.openai.com", "enabled": true}` + rec = f.do(t, nethttp.MethodPut, "/agent-network/providers/"+created.Id, update) + require.Equal(t, nethttp.StatusOK, rec.Code, "update without api_key must succeed (key is preserved): %s", rec.Body.String()) + + var updated api.AgentNetworkProvider + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated)) + assert.Equal(t, "openai-renamed", updated.Name, "sent field must apply") + assert.True(t, updated.Enabled, "sent field must apply") + assert.False(t, updated.MetadataDisabled, "omitted metadata_disabled must land as false — PUT replaces the full state") + assert.False(t, updated.SkipTlsVerification, "omitted skip_tls_verification must land as false") + assert.Nil(t, updated.ExtraValues, "omitted extra_values must be cleared") + assert.Equal(t, "", updated.IdentityHeaderUserId, "omitted identity header must be cleared yet stay on the wire") + assert.Empty(t, updated.Models, "omitted models must be cleared") + assert.Contains(t, rec.Body.String(), `"identity_header_user_id":""`, + "cleared identity header must round-trip as an explicit empty string") +} diff --git a/management/internals/modules/agentnetwork/handlers/settings_handler.go b/management/internals/modules/agentnetwork/handlers/settings_handler.go new file mode 100644 index 000000000..0a365f9ce --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/settings_handler.go @@ -0,0 +1,127 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +// addSettingsEndpoints registers the Agent Network settings routes. POST +// bootstraps the settings row, assigning the account's immutable endpoint; +// GET reads it (defaults with an empty endpoint before bootstrap); PUT +// carries every field, replacing the mutable collection toggles and rejecting +// any change to the identity fields; DELETE removes the row — guarded so it +// stays a bootstrap-repair operation — releasing the endpoint for a fresh +// bootstrap. +func (h *handler) addSettingsEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/settings", h.createSettings).Methods("POST", "OPTIONS") + router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS") + router.HandleFunc("/agent-network/settings", h.deleteSettings).Methods("DELETE", "OPTIONS") +} + +// createSettings bootstraps the account's settings row. Exactly one of +// proxy_address (labeled endpoint; the server allocates the label) and +// endpoint (self-addressed, claimed verbatim) must be provided; optional +// collection toggles ride along with defaults for omitted fields. +func (h *handler) createSettings(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.AgentNetworkSettingsCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + settings := types.DefaultSettings(userAuth.AccountId) + settings.FromAPICreateRequest(&req) + + proxyAddress := "" + if req.ProxyAddress != nil { + proxyAddress = *req.ProxyAddress + } + endpoint := "" + if req.Endpoint != nil { + endpoint = *req.Endpoint + } + + created, err := h.manager.CreateSettings(r.Context(), userAuth.UserId, settings, proxyAddress, endpoint) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, created.ToAPIResponse()) +} + +// updateSettings replaces the mutable settings fields on the account's row. +// A request carrying a cluster bootstraps the row when the account doesn't +// have one yet. +func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.AgentNetworkSettingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + settings := &types.Settings{AccountID: userAuth.AccountId} + settings.FromAPIRequest(&req) + + updated, err := h.manager.UpdateSettings(r.Context(), userAuth.UserId, settings) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse()) +} + +// deleteSettings removes the account's settings row, releasing the endpoint. +// The manager refuses (412) while providers exist or a proxy is actively +// serving the endpoint; a later POST bootstraps fresh, allocating a new +// endpoint. +func (h *handler) deleteSettings(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + if err := h.manager.DeleteSettings(r.Context(), userAuth.AccountId, userAuth.UserId); err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +// getSettings returns the account's agent-network settings. Accounts that +// haven't been bootstrapped yet read as the defaults with an empty cluster, +// subdomain and endpoint; the manager synthesises that view. +func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, settings.ToAPIResponse()) +} diff --git a/management/internals/modules/agentnetwork/handlers/settings_handler_test.go b/management/internals/modules/agentnetwork/handlers/settings_handler_test.go new file mode 100644 index 000000000..400208e1c --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/settings_handler_test.go @@ -0,0 +1,395 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestSettingsHandler_GetUnbootstrappedReturnsDefaults pins the settings-read +// convention shared with the account and DNS settings endpoints: settings +// always read as a JSON object. Before bootstrap that object carries the +// defaults with an empty endpoint/proxy_address (the "not bootstrapped" +// signal) and no timestamps — never a 404 and never the legacy null body. +func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodGet, "/agent-network/settings", "") + require.Equal(t, http.StatusOK, rec.Code, + "unbootstrapped account must read as 200 with defaults: got %d body=%s", rec.Code, rec.Body.String()) + require.NotEqual(t, "null", trimSpace(rec.Body.String()), + "the legacy 200+null shape must not come back") + + var got api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped") + assert.Empty(t, got.ProxyAddress, "proxy address must be empty until bootstrapped") + assert.False(t, got.Dedicated, "an unbootstrapped account has no serving shape") + assert.True(t, got.EnableLogCollection, "defaults must show log collection on, matching bootstrap") + assert.False(t, got.EnablePromptCollection, "defaults must show prompt collection off") + assert.False(t, got.RedactPii, "defaults must show redaction off") + require.NotNil(t, got.AccessLogRetentionDays) + assert.Equal(t, 30, *got.AccessLogRetentionDays, "defaults must show the bootstrap retention") + assert.Nil(t, got.CreatedAt, "no timestamps before a row exists") + assert.Nil(t, got.UpdatedAt, "no timestamps before a row exists") +} + +// TestSettingsHandler_PostBootstrapsLabeled covers the labeled bootstrap +// shape: a POST carrying a proxy_address allocates a label beneath it, so the +// endpoint hangs one label under the shared cluster's address and the pin is +// not dedicated. Toggles riding along apply; omitted ones keep defaults. +func TestSettingsHandler_PostBootstrapsLabeled(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodPost, "/agent-network/settings", + `{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "access_log_retention_days": 14}`) + require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String()) + + var got api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Equal(t, "eu.proxy.netbird.io", got.ProxyAddress, "proxy address must be pinned from the request") + require.NotEmpty(t, got.Endpoint, "endpoint must be allocated at bootstrap") + assert.True(t, strings.HasSuffix(got.Endpoint, ".eu.proxy.netbird.io"), + "labeled endpoint must hang off the proxy address: %s", got.Endpoint) + label := strings.TrimSuffix(got.Endpoint, ".eu.proxy.netbird.io") + assert.NotContains(t, label, ".", "the allocated label must be a single DNS label: %s", label) + assert.False(t, got.Dedicated, "a labeled pin is not dedicated") + assert.True(t, got.EnableLogCollection, "omitted toggle must keep its default") + assert.True(t, got.EnablePromptCollection, "toggle from the bootstrap request must apply") + require.NotNil(t, got.AccessLogRetentionDays) + assert.Equal(t, 14, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply") + assert.NotNil(t, got.CreatedAt, "a persisted row carries timestamps") + + // The row is now readable via GET. + rec = f.do(t, http.MethodGet, "/agent-network/settings", "") + require.Equal(t, http.StatusOK, rec.Code, "GET after bootstrap must succeed") + var read api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &read)) + assert.Equal(t, got.Endpoint, read.Endpoint, "GET must return the bootstrapped endpoint") +} + +// TestSettingsHandler_PostBootstrapsSelfAddressed covers the dedicated shape: +// a POST carrying an endpoint claims the hostname verbatim, the proxy address +// equals it, and the pin reads as dedicated. The claim is legitimate before +// any proxy declares the address (address-first). +func TestSettingsHandler_PostBootstrapsSelfAddressed(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodPost, "/agent-network/settings", + `{"endpoint": "Brave-Otter.Gateway.Example.com"}`) + require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String()) + + var got api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Equal(t, "brave-otter.gateway.example.com", got.Endpoint, + "endpoint must be claimed verbatim, lowercased") + assert.Equal(t, got.Endpoint, got.ProxyAddress, "self-addressed: the proxy address is the endpoint") + assert.True(t, got.Dedicated, "a self-addressed pin is dedicated") + assert.True(t, got.EnableLogCollection, "omitted toggles must keep their defaults") +} + +// TestSettingsHandler_PostRequiresExactlyOneIdentityField pins the request +// contract: proxy_address and endpoint are mutually exclusive and one is +// required — both or neither is a validation error, not a guess. +func TestSettingsHandler_PostRequiresExactlyOneIdentityField(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodPost, "/agent-network/settings", `{}`) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, + "empty POST must be rejected: got %d body=%s", rec.Code, rec.Body.String()) + + rec = f.do(t, http.MethodPost, "/agent-network/settings", + `{"proxy_address": "eu.proxy.netbird.io", "endpoint": "brave-otter.gateway.example.com"}`) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, + "POST with both identity fields must be rejected: got %d body=%s", rec.Code, rec.Body.String()) +} + +// TestSettingsHandler_PostRejectsMalformedHostnames pins per-write input +// validation: shapes canonicalization cannot repair — trailing dots, embedded +// whitespace, empty labels — are rejected with a validation error instead of +// landing in an immutable column. +func TestSettingsHandler_PostRejectsMalformedHostnames(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + for name, body := range map[string]string{ + "trailing dot": `{"endpoint": "gateway.example.com."}`, + "leading dot": `{"endpoint": ".gateway.example.com"}`, + "inner whitespace": `{"endpoint": "gate way.example.com"}`, + "empty label": `{"proxy_address": "eu..proxy.netbird.io"}`, + } { + rec := f.do(t, http.MethodPost, "/agent-network/settings", body) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, + "%s must be rejected: got %d body=%s", name, rec.Code, rec.Body.String()) + } +} + +// TestSettingsHandler_PostConflictsOnSecondBootstrap pins that bootstrap is a +// one-time create: a second POST returns 409 and leaves the row untouched. +func TestSettingsHandler_PostConflictsOnSecondBootstrap(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`) + require.Equal(t, http.StatusOK, rec.Code, "first bootstrap must succeed: %s", rec.Body.String()) + var first api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &first)) + + rec = f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "us.proxy.netbird.io"}`) + assert.Equal(t, http.StatusConflict, rec.Code, + "second bootstrap must 409: got %d body=%s", rec.Code, rec.Body.String()) + + rec = f.do(t, http.MethodGet, "/agent-network/settings", "") + require.Equal(t, http.StatusOK, rec.Code) + var got api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Equal(t, first.Endpoint, got.Endpoint, "the original endpoint must survive the rejected bootstrap") + assert.Equal(t, first.ProxyAddress, got.ProxyAddress, "the original proxy address must survive") +} + +// TestSettingsHandler_PutBeforeBootstrapIs404 pins that a PUT cannot conjure a +// settings row out of nothing — bootstrap is the explicit POST — and the +// error points the caller there. +func TestSettingsHandler_PutBeforeBootstrapIs404(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodPut, "/agent-network/settings", + `{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false}`) + assert.Equal(t, http.StatusNotFound, rec.Code, + "PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "/api/agent-network/settings", + "the error must point the caller at the bootstrap POST: %s", rec.Body.String()) +} + +// TestSettingsHandler_PutReplacesMutableFields pins the update contract shared +// with the other PUT endpoints: the request carries every field, replacing the +// mutable ones. The identity fields ride along as a required echo of the +// assigned values — compared, never written — so the endpoint and proxy +// address survive every accepted update. +func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodPost, "/agent-network/settings", + `{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`) + require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String()) + + var before api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before)) + + rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf( + `{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 7}`, + before.Endpoint, before.ProxyAddress)) + require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String()) + + var got api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.True(t, got.EnableLogCollection, "sent toggle must apply") + assert.False(t, got.EnablePromptCollection, "sent toggle must apply") + assert.False(t, got.RedactPii, "sent toggle must apply") + require.NotNil(t, got.AccessLogRetentionDays) + assert.Equal(t, 7, *got.AccessLogRetentionDays, "sent retention must apply") + assert.Equal(t, before.Endpoint, got.Endpoint, "endpoint must survive updates untouched") + assert.Equal(t, before.ProxyAddress, got.ProxyAddress, "proxy address must survive updates untouched") +} + +// TestSettingsHandler_PutRejectsChangedIdentity pins the immutability contract: +// the PUT carries the identity fields like every other field, but they are an +// echo — a request carrying a different endpoint or proxy address is rejected +// as a validation error and the row is left untouched. The comparison is +// lenient about casing (the stored values are normalized lowercase), so a +// client replaying a GET response with different casing is not rejected. +func TestSettingsHandler_PutRejectsChangedIdentity(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodPost, "/agent-network/settings", + `{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true}`) + require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String()) + var before api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before)) + + for name, body := range map[string]string{ + "changed endpoint": fmt.Sprintf( + `{"endpoint": "other.gateway.example.com", "proxy_address": %q, "enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`, + before.ProxyAddress), + "changed proxy_address": fmt.Sprintf( + `{"endpoint": %q, "proxy_address": "us.proxy.netbird.io", "enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`, + before.Endpoint), + "omitted identity": `{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`, + } { + rec = f.do(t, http.MethodPut, "/agent-network/settings", body) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, + "%s must be rejected: got %d body=%s", name, rec.Code, rec.Body.String()) + } + + // The rejected updates must not have applied anything — toggles included. + rec = f.do(t, http.MethodGet, "/agent-network/settings", "") + require.Equal(t, http.StatusOK, rec.Code) + var got api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Equal(t, before.Endpoint, got.Endpoint, "rejected PUT must not change the endpoint") + assert.Equal(t, before.ProxyAddress, got.ProxyAddress, "rejected PUT must not change the proxy address") + assert.True(t, got.EnablePromptCollection, "rejected PUT must not apply its toggles") + + // An uppercased echo of the assigned values still names the same host and + // must be accepted. + rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf( + `{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`, + strings.ToUpper(before.Endpoint), strings.ToUpper(before.ProxyAddress))) + assert.Equal(t, http.StatusOK, rec.Code, + "an uppercased identity echo must be accepted: got %d body=%s", rec.Code, rec.Body.String()) +} + +// TestSettingsHandler_PutOmittedRetentionLandsAsZero documents a residual the +// required-ness of access_log_retention_days does not remove. Marking the field +// required changes the generated client type from *int to int, so a generated +// client cannot omit it — but nothing validates OpenAPI required-ness at +// runtime, so a hand-rolled body without the field still decodes as 0, which +// the API documents as "keep indefinitely". +// +// That is the same latitude the three booleans already have, so it is left +// consistent rather than special-cased. This test exists to make the gap +// explicit: if request validation is ever added, this expectation is what +// changes. +func TestSettingsHandler_PutOmittedRetentionLandsAsZero(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodPost, "/agent-network/settings", + `{"proxy_address": "eu.proxy.netbird.io", "access_log_retention_days": 14}`) + require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String()) + var before api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before)) + + rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf( + `{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`, + before.Endpoint, before.ProxyAddress)) + require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String()) + + var got api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.NotNil(t, got.AccessLogRetentionDays) + assert.Equal(t, 0, *got.AccessLogRetentionDays, + "a non-conforming body that omits retention still replaces it with the zero value") +} + +// TestSettingsHandler_DeleteBeforeBootstrapIs404 pins that DELETE on an +// account with no settings row is a 404, mirroring the PUT. +func TestSettingsHandler_DeleteBeforeBootstrapIs404(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodDelete, "/agent-network/settings", "") + assert.Equal(t, http.StatusNotFound, rec.Code, + "DELETE on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String()) +} + +// TestSettingsHandler_DeleteBlockedByProviders pins the first delete guard: +// while any provider exists for the account, the delete is refused with 412 +// and the row survives. Providers route through the endpoint — the guard +// keeps DELETE a bootstrap-repair operation rather than a way to abandon a +// configured gateway. +func TestSettingsHandler_DeleteBlockedByProviders(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`) + require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String()) + var before api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before)) + + f.seedProvider(t, "prov-guard") + + rec = f.do(t, http.MethodDelete, "/agent-network/settings", "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, + "delete with a provider present must be refused: got %d body=%s", rec.Code, rec.Body.String()) + + rec = f.do(t, http.MethodGet, "/agent-network/settings", "") + require.Equal(t, http.StatusOK, rec.Code) + var got api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + assert.Equal(t, before.Endpoint, got.Endpoint, "the refused delete must leave the row intact") +} + +// TestSettingsHandler_DeleteBlockedByActiveProxy pins the second delete +// guard: while a proxy is actively serving the endpoint — an active proxy +// row declaring the endpoint hostname as its cluster address, the dedicated +// shape — the delete is refused with 412. A proxy that has disconnected no +// longer blocks: the guard is about a live serving path, not history. +// +// The proxy declares its address with mixed casing on purpose: Connect +// stores the declared address verbatim while the settings row is normalized +// lowercase, and hostnames are case-insensitive, so the guard must match +// across the casing difference rather than be sidestepped by it. +func TestSettingsHandler_DeleteBlockedByActiveProxy(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + const endpoint = "gw.dedicated.example.com" + rec := f.do(t, http.MethodPost, "/agent-network/settings", fmt.Sprintf(`{"endpoint": %q}`, endpoint)) + require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String()) + + now := time.Now() + accountID := testAccountID + proxyRow := &rpproxy.Proxy{ + ID: "proxy-guard", + SessionID: "sess-1", + ClusterAddress: "GW.Dedicated.Example.Com", + AccountID: &accountID, + LastSeen: now, + ConnectedAt: &now, + Status: rpproxy.StatusConnected, + } + require.NoError(t, f.store.SaveProxy(context.Background(), proxyRow)) + + rec = f.do(t, http.MethodDelete, "/agent-network/settings", "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, + "delete with an active proxy at the endpoint must be refused: got %d body=%s", rec.Code, rec.Body.String()) + + // Once the proxy disconnects it no longer serves the endpoint, so the + // delete goes through. + require.NoError(t, f.store.DisconnectProxy(context.Background(), proxyRow.ID, proxyRow.SessionID)) + rec = f.do(t, http.MethodDelete, "/agent-network/settings", "") + assert.Equal(t, http.StatusOK, rec.Code, + "delete after the proxy disconnected must succeed: got %d body=%s", rec.Code, rec.Body.String()) +} + +// TestSettingsHandler_DeleteReleasesEndpointForFreshBootstrap pins the +// full-reset semantic that gives replace-on-change clients (e.g. Terraform's +// RequiresReplace) a real path: with both guards clear the delete succeeds, +// the account reads as the defaults again, and a fresh bootstrap draws a +// fresh label. The released hostname is not reserved — a fresh draw may even +// legitimately re-pick it — so the assertions check the new row's shape, not +// that the label differs. +func TestSettingsHandler_DeleteReleasesEndpointForFreshBootstrap(t *testing.T) { + f := newAgentNetworkHandlerFixture(t) + + rec := f.do(t, http.MethodPost, "/agent-network/settings", + `{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true}`) + require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String()) + + rec = f.do(t, http.MethodDelete, "/agent-network/settings", "") + require.Equal(t, http.StatusOK, rec.Code, + "delete with both guards clear must succeed: got %d body=%s", rec.Code, rec.Body.String()) + + rec = f.do(t, http.MethodGet, "/agent-network/settings", "") + require.Equal(t, http.StatusOK, rec.Code) + var after api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after)) + assert.Empty(t, after.Endpoint, "a deleted account must read as unbootstrapped defaults") + assert.False(t, after.EnablePromptCollection, "the deleted row's toggles must not linger") + + rec = f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`) + require.Equal(t, http.StatusOK, rec.Code, "re-bootstrap after delete must succeed: %s", rec.Body.String()) + var second api.AgentNetworkSettings + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &second)) + require.NotEmpty(t, second.Endpoint, "the fresh bootstrap must allocate an endpoint") + assert.True(t, strings.HasSuffix(second.Endpoint, ".eu.proxy.netbird.io"), + "the fresh endpoint must hang beneath the requested proxy address: %s", second.Endpoint) + assert.False(t, second.EnablePromptCollection, + "the fresh row must carry bootstrap defaults, not the deleted row's toggles") + assert.NotNil(t, second.CreatedAt, "the fresh row is persisted and carries timestamps") +} diff --git a/management/internals/modules/agentnetwork/labelgen/adjectives.go b/management/internals/modules/agentnetwork/labelgen/adjectives.go new file mode 100644 index 000000000..5058c2f1c --- /dev/null +++ b/management/internals/modules/agentnetwork/labelgen/adjectives.go @@ -0,0 +1,37 @@ +package labelgen + +// adjectives is the descriptor half of a generated label. It pairs with the +// noun pool in words.go to form `-` labels, and is kept +// separate because words.go is almost entirely nouns — drawing both halves +// from it produced unreadable pairs like "millet-hammock". Entries are +// lowercase ASCII, 4-12 chars, free of hyphens and digits, screened for +// offensive/brand/region-specific terms, and disjoint from the noun pool +// (enforced by TestAdjectives_AreDisjointFromNouns). +var adjectives = []string{ + "able", "active", "adept", "agile", "airy", "alert", "amiable", "ample", + "ancient", "ardent", "artful", "astute", "balmy", "blithe", "bold", "bonny", + "brave", "breezy", "brisk", "bubbly", "buoyant", "bushy", "candid", "canny", + "cheery", "chilly", "chipper", "chunky", "civil", "classic", "clever", "comely", + "compact", "cordial", "cosmic", "courtly", "crafty", "creamy", "crisp", "cuddly", + "curious", "dainty", "dapper", "daring", "dashing", "deft", "dewy", "diligent", + "downy", "dreamy", "dulcet", "durable", "dusky", "eager", "earnest", "earthy", + "easy", "elated", "elegant", "epic", "fabled", "faithful", "fancy", "fearless", + "feisty", "fervent", "fleet", "fluffy", "fond", "frisky", "frosty", "gallant", + "genial", "genteel", "gentle", "giddy", "gilded", "glad", "glassy", "gleaming", + "glossy", "graceful", "grand", "grainy", "hale", "hardy", "hearty", "hefty", + "honest", "hopeful", "humble", "hushed", "immense", "jaunty", "jolly", "jovial", + "joyful", "jubilant", "keen", "kindly", "kindred", "lanky", "leafy", "limber", + "lively", "lofty", "loyal", "lucent", "lucid", "luminous", "lush", "maroon", + "mellow", "merry", "mighty", "mindful", "mirthful", "misty", "modest", "muted", + "nifty", "nimble", "noble", "patient", "peaceful", "pearly", "peppy", "perky", + "petite", "placid", "playful", "pleasant", "plucky", "plush", "polite", "posh", + "prancing", "pristine", "prompt", "proud", "prudent", "quaint", "quick", "quirky", + "radiant", "ready", "regal", "restful", "robust", "rosy", "ruddy", "rugged", + "sandy", "satin", "saucy", "savvy", "sedate", "serene", "shady", "shiny", + "silken", "silky", "sincere", "sleek", "slender", "smart", "smooth", "snappy", + "snug", "soaring", "sparkly", "spiffy", "spirited", "sprightly", "spry", "stalwart", + "stately", "steady", "sterling", "stoic", "stormy", "stout", "sturdy", "sunlit", + "supple", "svelte", "tawny", "tender", "tidy", "timeless", "trusty", "upbeat", + "urbane", "valiant", "vast", "vernal", "vibrant", "vintage", "whimsy", "willing", + "windy", "winsome", "wintry", "witty", "worthy", "zesty", "zippy", +} diff --git a/management/internals/modules/agentnetwork/labelgen/labelgen.go b/management/internals/modules/agentnetwork/labelgen/labelgen.go new file mode 100644 index 000000000..549767096 --- /dev/null +++ b/management/internals/modules/agentnetwork/labelgen/labelgen.go @@ -0,0 +1,83 @@ +// Package labelgen produces DNS-safe Agent Network subdomain labels. +package labelgen + +import ( + "fmt" + "math/rand" + "sort" + "sync" +) + +// pickAttempts caps the random retries before falling back to the +// suffixed form. Eight is a soft compromise: with a near-empty taken +// set the very first pick almost always succeeds; when the wordlist is +// densely populated the fallback eventually fires anyway. +const pickAttempts = 8 + +var ( + dedupOnce sync.Once + uniqWords []string +) + +// uniqueWords returns the wordlist deduplicated and sorted for +// deterministic exhaustion behaviour. Lazy-built once per process. +func uniqueWords() []string { + dedupOnce.Do(func() { + seen := make(map[string]struct{}, len(words)) + uniqWords = make([]string, 0, len(words)) + for _, w := range words { + if _, ok := seen[w]; ok { + continue + } + seen[w] = struct{}{} + uniqWords = append(uniqWords, w) + } + sort.Strings(uniqWords) + }) + return uniqWords +} + +// PickUnique selects a label not already in `taken`. It tries up to +// pickAttempts random picks; on exhaustion it scans the deduplicated +// wordlist for any remaining free entry, and if none is left appends +// `-` to a deterministic word and returns. The caller +// is responsible for seeding rng (math/rand). +func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string) string { + pool := uniqueWords() + if len(pool) == 0 { + return fallbackSuffix + } + + for i := 0; i < pickAttempts; i++ { + w := pool[rng.Intn(len(pool))] + if _, ok := taken[w]; !ok { + return w + } + } + + for _, w := range pool { + if _, ok := taken[w]; !ok { + return w + } + } + + w := pool[rng.Intn(len(pool))] + return fmt.Sprintf("%s-%s", w, fallbackSuffix) +} + +// PickTuple returns an adjective-noun label such as "brave-otter". It is still +// a single DNS label. +// +// Unlike PickUnique it takes no `taken` set and has no fallback suffix. The +// noun pool holds 857 entries, which is ample per cluster but a hard ceiling +// once labels must be unique across one shared zone; pairing an adjective with +// a noun spans len(adjectives) * 857 instead. Uniqueness is enforced by a +// database constraint and retried by the caller, rather than guessed from a +// pre-read set that a concurrent allocation can invalidate. +func PickTuple(rng *rand.Rand) string { + nouns := uniqueWords() + if len(nouns) == 0 || len(adjectives) == 0 { + return "" + } + return adjectives[rng.Intn(len(adjectives))] + "-" + nouns[rng.Intn(len(nouns))] +} diff --git a/management/internals/modules/agentnetwork/labelgen/labelgen_test.go b/management/internals/modules/agentnetwork/labelgen/labelgen_test.go new file mode 100644 index 000000000..7e12fc133 --- /dev/null +++ b/management/internals/modules/agentnetwork/labelgen/labelgen_test.go @@ -0,0 +1,180 @@ +package labelgen + +import ( + "math/rand" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPickUnique_DeterministicWithSeededRng locks the property the +// caller relies on: same seed + same taken set → same pick. Without +// that, the bootstrap flow can't reproduce a label across retries. +func TestPickUnique_DeterministicWithSeededRng(t *testing.T) { + taken := map[string]struct{}{} + + rngA := rand.New(rand.NewSource(42)) + rngB := rand.New(rand.NewSource(42)) + + a := PickUnique(rngA, taken, "abcd") + b := PickUnique(rngB, taken, "abcd") + + assert.Equal(t, a, b, "Same seed and taken set must produce identical pick") +} + +// TestPickUnique_AvoidsTakenWordsWhenMostAreReserved seeds taken with +// every word in the pool except a handful and confirms PickUnique +// finds one of the remaining free entries instead of returning the +// fallback form. +func TestPickUnique_AvoidsTakenWordsWhenMostAreReserved(t *testing.T) { + pool := uniqueWords() + require.NotEmpty(t, pool, "wordlist must be populated for the test to mean anything") + + free := map[string]struct{}{ + pool[0]: {}, + pool[len(pool)/2]: {}, + pool[len(pool)-1]: {}, + } + + taken := make(map[string]struct{}, len(pool)) + for _, w := range pool { + if _, ok := free[w]; ok { + continue + } + taken[w] = struct{}{} + } + + rng := rand.New(rand.NewSource(7)) + got := PickUnique(rng, taken, "abcd") + + _, isFree := free[got] + assert.True(t, isFree, "PickUnique must return one of the free words; got %q", got) + assert.NotContains(t, got, "-", "Free pick must not be the suffix fallback form") +} + +// TestPickUnique_FallsBackWhenAllReserved exhausts the pool and +// confirms PickUnique appends the supplied suffix instead of +// returning a duplicate. +func TestPickUnique_FallsBackWhenAllReserved(t *testing.T) { + pool := uniqueWords() + + taken := make(map[string]struct{}, len(pool)) + for _, w := range pool { + taken[w] = struct{}{} + } + + rng := rand.New(rand.NewSource(99)) + got := PickUnique(rng, taken, "abcd") + + assert.True(t, strings.HasSuffix(got, "-abcd"), "Exhausted pool must produce -; got %q", got) + + prefix := strings.TrimSuffix(got, "-abcd") + found := false + for _, w := range pool { + if w == prefix { + found = true + break + } + } + assert.True(t, found, "Fallback prefix must be drawn from the wordlist; got %q", prefix) +} + +// TestUniqueWords_DropsDuplicates guards against authoring slips in +// words.go: every entry must be unique and DNS-safe. +func TestUniqueWords_DropsDuplicates(t *testing.T) { + pool := uniqueWords() + seen := make(map[string]struct{}, len(pool)) + for _, w := range pool { + _, dup := seen[w] + assert.False(t, dup, "Duplicate entry %q in deduplicated pool", w) + seen[w] = struct{}{} + assert.GreaterOrEqual(t, len(w), 4, "Word %q is shorter than 4 chars", w) + assert.LessOrEqual(t, len(w), 12, "Word %q is longer than 12 chars", w) + for _, r := range w { + ok := r >= 'a' && r <= 'z' + assert.True(t, ok, "Word %q contains non-lowercase-ASCII rune %q", w, r) + } + } + assert.GreaterOrEqual(t, len(pool), 500, "Pool must contain at least 500 unique words") +} + +// TestPickTuple_ShapeAndPoolMembership locks the wire-visible shape: an +// adjective and a noun, each from its own pool, joined by a single hyphen so +// the result stays one DNS label. +func TestPickTuple_ShapeAndPoolMembership(t *testing.T) { + nouns := uniqueWords() + inNouns := make(map[string]struct{}, len(nouns)) + for _, w := range nouns { + inNouns[w] = struct{}{} + } + inAdjectives := make(map[string]struct{}, len(adjectives)) + for _, a := range adjectives { + inAdjectives[a] = struct{}{} + } + + rng := rand.New(rand.NewSource(7)) + for i := 0; i < 200; i++ { + got := PickTuple(rng) + + parts := strings.Split(got, "-") + require.Len(t, parts, 2, "PickTuple must produce exactly two hyphen-joined words; got %q", got) + + _, adjOK := inAdjectives[parts[0]] + assert.True(t, adjOK, "First half must be an adjective; %q not in adjectives (from %q)", parts[0], got) + _, nounOK := inNouns[parts[1]] + assert.True(t, nounOK, "Second half must be a noun; %q not in words (from %q)", parts[1], got) + + assert.LessOrEqual(t, len(got), 63, "Label must fit a DNS label; got %q (%d chars)", got, len(got)) + } +} + +// TestAdjectives_AreDisjointFromNouns keeps the namespace a clean product and +// prevents nonsense like "azure-azure": a handful of the noun pool's entries +// are adjectival, and any overlap would let the same word land on both sides. +func TestAdjectives_AreDisjointFromNouns(t *testing.T) { + nouns := make(map[string]struct{}, len(uniqueWords())) + for _, w := range uniqueWords() { + nouns[w] = struct{}{} + } + for _, a := range adjectives { + _, clash := nouns[a] + assert.False(t, clash, "Adjective %q also appears in the noun pool; remove it from one list", a) + } +} + +// TestAdjectives_AreDNSSafeAndDeduplicated mirrors the curation contract stated +// in words.go: lowercase ASCII, 4-12 chars, no digits or hyphens, no repeats. +func TestAdjectives_AreDNSSafeAndDeduplicated(t *testing.T) { + seen := make(map[string]struct{}, len(adjectives)) + for _, a := range adjectives { + _, dup := seen[a] + assert.False(t, dup, "Duplicate adjective %q", a) + seen[a] = struct{}{} + + assert.Regexp(t, `^[a-z]{4,12}$`, a, "Adjective %q must be 4-12 lowercase ASCII letters", a) + } + assert.Greater(t, len(adjectives), 150, "Adjective pool too small to give a useful namespace") +} + +// TestPickTuple_DeterministicWithSeededRng documents that generation is a pure +// function of the rng, which is what makes allocation retries reproducible in tests. +func TestPickTuple_DeterministicWithSeededRng(t *testing.T) { + a := PickTuple(rand.New(rand.NewSource(42))) + b := PickTuple(rand.New(rand.NewSource(42))) + assert.Equal(t, a, b, "Same seed must yield the same tuple") +} + +// TestPickTuple_SpansALargeNamespace guards the reason we moved to tuples: a +// single-word pool caps the GLOBAL namespace at 857. Drawing many tuples must +// yield overwhelmingly distinct values. +func TestPickTuple_SpansALargeNamespace(t *testing.T) { + rng := rand.New(rand.NewSource(11)) + seen := make(map[string]struct{}, 2000) + for i := 0; i < 2000; i++ { + seen[PickTuple(rng)] = struct{}{} + } + assert.Greater(t, len(seen), 1900, + "2000 draws should be nearly all distinct across a ~200k namespace; got %d unique", len(seen)) +} diff --git a/management/internals/modules/agentnetwork/labelgen/words.go b/management/internals/modules/agentnetwork/labelgen/words.go new file mode 100644 index 000000000..2028ff23d --- /dev/null +++ b/management/internals/modules/agentnetwork/labelgen/words.go @@ -0,0 +1,136 @@ +// Package labelgen produces DNS-safe Agent Network subdomain labels. +// +// The wordlist below is a curated subset drawn from public-domain +// nature / common-noun pools (e.g. EFF's diceware lists). Every entry +// is lowercase ASCII, 4–12 chars, no hyphens, no digits, and was +// hand-checked to avoid offensive, brand, or region-specific terms. +package labelgen + +// words is the pool PickUnique selects from. The slice is intentionally +// not sorted — random picks distribute across the list naturally. +var words = []string{ + "acorn", "adobe", "agate", "alder", "almond", "alpine", "amber", "amethyst", + "anchor", "antler", "apple", "apricot", "arcade", "arctic", "arrow", "ashen", + "aspen", "atlas", "atom", "aurora", "autumn", "azure", + "badger", "bamboo", "banana", "banjo", "barley", "barn", "basalt", "basil", + "basin", "bayou", "beach", "beacon", "beaver", "beech", "beetle", "berry", + "birch", "bison", "blossom", "blue", "bobcat", "bonsai", "boulder", "branch", + "brass", "breeze", "bridge", "bright", "brook", "broom", "brown", "buffalo", + "bumble", "burrow", "butter", "button", + "cabin", "cactus", "calm", "camel", "campfire", "canary", "candle", "canoe", + "canyon", "cardinal", "carrot", "cascade", "castle", "cedar", "celery", "cello", + "cement", "cherry", "chestnut", "chime", "cinnamon", "cinder", "citron", "clay", + "clear", "cliff", "clock", "cloud", "clover", "coast", "cobalt", "cobble", + "cocoa", "coffee", "comet", "compass", "copper", "coral", "corner", "cosmos", + "cotton", "cougar", "country", "coyote", "cove", "crane", "crater", "creek", + "crescent", "crimson", "crocus", "crystal", "cypress", + "daffodil", "dahlia", "daisy", "dawn", "deer", "delta", "denim", "desert", + "dewdrop", "diamond", "dolphin", "doodle", "dove", "dragon", "drift", "drop", + "dune", "dusk", "dusty", + "eagle", "earth", "echo", "elder", "elkhorn", "ember", "emerald", "emperor", + "evergreen", "evening", + "falcon", "fawn", "feather", "fern", "fiddle", "field", "fiesta", "finch", + "firepit", "firefly", "fjord", "flame", "flax", "fleece", "flint", "floral", + "flower", "flute", "foal", "foggy", "forest", "fountain", "foxglove", "fresh", + "frost", "fuchsia", "fudge", + "gable", "galaxy", "garden", "garnet", "gazelle", "geode", "geyser", "ginger", + "glacier", "glade", "glass", "glow", "gold", "goose", "gorge", "gourd", + "granite", "grape", "grass", "gravel", "grayling", "greenery", "grizzly", "grove", + "gull", "gumdrop", "gust", + "hammock", "harbor", "harvest", "hawk", "hazel", "heather", "hedge", "heron", + "hibiscus", "hickory", "hideaway", "highland", "hill", "hive", "hollow", "honey", + "hopper", "horizon", "hummingbird", "husky", + "iceberg", "indigo", "iris", "island", "ivory", "ivybush", + "jade", "jasmine", "jasper", "jaybird", "jelly", "jewel", "jonquil", "journey", + "juniper", "jupiter", "jute", + "kale", "kangaroo", "kayak", "kelp", "kestrel", "kettle", "khaki", "kindling", + "kingfisher", "kiwi", "knapweed", "koala", + "lagoon", "lake", "lantern", "larch", "lark", "laurel", "lava", "lavender", + "leaf", "lemon", "lichen", "light", "lilac", "lily", "lime", "limestone", + "linden", "linen", "lion", "lobster", "locust", "loon", "lotus", "lumber", + "lunar", "lupine", "lynx", + "madrone", "magenta", "magnolia", "mahogany", "mallow", "mango", "manor", "maple", + "marble", "marigold", "marina", "marlin", "marsh", "mauve", "meadow", "melody", + "melon", "merlin", "metal", "midnight", "milk", "millet", "mineral", "mint", + "mirror", "mist", "mitten", "molasses", "moon", "moose", "morning", "moss", + "mountain", "mulberry", "muscat", "mustard", + "narwhal", "navy", "nectar", "needle", "nest", "nettle", "newt", "nightfall", + "noon", "nook", "north", "nova", "nutmeg", + "oaken", "oasis", "oatmeal", "ocean", "ochre", "octagon", "olive", "onyx", + "opal", "orange", "orbit", "orchard", "orchid", "oregano", "orion", "osprey", + "otter", "outpost", "owlet", "oyster", + "painter", "palace", "palm", "pansy", "panther", "papaya", "paprika", "parsley", + "partridge", "passage", "pastel", "patio", "peach", "peacock", "pear", "pearl", + "pebble", "pecan", "pelican", "penguin", "peony", "pepper", "perch", "peridot", + "pewter", "phoenix", "pier", "pillar", "pine", "pineapple", "pinto", "piper", + "pistachio", "plain", "planet", "plateau", "platinum", "plum", "plume", "polar", + "pollen", "pond", "poplar", "poppy", "porcelain", "portal", "portrait", "potato", + "prairie", "primrose", "prism", "puffin", "pumpkin", + "quail", "quartz", "quaver", "quill", "quince", "quinoa", + "rabbit", "raccoon", "radish", "rain", "rainbow", "raindrop", "rapids", "raspberry", + "raven", "ravine", "redwood", "reed", "reef", "ridge", "river", "robin", + "rocket", "rubyred", "rose", "rosemary", "rosewood", "ruffle", "rugby", "russet", + "rustic", "ryefield", + "saffron", "sage", "salmon", "sand", "sandstone", "sapphire", "savanna", "scarlet", + "scout", "seal", "season", "seaweed", "sequoia", "shadow", "shamrock", "shell", + "sherbet", "shore", "silver", "siskin", "skybloom", "skyline", "sleet", "smoke", + "snail", "snapdragon", "snow", "snowflake", "snowy", "solar", "song", "sonic", + "sorrel", "south", "sparkle", "sparrow", "spice", "spider", "spinach", "spire", + "spring", "sprout", "spruce", "squirrel", "starfish", "starlight", "stoat", "stone", + "stork", "storm", "stream", "studio", "summer", "sunbeam", "sundew", "sunny", + "sunrise", "sunset", "swallow", "swan", "sweet", "sycamore", + "tangelo", "tangerine", "tansy", "taupe", "teak", "teal", "thicket", "thistle", + "thrush", "thunder", "tide", "tiger", "tinder", "topaz", "torch", "tortoise", + "tower", "trail", "tranquil", "tundra", "tulip", "turquoise", "turtle", "twig", + "twilight", + "umber", "uplands", + "valley", "vanilla", "velvet", "venus", "verdant", "verdigris", "vermilion", "violet", + "vista", "vivid", "volcano", "vortex", + "walnut", "warbler", "watercress", "waterfall", "wave", "waxwing", "weasel", "westwind", + "whale", "whisker", "whisper", "wicker", "wildwood", "willow", "winter", "wisp", + "wisteria", "wolf", "wombat", "woodland", "woolly", "wren", "wreath", + "yarrow", "yellow", "yewtree", "yodel", + "zebra", "zenith", "zephyr", "zinnia", + "alabaster", "alfalfa", "almanac", "anise", "antelope", "arbor", "arena", "armadillo", + "avocet", "azalea", "balsam", "bayou", "beacon", "blizzard", "bluebell", "bluebird", + "bluejay", "bobolink", "borage", "boreal", "buckeye", "buckthorn", "buttercup", + "cabana", "calico", "canopy", "caraway", "cardamom", "cattail", "celadon", "centaur", + "chambray", "chamois", "champlain", "chestnuts", "chickadee", "chinook", "chipmunk", "cinnabar", + "cirrus", "citrine", "clematis", "copperhead", + "crocodile", "currant", "cuttlebone", "daffy", "dapple", "delphinium", "dervish", "diamondback", + "dogwood", "dolphins", "dragonfly", "driftwood", "dusk", "dustpan", "ebony", "edelweiss", + "emperor", "endive", "estuary", "everglade", "fairway", "feldspar", "fennel", "fieldstone", + "firebrand", "firefly", "fireweed", "firework", "flagstone", "fossil", "frostbite", "galleon", + "gardener", "geranium", "gingko", "ginseng", "goldfish", "goldfinch", "goldenrod", "graphite", + "greenfinch", "guppy", "haiku", "halibut", "hammerhead", "harbinger", "harvest", "hatchling", + "havana", "hawthorn", "hazelnut", "heartwood", "henna", "heron", "highrise", "homestead", + "honeycomb", "honeydew", "horseshoe", "hyacinth", "iceland", "icicle", "indigobird", "ironwood", + "jacaranda", "jamboree", "javelina", "jellyfish", "junebug", "kaleido", "kayaker", "kerchief", + "keystone", "kingdom", "labrador", "lacewing", "ladybug", "lakeside", "lamplight", "leopard", + "lighthouse", "lilypad", "lullaby", "magnet", "mahonia", "mandolin", "manzanita", "maraschino", + "mariner", "marsupial", "mastodon", "matterhorn", "mayflower", "mayfly", "meadowlark", "merlot", + "meteor", "midshipman", "millpond", "mimosa", "minnow", "mockingbird", "molten", "monarch", + "monsoon", "moondust", "moonlight", "moorland", "morning", "mossland", "mountain", "mulch", + "narcissus", "nautilus", "nettlebush", "northstar", "nuthatch", "obsidian", "okra", "olivine", + "opalescent", "orchidea", "orchard", "ornament", "outrigger", "oxalis", "paddler", "paintbrush", + "papyrus", "paradise", "pasture", "patchwork", "pathway", "peridot", "periwinkle", "petalbloom", + "petrel", "petunia", "phlox", "pikeperch", "pinecone", "pioneer", "pipevine", "platypus", + "pomelo", "pondweed", "porpoise", "powder", "promise", "puddle", "pumice", "puzzle", + "quetzal", "quicksilver", "raccoon", "ragwort", "rainforest", "ramble", "rapid", "rascal", + "raspberry", "redbud", "redfern", "redpoll", "reedling", "ringtail", "riverbed", "riverbird", + "riverstone", "rockcress", "roebuck", "rosebay", "rosehip", "rosemary", "rowan", "rumble", + "runaway", "rustler", "sagebrush", "sailcloth", "salamander", "salsify", "samphire", "sandbar", + "sanddollar", "sandpiper", "santolina", "sapodilla", "sassafras", "scallion", "schooner", "seafoam", + "seafrost", "seagrass", "seahorse", "seaport", "seashell", "seaspray", "shamble", "shimmer", + "shoreline", "silkmoth", "silverfox", "skylark", "snapdragon", "snowberry", "snowdrop", "snowfall", + "snowmelt", "softwood", "songbird", "sorghum", "southwind", "speedwell", "spinnaker", "spruce", + "starlight", "starling", "stormcloud", "summit", "sundance", "sundew", "sundial", "sunflower", + "surface", "swallowtail", "sweetcorn", "sycamore", "tabletop", "tamarack", "tamarind", "tangerine", + "tarragon", "telescope", "thicket", "thrasher", "thunder", "thyme", "tideline", "timberland", + "tinderbox", "topiary", "torchwood", "totem", "tradewind", "treasure", "tremolo", "trinket", + "trumpetvine", "tugboat", "tundra", "turnstone", "underbrush", "vagabond", "valerian", "vanilla", + "velveteen", "vermilion", "vinca", "vineyard", "violet", "voyager", "wagonwheel", "walnutwood", + "watermark", "watershed", "waterway", "wavefront", "westerly", "whaleback", "whetstone", "wicker", + "wildbloom", "wildflower", "wilderness", "windsong", "windward", "winterberry", "woodbine", "woodfern", + "woodland", "woodthrush", "woolgrass", "yellowfin", "zenithal", "zucchini", +} diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go new file mode 100644 index 000000000..41789195e --- /dev/null +++ b/management/internals/modules/agentnetwork/manager.go @@ -0,0 +1,1185 @@ +package agentnetwork + +import ( + "context" + "errors" + "fmt" + "math/rand" + "slices" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/labelgen" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +// ensureSessionKeys mints an ed25519 session keypair on the provider +// when one is missing. Idempotent: skips when both fields are already +// populated (e.g. update or migrated rows). The keys are used by the +// synthesised reverse-proxy service to sign / verify session JWTs +// after a successful OIDC handshake. +func ensureSessionKeys(p *types.Provider) error { + if p.SessionPrivateKey != "" && p.SessionPublicKey != "" { + return nil + } + pair, err := sessionkey.GenerateKeyPair() + if err != nil { + return fmt.Errorf("generate provider session keys: %w", err) + } + p.SessionPrivateKey = pair.PrivateKey + p.SessionPublicKey = pair.PublicKey + return nil +} + +// Manager governs the lifecycle of Agent Network providers and policies. +type Manager interface { + GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) + GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) + CreateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) + UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) + DeleteProvider(ctx context.Context, accountID, userID, providerID string) error + DiscoverProviderModels(ctx context.Context, accountID, userID string, req modeldiscovery.Request, recordID string) ([]modeldiscovery.Model, error) + + GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) + GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error) + CreatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) + UpdatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) + DeletePolicy(ctx context.Context, accountID, userID, policyID string) error + + GetAllGuardrails(ctx context.Context, accountID, userID string) ([]*types.Guardrail, error) + GetGuardrail(ctx context.Context, accountID, userID, guardrailID string) (*types.Guardrail, error) + CreateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) + UpdateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) + DeleteGuardrail(ctx context.Context, accountID, userID, guardrailID string) error + + GetAllBudgetRules(ctx context.Context, accountID, userID string) ([]*types.AccountBudgetRule, error) + GetBudgetRule(ctx context.Context, accountID, userID, ruleID string) (*types.AccountBudgetRule, error) + CreateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) + UpdateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) + DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error + + GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) + CreateSettings(ctx context.Context, userID string, settings *types.Settings, proxyAddress, endpoint string) (*types.Settings, error) + UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) + DeleteSettings(ctx context.Context, accountID, userID string) error + + ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) + ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) + ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) + GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) + StartAccessLogCleanup(ctx context.Context, cleanupIntervalHours int) + RecordConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds, tokensIn, tokensOut int64, costUSD float64) error + RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error + RecordUsage(ctx context.Context, in RecordUsageInput) error + SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error) +} + +// PolicySelectionInput is the per-request selection envelope. The +// proxy populates it from CapturedData (account, user, groups) plus +// the provider llm_router resolved and the model it extracted. +type PolicySelectionInput struct { + AccountID string + UserID string + GroupIDs []string + ProviderID string + // Model is the already-normalised upstream model id the proxy extracted + // (parser strips Bedrock region/version, Vertex @version), so a + // case-insensitive compare suffices. Empty = undetermined → not permitted + // (fail closed). + Model string +} + +// PolicySelectionResult names the policy that "pays" for this request +// plus the deny envelope when every applicable policy has exhausted +// every cap. AttributionGroupID is the lowest group id (string sort) +// of caller_groups ∩ selected_policy.source_groups; empty when no +// group dimension applies. WindowSeconds is the chosen policy's +// effective window length in seconds (token_limit's wins when both +// halves are enabled with mismatched windows; budget_limit's +// otherwise; 0 when no caps are configured at all). +type PolicySelectionResult struct { + Allow bool + SelectedPolicyID string + AttributionGroupID string + WindowSeconds int64 + DenyCode string + DenyReason string +} + +type managerImpl struct { + store store.Store + accountManager account.Manager + permissionsManager permissions.Manager + proxyController proxy.Controller + + // modelDiscovery queries vendors for the models a credential can reach. + // A field rather than a package call so tests can drive it without + // reaching the network. + // + // One instance serves every request for the process's lifetime, so its + // fields must stay read-only after construction: lazy initialisation + // inside Fetch or httpClient would race across request goroutines. + modelDiscovery *modeldiscovery.Client + + // reconcileCache holds the last set of synthesised proxy mappings + // per account, each paired with the proxy that served it, so a change + // of serving proxy can be diffed without re-deriving it. + reconcileMu sync.Mutex + reconcileCache map[string]map[string]syntheticMapping + + // labelRngMu guards labelRng. PickUnique consumes math/rand.Source + // state; concurrent provider creates would otherwise race. + labelRngMu sync.Mutex + labelRng *rand.Rand +} + +// NewManager constructs the persistent Agent Network manager. The +// manager persists provider/policy/guardrail configuration and, on +// every mutation, reconciles the in-memory synthesised reverse-proxy +// services with the proxy cluster via proxyController. Pass nil for +// proxyController to disable the reconcile push (useful in tests). +func NewManager( + store store.Store, + permissionsManager permissions.Manager, + accountManager account.Manager, + proxyController proxy.Controller, +) Manager { + return &managerImpl{ + store: store, + accountManager: accountManager, + permissionsManager: permissionsManager, + proxyController: proxyController, + modelDiscovery: &modeldiscovery.Client{}, + reconcileCache: make(map[string]map[string]syntheticMapping), + labelRng: rand.New(rand.NewSource(time.Now().UnixNano())), + } +} + +func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil { + return nil, err + } + return m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID) +} + +func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Read); err != nil { + return nil, err + } + return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID) +} + +// DiscoverProviderModels asks the vendor which models a credential can reach. +// +// recordID, when set, names an existing provider whose stored credential and +// upstream are used instead of the ones in req — so the dashboard can refresh +// the list without ever holding the key. +// +// Gated on Create rather than Read: this spends the operator's credential +// against a third party, which is not something a read-only role should be +// able to make the server do. That one check also covers reading the stored +// record — Create is strictly stronger than Read here, and the lookup is +// scoped to accountID, so another account's record is never reachable. +func (m *managerImpl) DiscoverProviderModels(ctx context.Context, accountID, userID string, req modeldiscovery.Request, recordID string) ([]modeldiscovery.Model, error) { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil { + return nil, err + } + + if recordID != "" { + record, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, recordID) + if err != nil { + return nil, err + } + // The catalog id comes from the stored record too: letting the caller + // name a different one would run a provider's credential against + // whichever vendor endpoint they picked. + req.CatalogID = record.ProviderID + req.UpstreamURL = record.UpstreamURL + req.APIKey = record.APIKey + } + + return m.modelDiscovery.Fetch(ctx, req) +} + +// CreateProvider persists a new provider for the account. Providers have no +// settings side effects: the account's endpoint is bootstrapped separately and +// explicitly via CreateSettings, and every provider in the account routes +// through it. +func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) { + if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Create); err != nil { + return nil, err + } + + // An empty api_key would silently produce a synthesised service + // that 401s on every upstream request. Surface the misconfiguration + // at create time instead. + if strings.TrimSpace(provider.APIKey) == "" { + return nil, status.Errorf(status.InvalidArgument, "api_key is required when creating an agent network provider") + } + + if provider.ID == "" { + fresh := types.NewProvider(provider.AccountID) + provider.ID = fresh.ID + provider.CreatedAt = fresh.CreatedAt + provider.UpdatedAt = fresh.UpdatedAt + } + + if err := ensureSessionKeys(provider); err != nil { + return nil, err + } + + if err := m.store.SaveAgentNetworkProvider(ctx, provider); err != nil { + return nil, fmt.Errorf("save agent network provider: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, provider.ID, provider.AccountID, activity.AgentNetworkProviderCreated, provider.EventMeta()) + m.reconcile(ctx, provider.AccountID) + + return provider, nil +} + +func (m *managerImpl) UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) { + if err := m.requirePermission(ctx, provider.AccountID, userID, modules.AgentNetworkProviders, operations.Update); err != nil { + return nil, err + } + + existing, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthUpdate, provider.AccountID, provider.ID) + if err != nil { + return nil, fmt.Errorf("failed to get agent network provider: %w", err) + } + + // Preserve the API key if the caller didn't rotate it. A + // whitespace-only value is treated as "not rotated" rather than a + // real key, but it must not silently overwrite a valid stored key. + if provider.APIKey == "" { + provider.APIKey = existing.APIKey + } else if strings.TrimSpace(provider.APIKey) == "" { + return nil, status.Errorf(status.InvalidArgument, "api_key must be non-blank when rotating an agent network provider") + } + // Always preserve the session keypair across updates so existing + // session cookies stay valid. The keys are server-managed and + // never surfaced through the API. + provider.SessionPrivateKey = existing.SessionPrivateKey + provider.SessionPublicKey = existing.SessionPublicKey + if err := ensureSessionKeys(provider); err != nil { + return nil, err + } + provider.CreatedAt = existing.CreatedAt + provider.UpdatedAt = time.Now().UTC() + + if err := m.store.SaveAgentNetworkProvider(ctx, provider); err != nil { + return nil, fmt.Errorf("save agent network provider: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, provider.ID, provider.AccountID, activity.AgentNetworkProviderUpdated, provider.EventMeta()) + m.reconcile(ctx, provider.AccountID) + + return provider, nil +} + +func (m *managerImpl) DeleteProvider(ctx context.Context, accountID, userID, providerID string) error { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkProviders, operations.Delete); err != nil { + return err + } + + provider, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthUpdate, accountID, providerID) + if err != nil { + return fmt.Errorf("failed to get agent network provider: %w", err) + } + + // Refuse to delete while any policy still references this provider. + // The operator must detach it first. + policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return fmt.Errorf("failed to get agent network policies: %w", err) + } + var blocking []string + for _, p := range policies { + if slices.Contains(p.DestinationProviderIDs, providerID) { + blocking = append(blocking, p.Name) + } + } + if len(blocking) > 0 { + return status.Errorf( + status.InvalidArgument, + "provider is in use by %d %s (%s); detach it before deleting", + len(blocking), + pluralize(len(blocking), "policy", "policies"), + strings.Join(blocking, ", "), + ) + } + + if err := m.store.DeleteAgentNetworkProvider(ctx, accountID, providerID); err != nil { + return fmt.Errorf("failed to delete agent network provider: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, providerID, accountID, activity.AgentNetworkProviderDeleted, provider.EventMeta()) + m.reconcile(ctx, accountID) + + return nil +} + +func pluralize(n int, singular, plural string) string { + if n == 1 { + return singular + } + return plural +} + +func (m *managerImpl) GetAllPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil { + return nil, err + } + return m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID) +} + +func (m *managerImpl) GetPolicy(ctx context.Context, accountID, userID, policyID string) (*types.Policy, error) { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Read); err != nil { + return nil, err + } + return m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthNone, accountID, policyID) +} + +func (m *managerImpl) CreatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) { + if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Create); err != nil { + return nil, err + } + + if policy.ID == "" { + fresh := types.NewPolicy(policy.AccountID) + policy.ID = fresh.ID + policy.CreatedAt = fresh.CreatedAt + policy.UpdatedAt = fresh.UpdatedAt + } + + if err := m.validateProviderRefs(ctx, policy.AccountID, policy.DestinationProviderIDs); err != nil { + return nil, err + } + + if err := m.store.SaveAgentNetworkPolicy(ctx, policy); err != nil { + return nil, fmt.Errorf("failed to save agent network policy: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, policy.ID, policy.AccountID, activity.AgentNetworkPolicyCreated, policy.EventMeta()) + m.reconcile(ctx, policy.AccountID) + + return policy, nil +} + +func (m *managerImpl) UpdatePolicy(ctx context.Context, userID string, policy *types.Policy) (*types.Policy, error) { + if err := m.requirePermission(ctx, policy.AccountID, userID, modules.AgentNetworkPolicies, operations.Update); err != nil { + return nil, err + } + + existing, err := m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthUpdate, policy.AccountID, policy.ID) + if err != nil { + return nil, fmt.Errorf("failed to get agent network policy: %w", err) + } + + if err := m.validateProviderRefs(ctx, policy.AccountID, policy.DestinationProviderIDs); err != nil { + return nil, err + } + + policy.CreatedAt = existing.CreatedAt + policy.UpdatedAt = time.Now().UTC() + + if err := m.store.SaveAgentNetworkPolicy(ctx, policy); err != nil { + return nil, fmt.Errorf("failed to save agent network policy: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, policy.ID, policy.AccountID, activity.AgentNetworkPolicyUpdated, policy.EventMeta()) + m.reconcile(ctx, policy.AccountID) + + return policy, nil +} + +func (m *managerImpl) DeletePolicy(ctx context.Context, accountID, userID, policyID string) error { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkPolicies, operations.Delete); err != nil { + return err + } + + policy, err := m.store.GetAgentNetworkPolicyByID(ctx, store.LockingStrengthUpdate, accountID, policyID) + if err != nil { + return fmt.Errorf("failed to get agent network policy: %w", err) + } + + if err := m.store.DeleteAgentNetworkPolicy(ctx, accountID, policyID); err != nil { + return fmt.Errorf("failed to delete agent network policy: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, policyID, accountID, activity.AgentNetworkPolicyDeleted, policy.EventMeta()) + m.reconcile(ctx, accountID) + + return nil +} + +func (m *managerImpl) GetAllGuardrails(ctx context.Context, accountID, userID string) ([]*types.Guardrail, error) { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil { + return nil, err + } + return m.store.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID) +} + +func (m *managerImpl) GetGuardrail(ctx context.Context, accountID, userID, guardrailID string) (*types.Guardrail, error) { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Read); err != nil { + return nil, err + } + return m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthNone, accountID, guardrailID) +} + +func (m *managerImpl) CreateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) { + if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Create); err != nil { + return nil, err + } + + if guardrail.ID == "" { + fresh := types.NewGuardrail(guardrail.AccountID) + guardrail.ID = fresh.ID + guardrail.CreatedAt = fresh.CreatedAt + guardrail.UpdatedAt = fresh.UpdatedAt + } + + if err := m.store.SaveAgentNetworkGuardrail(ctx, guardrail); err != nil { + return nil, fmt.Errorf("failed to save agent network guardrail: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, guardrail.ID, guardrail.AccountID, activity.AgentNetworkGuardrailCreated, guardrail.EventMeta()) + m.reconcile(ctx, guardrail.AccountID) + + return guardrail, nil +} + +func (m *managerImpl) UpdateGuardrail(ctx context.Context, userID string, guardrail *types.Guardrail) (*types.Guardrail, error) { + if err := m.requirePermission(ctx, guardrail.AccountID, userID, modules.AgentNetworkGuardrails, operations.Update); err != nil { + return nil, err + } + + existing, err := m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthUpdate, guardrail.AccountID, guardrail.ID) + if err != nil { + return nil, fmt.Errorf("failed to get agent network guardrail: %w", err) + } + + guardrail.CreatedAt = existing.CreatedAt + guardrail.UpdatedAt = time.Now().UTC() + + if err := m.store.SaveAgentNetworkGuardrail(ctx, guardrail); err != nil { + return nil, fmt.Errorf("failed to save agent network guardrail: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, guardrail.ID, guardrail.AccountID, activity.AgentNetworkGuardrailUpdated, guardrail.EventMeta()) + m.reconcile(ctx, guardrail.AccountID) + + return guardrail, nil +} + +func (m *managerImpl) DeleteGuardrail(ctx context.Context, accountID, userID, guardrailID string) error { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkGuardrails, operations.Delete); err != nil { + return err + } + + guardrail, err := m.store.GetAgentNetworkGuardrailByID(ctx, store.LockingStrengthUpdate, accountID, guardrailID) + if err != nil { + return fmt.Errorf("failed to get agent network guardrail: %w", err) + } + + if err := m.store.DeleteAgentNetworkGuardrail(ctx, accountID, guardrailID); err != nil { + return fmt.Errorf("failed to delete agent network guardrail: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, guardrailID, accountID, activity.AgentNetworkGuardrailDeleted, guardrail.EventMeta()) + m.reconcile(ctx, accountID) + + return nil +} + +// GetAllBudgetRules returns every account-level budget rule for the account. +func (m *managerImpl) GetAllBudgetRules(ctx context.Context, accountID, userID string) ([]*types.AccountBudgetRule, error) { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil { + return nil, err + } + return m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, accountID) +} + +// GetBudgetRule returns a single account-level budget rule. +func (m *managerImpl) GetBudgetRule(ctx context.Context, accountID, userID, ruleID string) (*types.AccountBudgetRule, error) { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Read); err != nil { + return nil, err + } + return m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthNone, accountID, ruleID) +} + +// CreateBudgetRule persists a new account-level budget rule. Budget rules are +// enforced at request time (CheckLLMPolicyLimits), not baked into the synth +// proxy config, so no reconcile is needed. +func (m *managerImpl) CreateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) { + if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Create); err != nil { + return nil, err + } + + if rule.ID == "" { + fresh := types.NewAccountBudgetRule(rule.AccountID) + rule.ID = fresh.ID + rule.CreatedAt = fresh.CreatedAt + rule.UpdatedAt = fresh.UpdatedAt + } + + if err := m.store.SaveAgentNetworkBudgetRule(ctx, rule); err != nil { + return nil, fmt.Errorf("save agent network budget rule: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, rule.ID, rule.AccountID, activity.AgentNetworkBudgetRuleCreated, rule.EventMeta()) + + return rule, nil +} + +// UpdateBudgetRule updates an existing account-level budget rule. +func (m *managerImpl) UpdateBudgetRule(ctx context.Context, userID string, rule *types.AccountBudgetRule) (*types.AccountBudgetRule, error) { + if err := m.requirePermission(ctx, rule.AccountID, userID, modules.AgentNetworkBudgets, operations.Update); err != nil { + return nil, err + } + + existing, err := m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthUpdate, rule.AccountID, rule.ID) + if err != nil { + return nil, fmt.Errorf("get agent network budget rule: %w", err) + } + + rule.CreatedAt = existing.CreatedAt + rule.UpdatedAt = time.Now().UTC() + + if err := m.store.SaveAgentNetworkBudgetRule(ctx, rule); err != nil { + return nil, fmt.Errorf("save agent network budget rule: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, rule.ID, rule.AccountID, activity.AgentNetworkBudgetRuleUpdated, rule.EventMeta()) + + return rule, nil +} + +// DeleteBudgetRule removes an account-level budget rule. +func (m *managerImpl) DeleteBudgetRule(ctx context.Context, accountID, userID, ruleID string) error { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkBudgets, operations.Delete); err != nil { + return err + } + + rule, err := m.store.GetAgentNetworkBudgetRuleByID(ctx, store.LockingStrengthUpdate, accountID, ruleID) + if err != nil { + return fmt.Errorf("get agent network budget rule: %w", err) + } + + if err := m.store.DeleteAgentNetworkBudgetRule(ctx, accountID, ruleID); err != nil { + return fmt.Errorf("delete agent network budget rule: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, ruleID, accountID, activity.AgentNetworkBudgetRuleDeleted, rule.EventMeta()) + + return nil +} + +// UpdateSettings replaces the mutable account-level settings — the collection +// toggles and retention — on the account's row. The identity fields (Domain, +// ProxyAddress) are assigned at bootstrap (CreateSettings) and immutable: the +// request carries them, matching the PUT convention of every other endpoint, +// but they are only compared against the stored row — a request carrying +// different values is rejected, and the stored values are never overwritten. +// When the account has no settings row yet the update fails with NotFound. +// Because the collection toggles change the synthesised service config +// (prompt-capture gating, access-log emission), a reconcile is triggered so +// the proxy and peer network maps converge on the new state. +func (m *managerImpl) UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, error) { + if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Update); err != nil { + return nil, err + } + + // The row lock from LockingStrengthUpdate only holds for the duration of + // the surrounding transaction, so the read and the save must share one — + // otherwise concurrent PUTs could interleave between them. + var updated *types.Settings + err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error { + existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID) + switch { + case err == nil: + case isNotFound(err): + return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; POST /api/agent-network/settings to bootstrap them") + default: + return fmt.Errorf("get agent network settings: %w", err) + } + + // The identity echo is compared leniently (trimmed, case-insensitive): + // the stored values are normalized lowercase, and a client replaying a + // GET response must never be rejected over casing it didn't choose. + if !hostnamesEquivalent(settings.Domain, existing.Domain) { + return status.Errorf(status.InvalidArgument, "endpoint is immutable: it must match the assigned endpoint %q; delete the settings to release it and bootstrap again", existing.Domain) + } + if !hostnamesEquivalent(settings.ProxyAddress, existing.ProxyAddress) { + return status.Errorf(status.InvalidArgument, "proxy_address is immutable: it must match the assigned proxy address %q; delete the settings to release it and bootstrap again", existing.ProxyAddress) + } + + existing.EnableLogCollection = settings.EnableLogCollection + existing.EnablePromptCollection = settings.EnablePromptCollection + existing.RedactPii = settings.RedactPii + existing.AccessLogRetentionDays = settings.AccessLogRetentionDays + existing.UpdatedAt = time.Now().UTC() + + if err := tx.SaveAgentNetworkSettings(ctx, existing); err != nil { + return fmt.Errorf("save agent network settings: %w", err) + } + updated = existing + return nil + }) + if err != nil { + return nil, err + } + + m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{ + "log_collection": updated.EnableLogCollection, + "prompt_collection": updated.EnablePromptCollection, + "redact_pii": updated.RedactPii, + }) + m.reconcile(ctx, settings.AccountID) + + return updated, nil +} + +// hostnamesEquivalent reports whether a caller-supplied hostname names the +// same host as a stored (normalized, lowercase) one: equal after trimming and +// case folding. No structural validation — an arbitrary mismatch and a +// malformed value are both simply "not the assigned value". +func hostnamesEquivalent(supplied, stored string) bool { + return strings.EqualFold(strings.TrimSpace(supplied), stored) +} + +// DeleteSettings removes the account's settings row, releasing the endpoint. +// Two guards make this a bootstrap-repair operation rather than a way to tear +// down a serving gateway, both re-checked under the row lock: +// +// - No Agent Network providers may exist for the account. Providers route +// through the endpoint; delete them first. +// - No proxy may be actively serving the endpoint — that is, no active proxy +// declares the endpoint hostname as its cluster address. This is the +// dedicated (self-addressed) shape's guard: the proxy at the address IS +// this account's gateway. A labeled endpoint hangs beneath a shared +// cluster's address, and with the account's providers already gone the +// shared proxy serves nothing of the account's, so the parent cluster +// being up does not block the delete. +// +// Bootstrapping again after a delete allocates fresh — the released hostname +// is not reserved. That full-reset semantic is what gives clients that model +// immutability as replace-on-change (e.g. Terraform's RequiresReplace) a real +// path: tear down providers, delete, re-create. +func (m *managerImpl) DeleteSettings(ctx context.Context, accountID, userID string) error { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Delete); err != nil { + return err + } + + var deleted *types.Settings + err := m.store.ExecuteInTransaction(ctx, func(tx store.Store) error { + existing, err := tx.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, accountID) + switch { + case err == nil: + case isNotFound(err): + return status.Errorf(status.NotFound, "agent network settings have not been bootstrapped yet; there is nothing to delete") + default: + return fmt.Errorf("get agent network settings: %w", err) + } + + providers, err := tx.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return fmt.Errorf("get agent network providers: %w", err) + } + if len(providers) > 0 { + return status.Errorf(status.PreconditionFailed, "agent network settings cannot be deleted while %d provider(s) exist; delete the providers first", len(providers)) + } + + serving, err := tx.HasActiveProxyAtClusterAddress(ctx, existing.Domain) + if err != nil { + return fmt.Errorf("check for a proxy serving the endpoint: %w", err) + } + if serving { + return status.Errorf(status.PreconditionFailed, "agent network settings cannot be deleted while a proxy is actively serving the endpoint %q", existing.Domain) + } + + if err := tx.DeleteAgentNetworkSettings(ctx, accountID); err != nil { + return fmt.Errorf("delete agent network settings: %w", err) + } + deleted = existing + return nil + }) + if err != nil { + return err + } + + m.accountManager.StoreEvent(ctx, userID, accountID, accountID, activity.AgentNetworkSettingsDeleted, map[string]any{ + "endpoint": deleted.Domain, + "proxy_address": deleted.ProxyAddress, + }) + m.reconcile(ctx, accountID) + + return nil +} + +// isNotFound reports whether err is a status.NotFound error. +func isNotFound(err error) bool { + var sErr *status.Error + return errors.As(err, &sErr) && sErr.Type() == status.NotFound +} + +// validateProviderRefs ensures every destination provider id refers to a +// provider that exists in the same account. +func (m *managerImpl) validateProviderRefs(ctx context.Context, accountID string, providerIDs []string) error { + if len(providerIDs) == 0 { + return nil + } + for _, id := range providerIDs { + if _, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, id); err != nil { + // Only a genuine not-found means the reference is invalid; a + // store/runtime error must propagate as-is rather than be + // masked as a client validation error. + var sErr *status.Error + if errors.As(err, &sErr) && sErr.Type() == status.NotFound { + return status.Errorf(status.InvalidArgument, "destination_provider_ids: provider %s does not exist", id) + } + return fmt.Errorf("get destination provider %s: %w", id, err) + } + } + return nil +} + +// GetSettings returns the agent-network settings row for the account. When no +// row has been bootstrapped yet, the defaults are returned (without +// persisting) with cluster and subdomain empty — settings always read as an +// object, like the account and DNS settings endpoints. +func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) { + if err := m.requirePermission(ctx, accountID, userID, modules.AgentNetworkSettings, operations.Read); err != nil { + return nil, err + } + settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) + switch { + case err == nil: + return settings, nil + case isNotFound(err): + return types.DefaultSettings(accountID), nil + default: + return nil, err + } +} + +// maxDomainAllocationAttempts bounds the label search when bootstrapping a +// labeled endpoint. Package-level (rather than function-local) so tests can +// assert on the exhaustion path without duplicating the literal. +const maxDomainAllocationAttempts = 10 + +// CreateSettings bootstraps the per-account settings row, assigning the +// account's immutable endpoint. Exactly one of proxyAddress and endpoint must +// be non-empty: proxyAddress allocates a labeled endpoint one label beneath +// the given cluster address; endpoint claims the given hostname verbatim as a +// self-addressed (dedicated) endpoint — a legitimate claim before any proxy +// declares the address (address-first). settings carries the account ID and +// the initial collection toggles; its identity fields are assigned here. +func (m *managerImpl) CreateSettings(ctx context.Context, userID string, settings *types.Settings, proxyAddress, endpoint string) (*types.Settings, error) { + if settings == nil || settings.AccountID == "" { + return nil, status.Errorf(status.InvalidArgument, "account id is required") + } + if err := m.requirePermission(ctx, settings.AccountID, userID, modules.AgentNetworkSettings, operations.Create); err != nil { + return nil, err + } + + hasProxyAddress := strings.TrimSpace(proxyAddress) != "" + hasEndpoint := strings.TrimSpace(endpoint) != "" + if hasProxyAddress == hasEndpoint { + return nil, status.Errorf(status.InvalidArgument, "exactly one of proxy_address and endpoint is required") + } + + // Fail fast on an existing row for a clean 409; the insert below stays + // the authority against concurrent bootstraps (the primary key wins). + if _, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, settings.AccountID); err == nil { + return nil, status.Errorf(status.AlreadyExists, "agent network settings already bootstrapped for account %s", settings.AccountID) + } else if !isNotFound(err) { + return nil, fmt.Errorf("get agent network settings: %w", err) + } + + now := time.Now().UTC() + settings.CreatedAt = now + settings.UpdatedAt = now + + var err error + if hasEndpoint { + err = m.bootstrapSelfAddressed(ctx, settings, endpoint) + } else { + err = m.bootstrapLabeled(ctx, settings, proxyAddress) + } + if err != nil { + return nil, err + } + + m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{ + "bootstrapped": true, + "endpoint": settings.Domain, + "dedicated": settings.Dedicated(), + }) + m.reconcile(ctx, settings.AccountID) + + return settings, nil +} + +// bootstrapSelfAddressed claims the given hostname as the account's endpoint, +// served only by a proxy declaring exactly that address (Domain == +// ProxyAddress). The domain unique index is the arbiter of availability. +func (m *managerImpl) bootstrapSelfAddressed(ctx context.Context, settings *types.Settings, endpoint string) error { + hostname, err := types.NormalizeHostname(endpoint) + if err != nil { + return status.Errorf(status.InvalidArgument, "invalid endpoint: %s", err) + } + + settings.Domain = hostname + settings.ProxyAddress = hostname + if err := m.store.CreateAgentNetworkSettings(ctx, settings); err != nil { + if isUniqueConstraintError(err) { + // The violation is either the account primary key (a concurrent + // bootstrap for the same account won) or the domain index + // (another account holds the hostname). Distinguish by re-read. + if _, getErr := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, settings.AccountID); getErr == nil { + return status.Errorf(status.AlreadyExists, "agent network settings already bootstrapped for account %s", settings.AccountID) + } + return status.Errorf(status.AlreadyExists, "endpoint %s is already taken", hostname) + } + return fmt.Errorf("create agent network settings: %w", err) + } + return nil +} + +// bootstrapLabeled allocates a labeled endpoint one label beneath the given +// cluster address: Domain =