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/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..647e04936
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,48 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 3
+ groups:
+ actions:
+ patterns:
+ - "*"
+ ignore:
+ # git-town/action v1.3.x crashes on cyclic PR graphs (self-loop main->main
+ # fork PRs) via its topological-sort visualization. Pinned to v1.2.1 in
+ # git-town.yml; block v1.3.x until upstream tolerates cyclic edges.
+ - dependency-name: "git-town/action"
+ update-types:
+ - "version-update:semver-minor"
+ - "version-update:semver-major"
+
+ - package-ecosystem: "gomod"
+ directories:
+ - "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 15
+ groups:
+ golang-x-packages:
+ patterns:
+ - "golang.org/x/*"
+ aws-sdk:
+ patterns:
+ - "github.com/aws/aws-sdk-go-v2/*"
+ pion:
+ patterns:
+ - "github.com/pion/*"
+ gorm:
+ patterns:
+ - "gorm.io/*"
+ otel:
+ patterns:
+ - "go.opentelemetry.io/*"
+ testcontainers:
+ patterns:
+ - "github.com/testcontainers/testcontainers-go/*"
+ wireguard:
+ patterns:
+ - "golang.zx2c4.com/wireguard*"
diff --git a/.github/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..88b98293d
--- /dev/null
+++ b/.github/workflows/agent-network-e2e.yml
@@ -0,0 +1,80 @@
+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: ""
+
+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 }}
+ run: go test -tags e2e -timeout 40m -v ./e2e/...
diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml
index a721cb516..17c9fdc8d 100644
--- a/.github/workflows/check-license-dependencies.yml
+++ b/.github/workflows/check-license-dependencies.yml
@@ -2,16 +2,16 @@ name: Check License Dependencies
on:
push:
- branches: [ main ]
+ branches: [main]
paths:
- - 'go.mod'
- - 'go.sum'
- - '.github/workflows/check-license-dependencies.yml'
+ - "go.mod"
+ - "go.sum"
+ - ".github/workflows/check-license-dependencies.yml"
pull_request:
paths:
- - 'go.mod'
- - 'go.sum'
- - '.github/workflows/check-license-dependencies.yml'
+ - "go.mod"
+ - "go.sum"
+ - ".github/workflows/check-license-dependencies.yml"
jobs:
check-internal-dependencies:
@@ -19,7 +19,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - name: Checkout code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Check for problematic license dependencies
run: |
@@ -56,55 +59,57 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version-file: 'go.mod'
- cache: true
+ - name: Set up Go
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
+ with:
+ go-version-file: "go.mod"
+ cache: true
- - name: Install go-licenses
- run: go install github.com/google/go-licenses@v1.6.0
+ - name: Install go-licenses
+ run: go install github.com/google/go-licenses@v1.6.0
- - name: Check for GPL/AGPL licensed dependencies
- run: |
- echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
- echo ""
-
- # Check all Go packages for copyleft licenses, excluding internal netbird packages
- COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
-
- if [ -n "$COPYLEFT_DEPS" ]; then
- echo "Found copyleft licensed dependencies:"
- echo "$COPYLEFT_DEPS"
+ - name: Check for GPL/AGPL licensed dependencies
+ run: |
+ echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
echo ""
- # Filter out dependencies that are only pulled in by internal AGPL packages
- INCOMPATIBLE=""
- while IFS=',' read -r package url license; do
- if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
- # Find ALL packages that import this GPL package using go list
- IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
+ # Check all Go packages for copyleft licenses, excluding internal netbird packages
+ COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
- # Check if any importer is NOT in management/signal/relay
- BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
-
- if [ -n "$BSD_IMPORTER" ]; then
- echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
- INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
- else
- echo "✓ $package ($license) is only used by internal AGPL packages - OK"
- fi
- fi
- done <<< "$COPYLEFT_DEPS"
-
- if [ -n "$INCOMPATIBLE" ]; then
+ if [ -n "$COPYLEFT_DEPS" ]; then
+ echo "Found copyleft licensed dependencies:"
+ echo "$COPYLEFT_DEPS"
echo ""
- echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
- echo -e "$INCOMPATIBLE"
- exit 1
- fi
- fi
- echo "✅ All external license dependencies are compatible with BSD-3-Clause"
+ # Filter out dependencies that are only pulled in by internal AGPL packages
+ INCOMPATIBLE=""
+ while IFS=',' read -r package url license; do
+ if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
+ # Find ALL packages that import this GPL package using go list
+ IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
+
+ # Check if any importer is NOT in management/signal/relay
+ BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
+
+ if [ -n "$BSD_IMPORTER" ]; then
+ echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
+ INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
+ else
+ echo "✓ $package ($license) is only used by internal AGPL packages - OK"
+ fi
+ fi
+ done <<< "$COPYLEFT_DEPS"
+
+ if [ -n "$INCOMPATIBLE" ]; then
+ echo ""
+ echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
+ echo -e "$INCOMPATIBLE"
+ exit 1
+ fi
+ fi
+
+ echo "✅ All external license dependencies are compatible with BSD-3-Clause"
diff --git a/.github/workflows/docs-ack.yml b/.github/workflows/docs-ack.yml
index f11142a36..7e34e2f8a 100644
--- a/.github/workflows/docs-ack.yml
+++ b/.github/workflows/docs-ack.yml
@@ -83,7 +83,7 @@ jobs:
- name: Verify docs PR exists (and is open or merged)
if: steps.validate.outputs.mode == 'added'
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
id: verify
with:
pr_number: ${{ steps.extract.outputs.pr_number }}
diff --git a/.github/workflows/forum.yml b/.github/workflows/forum.yml
index a26a72586..75543ef8b 100644
--- a/.github/workflows/forum.yml
+++ b/.github/workflows/forum.yml
@@ -8,11 +8,10 @@ jobs:
post:
runs-on: ubuntu-latest
steps:
- - uses: roots/discourse-topic-github-release-action@main
+ - uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0
with:
discourse-api-key: ${{ secrets.DISCOURSE_RELEASES_API_KEY }}
discourse-base-url: https://forum.netbird.io
discourse-author-username: NetBird
discourse-category: 17
- discourse-tags:
- releases
+ discourse-tags: releases
diff --git a/.github/workflows/frontend-ui.yml b/.github/workflows/frontend-ui.yml
new file mode 100644
index 000000000..552ccef29
--- /dev/null
+++ b/.github/workflows/frontend-ui.yml
@@ -0,0 +1,98 @@
+name: UI Frontend
+
+on:
+ pull_request:
+ paths:
+ - "client/ui/frontend/**"
+ - "client/ui/i18n/**"
+ - "client/ui/**/*.go"
+ - ".github/workflows/frontend-ui.yml"
+ push:
+ branches:
+ - main
+ 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 699ed7d93..160c2ea38 100644
--- a/.github/workflows/git-town.yml
+++ b/.github/workflows/git-town.yml
@@ -3,7 +3,7 @@ name: Git Town
on:
pull_request:
branches:
- - '**'
+ - "**"
jobs:
git-town:
@@ -15,7 +15,9 @@ jobs:
pull-requests: write
steps:
- - uses: actions/checkout@v4
- - uses: git-town/action@v1.2.1
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+ - uses: git-town/action@3d8b878379abb1ee393fb49865a28b4a6c2cd3b0 # v1.2.1
with:
skip-single-stacks: true
diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml
index 0528ed086..420749a0e 100644
--- a/.github/workflows/golang-test-darwin.yml
+++ b/.github/workflows/golang-test-darwin.yml
@@ -16,16 +16,18 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/go/pkg/mod
key: macos-gotest-${{ hashFiles('**/go.sum') }}
@@ -43,5 +45,19 @@ jobs:
run: git --no-pager diff --exit-code
- name: Test
- run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+ # 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@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,client
diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml
index 2c029b117..9c795e783 100644
--- a/.github/workflows/golang-test-freebsd.yml
+++ b/.github/workflows/golang-test-freebsd.yml
@@ -15,20 +15,31 @@ jobs:
name: "Client / Unit"
runs-on: ubuntu-22.04
steps:
- - uses: actions/checkout@v4
+ - name: Checkout code
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Read Go version from go.mod
+ id: goversion
+ run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
+
- name: Test in FreeBSD
id: test
- uses: vmactions/freebsd-vm@v1
+ env:
+ GO_VERSION: ${{ steps.goversion.outputs.version }}
+ uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8
with:
usesh: true
copyback: false
- release: "14.2"
+ release: "15.0"
+ envs: "GO_VERSION"
prepare: |
pkg install -y curl pkgconf xorg
- GO_TARBALL="go1.25.3.freebsd-amd64.tar.gz"
+ GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
GO_URL="https://go.dev/dl/$GO_TARBALL"
curl -vLO "$GO_URL"
- tar -C /usr/local -vxzf "$GO_TARBALL"
+ tar -C /usr/local -vxzf "$GO_TARBALL"
# -x - to print all executed commands
# -e - to faile on first error
@@ -37,14 +48,14 @@ jobs:
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
time go build -o netbird client/main.go
# check all component except management, since we do not support management server on freebsd
- time go test -timeout 1m -failfast ./base62/...
+ time go test -tags privileged -timeout 1m -failfast ./base62/...
# NOTE: without -p1 `client/internal/dns` will fail because of `listen udp4 :33100: bind: address already in use`
- time go test -timeout 8m -failfast -v -p 1 ./client/...
- time go test -timeout 1m -failfast ./dns/...
- time go test -timeout 1m -failfast ./encryption/...
- time go test -timeout 1m -failfast ./formatter/...
- time go test -timeout 1m -failfast ./client/iface/...
- time go test -timeout 1m -failfast ./route/...
- time go test -timeout 1m -failfast ./sharedsock/...
- time go test -timeout 1m -failfast ./util/...
- time go test -timeout 1m -failfast ./version/...
+ time go test -tags privileged -timeout 8m -failfast -v -p 1 ./client/...
+ time go test -tags privileged -timeout 1m -failfast ./dns/...
+ time go test -tags privileged -timeout 1m -failfast ./encryption/...
+ time go test -tags privileged -timeout 1m -failfast ./formatter/...
+ time go test -tags privileged -timeout 1m -failfast ./client/iface/...
+ time go test -tags privileged -timeout 1m -failfast ./route/...
+ time go test -tags privileged -timeout 1m -failfast ./sharedsock/...
+ time go test -tags privileged -timeout 1m -failfast ./util/...
+ time go test -tags privileged -timeout 1m -failfast ./version/...
diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml
index 450c44aea..0af506bba 100644
--- a/.github/workflows/golang-test-linux.yml
+++ b/.github/workflows/golang-test-linux.yml
@@ -18,9 +18,11 @@ jobs:
management: ${{ steps.filter.outputs.management }}
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- - uses: dorny/paths-filter@v3
+ - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
filters: |
@@ -28,7 +30,7 @@ jobs:
- 'management/**'
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -36,10 +38,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
id: cache
with:
path: |
@@ -51,7 +53,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'
@@ -113,14 +115,16 @@ jobs:
strategy:
fail-fast: false
matrix:
- arch: [ '386','amd64' ]
+ arch: ["386", "amd64"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -128,10 +132,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -141,7 +145,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'
@@ -154,18 +158,36 @@ jobs:
run: git --no-pager diff --exit-code
- name: Test
- run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+ # 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@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 ]
+ needs: [build-cache]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -177,7 +199,7 @@ jobs:
echo "modcache_dir=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
id: cache-restore
with:
path: |
@@ -214,7 +236,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:
@@ -231,10 +253,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -246,10 +270,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -268,23 +292,33 @@ jobs:
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
go test ${{ matrix.raceFlag }} \
- -exec 'sudo' \
+ -exec 'sudo' -coverprofile=coverage.txt \
-timeout 10m -p 1 ./relay/... ./shared/relay/...
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,relay
+
test_proxy:
name: "Proxy / Unit"
needs: [build-cache]
strategy:
fail-fast: false
matrix:
- arch: [ '386','amd64' ]
+ arch: ["386", "amd64"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -298,7 +332,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -316,7 +350,15 @@ jobs:
- name: Test
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
- go test -timeout 10m -p 1 ./proxy/...
+ go test -timeout 10m -p 1 -coverprofile=coverage.txt ./proxy/...
+
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,proxy
test_signal:
name: "Signal / Unit"
@@ -324,14 +366,16 @@ jobs:
strategy:
fail-fast: false
matrix:
- arch: [ '386','amd64' ]
+ arch: ["386", "amd64"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -343,10 +387,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -365,24 +409,34 @@ jobs:
run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
go test \
- -exec 'sudo' \
+ -exec 'sudo' -coverprofile=coverage.txt \
-timeout 10m ./signal/... ./shared/signal/...
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,signal
+
test_management:
name: "Management / Unit"
- needs: [ build-cache ]
+ needs: [build-cache]
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres', 'mysql' ]
+ arch: ["amd64"]
+ store: ["sqlite", "postgres", "mysql"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -390,10 +444,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -410,7 +464,7 @@ jobs:
- name: Login to Docker hub
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
@@ -427,23 +481,31 @@ jobs:
run: docker pull mlsmaycon/warmed-mysql:8
- name: Test
- run: |
+ run: |
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
- go test -tags=devcert \
+ go test -tags=devcert -coverprofile=coverage.txt \
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
-timeout 20m ./management/... ./shared/management/...
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: unit,management
+
benchmark:
name: "Management / Benchmark"
- needs: [ build-cache ]
+ needs: [build-cache]
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres' ]
+ arch: ["amd64"]
+ store: ["sqlite", "postgres"]
runs-on: ubuntu-22.04
steps:
- name: Create Docker network
@@ -474,10 +536,12 @@ jobs:
prom/prometheus
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -485,10 +549,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -505,7 +569,7 @@ jobs:
- name: Login to Docker hub
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
@@ -522,20 +586,21 @@ 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)"
- needs: [ build-cache ]
+ needs: [build-cache]
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres' ]
+ arch: ["amd64"]
+ store: ["sqlite", "postgres"]
runs-on: ubuntu-22.04
steps:
- name: Create Docker network
@@ -566,10 +631,12 @@ jobs:
prom/prometheus
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -577,10 +644,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -597,7 +664,7 @@ jobs:
- name: Login to Docker hub
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
@@ -614,29 +681,32 @@ 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"
- needs: [ build-cache ]
+ needs: [build-cache]
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
strategy:
fail-fast: false
matrix:
- arch: [ 'amd64' ]
- store: [ 'sqlite', 'postgres']
+ arch: ["amd64"]
+ store: ["sqlite", "postgres"]
runs-on: ubuntu-22.04
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -644,10 +714,10 @@ jobs:
- name: Get Go environment
run: |
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
- echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
+ echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@v4
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -667,6 +737,14 @@ jobs:
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
- go test -tags=integration \
+ go test -tags=integration -coverprofile=coverage.txt \
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
-timeout 20m ./management/server/http/...
+
+ - name: Upload coverage reports to Codecov
+ if: matrix.arch == 'amd64'
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: netbirdio/netbird
+ flags: integration,management
diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml
index 8e672043d..50a5ba4d6 100644
--- a/.github/workflows/golang-test-windows.yml
+++ b/.github/workflows/golang-test-windows.yml
@@ -18,10 +18,12 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
id: go
with:
go-version-file: "go.mod"
@@ -33,7 +35,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -44,16 +46,15 @@ jobs:
${{ runner.os }}-go-
- name: Download wintun
- uses: carlosperate/download-file-action@v2
id: download-wintun
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
- file-name: wintun.zip
- location: ${{ env.downloadPath }}
- sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
+ url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
+ destination: ${{ env.downloadPath }}\wintun.zip
+ sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
- name: Decompressing wintun files
- run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
+ run: tar -xvf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
- run: mv ${{ env.downloadPath }}/wintun/bin/amd64/wintun.dll 'C:\Windows\System32\'
@@ -64,10 +65,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 7b7b32ec0..586e1235b 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -15,12 +15,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
- - name: codespell
- uses: codespell-project/actions-codespell@v2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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/**
+ 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,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
@@ -35,27 +45,38 @@ 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@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Check for duplicate constants
if: matrix.os == 'ubuntu-latest'
run: |
! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep .
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@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@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
+ uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
with:
version: latest
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 22d002a48..1514caedc 100644
--- a/.github/workflows/install-script-test.yml
+++ b/.github/workflows/install-script-test.yml
@@ -22,7 +22,9 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: run install script
env:
diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml
index 8325fbf2d..44e912c73 100644
--- a/.github/workflows/mobile-build-validation.yml
+++ b/.github/workflows/mobile-build-validation.yml
@@ -16,23 +16,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: Setup Android SDK
- uses: android-actions/setup-android@v3
+ uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
with:
cmdline-tools-version: 8512546
- name: Setup Java
- uses: actions/setup-java@v4
+ uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520
with:
java-version: "11"
distribution: "adopt"
- name: NDK Cache
id: ndk-cache
- uses: actions/cache@v4
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: /usr/local/lib/android/sdk/ndk
key: ndk-cache-23.1.7779620
@@ -52,9 +54,11 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: install gomobile
diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml
index a2e6ce219..24d81b50f 100644
--- a/.github/workflows/pr-title-check.yml
+++ b/.github/workflows/pr-title-check.yml
@@ -9,13 +9,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Validate PR title prefix
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const title = context.payload.pull_request.title;
const allowedTags = [
'management',
'client',
+ 'android',
+ 'ios',
'signal',
'proxy',
'relay',
diff --git a/.github/workflows/proto-version-check.yml b/.github/workflows/proto-version-check.yml
index ea300419d..fd2c2c908 100644
--- a/.github/workflows/proto-version-check.yml
+++ b/.github/workflows/proto-version-check.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check for proto tool version changes
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const files = await github.paginate(github.rest.pulls.listFiles, {
@@ -20,34 +20,83 @@ jobs:
per_page: 100,
});
- const pbFiles = files.filter(f => f.filename.endsWith('.pb.go'));
- const missingPatch = pbFiles.filter(f => !f.patch).map(f => f.filename);
- if (missingPatch.length > 0) {
- core.setFailed(
- `Cannot inspect patch data for:\n` +
- missingPatch.map(f => `- ${f}`).join('\n') +
- `\nThis can happen with very large PRs. Verify proto versions manually.`
- );
+ // Cover renamed .pb.go files in addition to plain edits.
+ // Renamed entries land under the new path with previous_filename
+ // pointing at the base-side name, so we read the base content
+ // from the old path when present.
+ const changedPbFiles = files
+ .filter(f => (f.status === 'modified' || f.status === 'renamed')
+ && f.filename.endsWith('.pb.go'))
+ .map(f => ({
+ headPath: f.filename,
+ basePath: f.previous_filename || f.filename,
+ }));
+ if (changedPbFiles.length === 0) {
+ console.log('No modified or renamed .pb.go files to check');
return;
}
- const versionPattern = /^[+-]\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
- const violations = [];
- for (const file of pbFiles) {
- const changed = file.patch
- .split('\n')
- .filter(line => versionPattern.test(line));
- if (changed.length > 0) {
+ // Matches the generator version headers protoc writes at the top
+ // of generated files:
+ // // protoc v3.21.12
+ // // protoc-gen-go v1.26.0
+ // // - protoc-gen-go-grpc v1.6.1 (grpc files prefix with "- ")
+ // The optional "- " prefix and the optional -gen-go / -gen-go-grpc
+ // suffixes keep the *_grpc.pb.go headers in scope.
+ const versionPattern = /^\s*\/\/\s+(?:-\s+)?protoc(?:-gen-go(?:-grpc)?)?\s+v[\d.]+/;
+ const baseSha = context.payload.pull_request.base.sha;
+ const headSha = context.payload.pull_request.head.sha;
+
+ async function getVersionHeader(path, ref) {
+ try {
+ const res = await github.rest.repos.getContent({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ path,
+ ref,
+ });
+ if (!res.data.content) {
+ return { ok: false, reason: 'no inline content (file too large)' };
+ }
+ const content = Buffer.from(res.data.content, 'base64').toString('utf8');
+ const lines = content
+ .split('\n')
+ .slice(0, 20)
+ .filter(line => versionPattern.test(line));
+ return { ok: true, lines };
+ } catch (e) {
+ return { ok: false, reason: e.message };
+ }
+ }
+
+ const violations = [];
+ for (const file of changedPbFiles) {
+ const [base, head] = await Promise.all([
+ getVersionHeader(file.basePath, baseSha),
+ getVersionHeader(file.headPath, headSha),
+ ]);
+ if (!base.ok || !head.ok) {
+ core.warning(
+ `Skipping ${file.headPath}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
+ );
+ continue;
+ }
+ if (base.lines.join('\n') !== head.lines.join('\n')) {
violations.push({
- file: file.filename,
- lines: changed,
+ file: file.basePath === file.headPath
+ ? file.headPath
+ : `${file.basePath} → ${file.headPath}`,
+ base: base.lines,
+ head: head.lines,
});
}
}
if (violations.length > 0) {
const details = violations.map(v =>
- `${v.file}:\n${v.lines.map(l => ' ' + l).join('\n')}`
+ `${v.file}:\n` +
+ ` base:\n${v.base.map(l => ' ' + l).join('\n') || ' (none)'}\n` +
+ ` head:\n${v.head.map(l => ' ' + l).join('\n') || ' (none)'}`
).join('\n\n');
core.setFailed(
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c1ae01a98..727bff45a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,10 +9,13 @@ on:
pull_request:
env:
- SIGN_PIPE_VER: "v0.1.4"
- GORELEASER_VER: "v2.14.3"
+ SIGN_PIPE_VER: "v0.1.8"
+ GORELEASER_VER: "v2.16.0"
PRODUCT_NAME: "NetBird"
COPYRIGHT: "NetBird GmbH"
+ flags: ""
+ SKIP_PUBLISH: "true"
+ SKIP_DOCKER_PUSH: "false"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
@@ -24,13 +27,15 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Generate FreeBSD port diff
- run: bash release_files/freebsd-port-diff.sh
+ run: bash -x release_files/freebsd-port-diff.sh
- name: Generate FreeBSD port issue body
- run: bash release_files/freebsd-port-issue-body.sh
+ run: bash -x release_files/freebsd-port-issue-body.sh
- name: Check if diff was generated
id: check_diff
@@ -51,19 +56,26 @@ jobs:
echo "Generated files for version: $VERSION"
cat netbird-*.diff
+ - name: Read Go version from go.mod
+ id: goversion
+ run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
+
- name: Test FreeBSD port
if: steps.check_diff.outputs.diff_exists == 'true'
- uses: vmactions/freebsd-vm@v1
+ env:
+ GO_VERSION: ${{ steps.goversion.outputs.version }}
+ uses: vmactions/freebsd-vm@b84ab5559b5a1bb4b8ee2737d2506a16e1737636 # v1.4.8
with:
usesh: true
copyback: false
release: "15.0"
+ envs: "GO_VERSION"
prepare: |
# Install required packages
- pkg install -y git curl portlint go
+ pkg install -y git curl portlint
# Install Go for building
- GO_TARBALL="go1.25.5.freebsd-amd64.tar.gz"
+ GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
GO_URL="https://go.dev/dl/$GO_TARBALL"
curl -LO "$GO_URL"
tar -C /usr/local -xzf "$GO_TARBALL"
@@ -93,19 +105,19 @@ jobs:
# Show patched Makefile
version=$(cat security/netbird/Makefile | grep -E '^DISTVERSION=' | awk '{print $NF}')
-
+
cd /usr/ports/security/netbird
export BATCH=yes
make package
pkg add ./work/pkg/netbird-*.pkg
-
+
netbird version | grep "$version"
echo "FreeBSD port test completed successfully!"
- name: Upload FreeBSD port files
if: steps.check_diff.outputs.diff_exists == 'true'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: freebsd-port-files
path: |
@@ -121,29 +133,45 @@ jobs:
windows_packages_artifact_url: ${{ steps.upload_windows_packages.outputs.artifact-url }}
macos_packages_artifact_url: ${{ steps.upload_macos_packages.outputs.artifact-url }}
ghcr_images: ${{ steps.tag_and_push_images.outputs.images_markdown }}
- env:
- flags: ""
steps:
- - name: Parse semver string
- id: semver_parser
- uses: booxmedialtd/ws-action-parse-semver@v1
- with:
- input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
- version_extractor_regex: '\/v(.*)$'
-
- - if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
- run: echo "flags=--snapshot" >> $GITHUB_ENV
- name: Checkout
- uses: actions/checkout@v4
+ 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
+
+ if [[ "x-${{ github.repository }}" != "x-netbirdio/netbird" ]]; then
+ echo "SKIP_DOCKER_PUSH=true" >> $GITHUB_ENV
+ fi
+
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/go/pkg/mod
@@ -153,21 +181,23 @@ jobs:
${{ runner.os }}-go-releaser-
- name: Install modules
run: go mod tidy
+ - name: run openapi generator
+ run: bash shared/management/http/api/generate.sh
- name: check git status
run: git --no-pager diff --exit-code
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2
+ uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v2
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
- name: Login to Docker hub
if: github.event_name != 'pull_request'
- uses: docker/login-action@v1
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Log in to the GitHub container registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
- uses: docker/login-action@v3
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -186,12 +216,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@v4
+ uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: ${{ env.GORELEASER_VER }}
args: release --clean ${{ env.flags }}
@@ -202,6 +232,8 @@ jobs:
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
+ SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
+ SKIP_DOCKER_PUSH: ${{ env.SKIP_DOCKER_PUSH }}
- name: Verify RPM signatures
run: |
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
@@ -261,8 +293,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
@@ -282,28 +317,28 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: upload non tags for debug purposes
id: upload_release
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: release
path: dist/
retention-days: 7
- name: upload linux packages
id: upload_linux_packages
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: linux-packages
path: dist/netbird_linux**
retention-days: 7
- name: upload windows packages
id: upload_windows_packages
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: windows-packages
path: dist/netbird_windows**
retention-days: 7
- name: upload macos packages
id: upload_macos_packages
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: macos-packages
path: dist/netbird_darwin**
@@ -314,27 +349,40 @@ jobs:
outputs:
release_ui_artifact_url: ${{ steps.upload_release_ui.outputs.artifact-url }}
steps:
- - name: Parse semver string
- id: semver_parser
- uses: booxmedialtd/ws-action-parse-semver@v1
- with:
- input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
- version_extractor_regex: '\/v(.*)$'
-
- - if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
- run: echo "flags=--snapshot" >> $GITHUB_ENV
- name: Checkout
- uses: actions/checkout@v4
+ 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@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/go/pkg/mod
@@ -349,8 +397,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
@@ -369,13 +427,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@v4
+ uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: ${{ env.GORELEASER_VER }}
args: release --config .goreleaser_ui.yaml --clean ${{ env.flags }}
@@ -386,6 +450,7 @@ jobs:
UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }}
GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }}
NFPM_NETBIRD_UI_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }}
+ SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }}
- name: Verify RPM signatures
run: |
docker run --rm -v $(pwd)/dist:/dist fedora:41 bash -c '
@@ -404,12 +469,138 @@ jobs:
run: rm -f /tmp/gpg-rpm-signing-key.asc
- name: upload non tags for debug purposes
id: upload_release_ui
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: release-ui
path: dist/
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:
@@ -418,16 +609,17 @@ jobs:
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: echo "flags=--snapshot" >> $GITHUB_ENV
- name: Checkout
- uses: actions/checkout@v4
+ 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@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/go/pkg/mod
@@ -439,9 +631,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@v4
+ uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with:
version: ${{ env.GORELEASER_VER }}
args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }}
@@ -449,7 +655,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: upload non tags for debug purposes
id: upload_release_ui_darwin
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: release-ui-darwin
path: dist/
@@ -474,27 +680,26 @@ jobs:
PackageWorkdir: netbird_windows_${{ matrix.arch }}
downloadPath: '${{ github.workspace }}\temp'
steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
- name: Parse semver string
id: semver_parser
- uses: booxmedialtd/ws-action-parse-semver@v1
- with:
- input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
- version_extractor_regex: '\/v(.*)$'
-
- - name: Checkout
- uses: actions/checkout@v4
+ uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
- name: Add 7-Zip to PATH
run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Download release artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release
path: release
- name: Download UI release artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-ui
path: release-ui
@@ -514,68 +719,74 @@ jobs:
Get-ChildItem $workdir
- name: Download wintun
- uses: carlosperate/download-file-action@v2
id: download-wintun
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
- file-name: wintun.zip
- location: ${{ env.downloadPath }}
- sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
+ url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
+ destination: ${{ env.downloadPath }}\wintun.zip
+ sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
- name: Decompress wintun files
- run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
+ run: tar -xvf "${{ env.downloadPath }}\wintun.zip" -C ${{ env.downloadPath }}
- name: Move wintun.dll into dist
run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
- - name: Download Mesa3D (amd64 only)
- uses: carlosperate/download-file-action@v2
- id: download-mesa3d
- if: matrix.arch == 'amd64'
- with:
- file-url: https://downloads.fdossena.com/Projects/Mesa3D/Builds/MesaForWindows-x64-20.1.8.7z
- file-name: mesa3d.7z
- location: ${{ env.downloadPath }}
- sha256: '71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9'
-
- - 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: carlosperate/download-file-action@v2
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://nsis.sourceforge.io/mediawiki/images/7/7f/EnVar_plugin.zip
- file-name: envar_plugin.zip
- location: ${{ github.workspace }}
+ url: https://pkgs.netbird.io/nsis/EnVar_plugin.zip
+ destination: ${{ github.workspace }}\envar_plugin.zip
+ sha256: e9aa92de351345ed82795251d838f1ae9041ba35af9d381a5780c7843b01f56a
- name: Extract EnVar plugin
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/envar_plugin.zip"
- name: Download ShellExecAsUser plugin for NSIS (amd64 only)
- uses: carlosperate/download-file-action@v2
if: matrix.arch == 'amd64'
+ uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
with:
- file-url: https://nsis.sourceforge.io/mediawiki/images/6/68/ShellExecAsUser_amd64-Unicode.7z
- file-name: ShellExecAsUser_amd64-Unicode.7z
- location: ${{ github.workspace }}
+ url: https://pkgs.netbird.io/nsis/ShellExecAsUser_amd64-Unicode.7z
+ destination: ${{ github.workspace }}\ShellExecAsUser_amd64-Unicode.7z
+ sha256: 0a55ea25c7330a92cec028eda8afcaf1b1a7092e0dfb77c21c8f654564b4ff9d
- name: Extract ShellExecAsUser plugin (amd64 only)
if: matrix.arch == 'amd64'
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
- - name: Build NSIS installer
- uses: joncloud/makensis-action@v3.3
+ - name: Set up Go for wails3 CLI
+ uses: actions/setup-go@v5
with:
- additional-plugin-paths: ${{ github.workspace }}/NSIS_Plugins/Plugins
- script-file: client/installer.nsis
- arguments: "/V4 /DARCH=${{ matrix.arch }}"
+ 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:
APPVER: ${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}.${{ steps.semver_parser.outputs.patch }}.${{ github.run_id }}
+ run: |
+ $nsisPluginDir = "C:\Program Files (x86)\NSIS\Plugins\x86-unicode"
+ $srcPlugins = "${{ github.workspace }}\NSIS_Plugins\Plugins"
+ Get-ChildItem -Path $srcPlugins -Recurse -Filter *.dll |
+ Copy-Item -Destination $nsisPluginDir -Force
+ & "C:\Program Files (x86)\NSIS\makensis.exe" /V4 "/DARCH=${{ matrix.arch }}" client\installer.nsis
+ if ($LASTEXITCODE -ne 0) { throw "makensis failed with exit code $LASTEXITCODE" }
- name: Rename NSIS installer
run: mv netbird-installer.exe netbird_installer_test_windows_${{ matrix.arch }}.exe
@@ -592,7 +803,7 @@ jobs:
- name: Upload installer artifacts
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: windows-installer-test-${{ matrix.arch }}
path: |
@@ -603,7 +814,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
@@ -611,16 +822,18 @@ jobs:
pull-requests: write
steps:
- name: Create or update PR comment
- uses: actions/github-script@v7
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
RELEASE_RESULT: ${{ needs.release.result }}
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
+ 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:
@@ -643,6 +856,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],
];
@@ -699,11 +913,11 @@ 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
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: Sign bin and installer
repo: netbirdio/sign-pipelines
diff --git a/.github/workflows/sync-main.yml b/.github/workflows/sync-main.yml
index e36e35a2d..5805fcf57 100644
--- a/.github/workflows/sync-main.yml
+++ b/.github/workflows/sync-main.yml
@@ -14,9 +14,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger main branch sync
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: sync-main.yml
repo: ${{ secrets.UPSTREAM_REPO }}
token: ${{ secrets.NC_GITHUB_TOKEN }}
- inputs: '{ "sha": "${{ github.sha }}" }'
\ No newline at end of file
+ inputs: '{ "sha": "${{ github.sha }}" }'
diff --git a/.github/workflows/sync-tag.yml b/.github/workflows/sync-tag.yml
index a75d9a9d5..d99f88b54 100644
--- a/.github/workflows/sync-tag.yml
+++ b/.github/workflows/sync-tag.yml
@@ -3,7 +3,7 @@ name: sync tag
on:
push:
tags:
- - 'v*'
+ - "v*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger release tag sync
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: sync-tag.yml
ref: main
@@ -29,7 +29,7 @@ jobs:
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
steps:
- name: Trigger android-client submodule bump
- uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: bump-netbird.yml
ref: main
@@ -42,10 +42,10 @@ jobs:
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
steps:
- name: Trigger ios-client submodule bump
- uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: bump-netbird.yml
ref: main
repo: netbirdio/ios-client
token: ${{ secrets.NC_GITHUB_TOKEN }}
- inputs: '{ "tag": "${{ github.ref_name }}" }'
\ No newline at end of file
+ inputs: '{ "tag": "${{ github.ref_name }}" }'
diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml
index e2f950731..729214d9e 100644
--- a/.github/workflows/test-infrastructure-files.yml
+++ b/.github/workflows/test-infrastructure-files.yml
@@ -6,10 +6,10 @@ on:
- main
pull_request:
paths:
- - 'infrastructure_files/**'
- - '.github/workflows/test-infrastructure-files.yml'
- - 'management/cmd/**'
- - 'signal/cmd/**'
+ - "infrastructure_files/**"
+ - ".github/workflows/test-infrastructure-files.yml"
+ - "management/cmd/**"
+ - "signal/cmd/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- store: [ 'sqlite', 'postgres', 'mysql' ]
+ store: ["sqlite", "postgres", "mysql"]
services:
postgres:
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
@@ -68,15 +68,17 @@ jobs:
run: sudo apt-get install -y curl
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: Cache Go modules
- uses: actions/cache@v4
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
@@ -139,8 +141,8 @@ jobs:
CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret
CI_NETBIRD_SIGNAL_PORT: 12345
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
- NETBIRD_STORE_ENGINE_POSTGRES_DSN: '${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$'
- NETBIRD_STORE_ENGINE_MYSQL_DSN: '${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$'
+ NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$"
+ NETBIRD_STORE_ENGINE_MYSQL_DSN: "${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$"
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
@@ -205,7 +207,7 @@ jobs:
- name: Build management docker image
working-directory: management
run: |
- docker build -t netbirdio/management:latest .
+ docker build -t netbirdio/management:latest --build-arg TARGETPLATFORM=. .
- name: Build signal binary
working-directory: signal
@@ -214,7 +216,7 @@ jobs:
- name: Build signal docker image
working-directory: signal
run: |
- docker build -t netbirdio/signal:latest .
+ docker build -t netbirdio/signal:latest --build-arg TARGETPLATFORM=. .
- name: Build relay binary
working-directory: relay
@@ -223,7 +225,7 @@ jobs:
- name: Build relay docker image
working-directory: relay
run: |
- docker build -t netbirdio/relay:latest .
+ docker build -t netbirdio/relay:latest --build-arg TARGETPLATFORM=. .
- name: run docker compose up
working-directory: infrastructure_files/artifacts
@@ -247,76 +249,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@v4
+ 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/update-docs.yml b/.github/workflows/update-docs.yml
index 26f3b8f02..ff4f0a86a 100644
--- a/.github/workflows/update-docs.yml
+++ b/.github/workflows/update-docs.yml
@@ -3,9 +3,9 @@ name: update docs
on:
push:
tags:
- - 'v*'
+ - "v*"
paths:
- - 'shared/management/http/api/openapi.yml'
+ - "shared/management/http/api/openapi.yml"
jobs:
trigger_docs_api_update:
@@ -13,10 +13,10 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Trigger API pages generation
- uses: benc-uk/workflow-dispatch@v1
+ uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
with:
workflow: generate api pages
repo: netbirdio/docs
ref: "refs/heads/main"
token: ${{ secrets.SIGN_GITHUB_TOKEN }}
- inputs: '{ "tag": "${{ github.ref }}" }'
\ No newline at end of file
+ inputs: '{ "tag": "${{ github.ref }}" }'
diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml
index 81ae36e78..e8a12cdaf 100644
--- a/.github/workflows/wasm-build-validation.yml
+++ b/.github/workflows/wasm-build-validation.yml
@@ -19,15 +19,17 @@ jobs:
GOARCH: wasm
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ 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@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
+ uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
with:
version: latest
install-mode: binary
@@ -42,9 +44,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
- name: Install Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: Build Wasm client
@@ -61,8 +65,7 @@ jobs:
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
- if [ ${SIZE} -gt 58720256 ]; then
- echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
+ if [ ${SIZE} -gt 62914560 ]; then
+ echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!"
exit 1
fi
-
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 5ea479148..8dd05a192 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -1,5 +1,7 @@
version: 2
-
+env:
+ - SKIP_PUBLISH={{ if index .Env "SKIP_PUBLISH" }}{{ .Env.SKIP_PUBLISH }}{{ else }}true{{ end }}
+ - SKIP_DOCKER_PUSH={{ if index .Env "SKIP_DOCKER_PUSH" }}{{ .Env.SKIP_DOCKER_PUSH }}{{ else }}false{{ end }}
project_name: netbird
builds:
- id: netbird-wasm
@@ -74,6 +76,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -88,6 +92,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -102,6 +108,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -122,6 +130,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -136,6 +146,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -150,6 +162,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}}
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -170,6 +184,8 @@ builds:
- amd64
- arm64
- arm
+ goarm:
+ - 7
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
@@ -196,6 +212,7 @@ nfpms:
description: Netbird client.
homepage: https://netbird.io/
license: BSD-3-Clause
+ vendor: NetBird
id: netbird_deb
bindir: /usr/bin
builds:
@@ -210,6 +227,7 @@ nfpms:
description: Netbird client.
homepage: https://netbird.io/
license: BSD-3-Clause
+ vendor: NetBird
id: netbird_rpm
bindir: /usr/bin
builds:
@@ -222,670 +240,192 @@ nfpms:
rpm:
signature:
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
-dockers:
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-amd64
- - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
- ids:
- - netbird
- goarch: amd64
- use: buildx
- dockerfile: client/Dockerfile
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
- ids:
- - netbird
- goarch: arm64
- use: buildx
- dockerfile: client/Dockerfile
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm
- ids:
- - netbird
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: client/Dockerfile
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
-
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-amd64
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
- ids:
- - netbird
- goarch: amd64
- use: buildx
- dockerfile: client/Dockerfile-rootless
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- ids:
- - netbird
- goarch: arm64
- use: buildx
- dockerfile: client/Dockerfile-rootless
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
- ids:
- - netbird
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: client/Dockerfile-rootless
- extra_files:
- - client/netbird-entrypoint.sh
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
-
- - image_templates:
- - netbirdio/relay:{{ .Version }}-amd64
- - ghcr.io/netbirdio/relay:{{ .Version }}-amd64
- ids:
- - netbird-relay
- goarch: amd64
- use: buildx
- dockerfile: relay/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/relay:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
- ids:
- - netbird-relay
- goarch: arm64
- use: buildx
- dockerfile: relay/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/relay:{{ .Version }}-arm
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm
- ids:
- - netbird-relay
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: relay/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/signal:{{ .Version }}-amd64
- - ghcr.io/netbirdio/signal:{{ .Version }}-amd64
- ids:
- - netbird-signal
- goarch: amd64
- use: buildx
- dockerfile: signal/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/signal:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
- ids:
- - netbird-signal
- goarch: arm64
- use: buildx
- dockerfile: signal/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/signal:{{ .Version }}-arm
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm
- ids:
- - netbird-signal
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: signal/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-amd64
- - ghcr.io/netbirdio/management:{{ .Version }}-amd64
- ids:
- - netbird-mgmt
- goarch: amd64
- use: buildx
- dockerfile: management/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
- ids:
- - netbird-mgmt
- goarch: arm64
- use: buildx
- dockerfile: management/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-arm
- ids:
- - netbird-mgmt
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: management/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-debug-amd64
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64
- ids:
- - netbird-mgmt
- goarch: amd64
- use: buildx
- dockerfile: management/Dockerfile.debug
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/management:{{ .Version }}-debug-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8
- ids:
- - netbird-mgmt
- goarch: arm64
- use: buildx
- dockerfile: management/Dockerfile.debug
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
-
- - image_templates:
- - netbirdio/management:{{ .Version }}-debug-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm
- ids:
- - netbird-mgmt
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: management/Dockerfile.debug
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/upload:{{ .Version }}-amd64
- - ghcr.io/netbirdio/upload:{{ .Version }}-amd64
- ids:
- - netbird-upload
- goarch: amd64
- use: buildx
- dockerfile: upload-server/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/upload:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
- ids:
- - netbird-upload
- goarch: arm64
- use: buildx
- dockerfile: upload-server/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/upload:{{ .Version }}-arm
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm
- ids:
- - netbird-upload
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: upload-server/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird-server:{{ .Version }}-amd64
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
- ids:
- - netbird-server
- goarch: amd64
- use: buildx
- dockerfile: combined/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird-server:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
- ids:
- - netbird-server
- goarch: arm64
- use: buildx
- dockerfile: combined/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/netbird-server:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
- ids:
- - netbird-server
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: combined/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-amd64
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
- ids:
- - netbird-proxy
- goarch: amd64
- use: buildx
- dockerfile: proxy/Dockerfile
- build_flag_templates:
- - "--platform=linux/amd64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- ids:
- - netbird-proxy
- goarch: arm64
- use: buildx
- dockerfile: proxy/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm64"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
- - image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-arm
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
- ids:
- - netbird-proxy
- goarch: arm
- goarm: 6
- use: buildx
- dockerfile: proxy/Dockerfile
- build_flag_templates:
- - "--platform=linux/arm"
- - "--label=org.opencontainers.image.created={{.Date}}"
- - "--label=org.opencontainers.image.title={{.ProjectName}}"
- - "--label=org.opencontainers.image.version={{.Version}}"
- - "--label=org.opencontainers.image.revision={{.FullCommit}}"
- - "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- - "--label=maintainer=dev@netbird.io"
-docker_manifests:
- - name_template: netbirdio/netbird:{{ .Version }}
- image_templates:
- - netbirdio/netbird:{{ .Version }}-arm64v8
- - netbirdio/netbird:{{ .Version }}-arm
- - netbirdio/netbird:{{ .Version }}-amd64
-
- - name_template: netbirdio/netbird:latest
- image_templates:
- - netbirdio/netbird:{{ .Version }}-arm64v8
- - netbirdio/netbird:{{ .Version }}-arm
- - netbirdio/netbird:{{ .Version }}-amd64
-
- - name_template: netbirdio/netbird:{{ .Version }}-rootless
- image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - netbirdio/netbird:{{ .Version }}-rootless-arm
- - netbirdio/netbird:{{ .Version }}-rootless-amd64
-
- - name_template: netbirdio/netbird:rootless-latest
- image_templates:
- - netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - netbirdio/netbird:{{ .Version }}-rootless-arm
- - netbirdio/netbird:{{ .Version }}-rootless-amd64
-
- - name_template: netbirdio/relay:{{ .Version }}
- image_templates:
- - netbirdio/relay:{{ .Version }}-arm64v8
- - netbirdio/relay:{{ .Version }}-arm
- - netbirdio/relay:{{ .Version }}-amd64
-
- - name_template: netbirdio/relay:latest
- image_templates:
- - netbirdio/relay:{{ .Version }}-arm64v8
- - netbirdio/relay:{{ .Version }}-arm
- - netbirdio/relay:{{ .Version }}-amd64
-
- - name_template: netbirdio/signal:{{ .Version }}
- image_templates:
- - netbirdio/signal:{{ .Version }}-arm64v8
- - netbirdio/signal:{{ .Version }}-arm
- - netbirdio/signal:{{ .Version }}-amd64
-
- - name_template: netbirdio/signal:latest
- image_templates:
- - netbirdio/signal:{{ .Version }}-arm64v8
- - netbirdio/signal:{{ .Version }}-arm
- - netbirdio/signal:{{ .Version }}-amd64
-
- - name_template: netbirdio/management:{{ .Version }}
- image_templates:
- - netbirdio/management:{{ .Version }}-arm64v8
- - netbirdio/management:{{ .Version }}-arm
- - netbirdio/management:{{ .Version }}-amd64
-
- - name_template: netbirdio/management:latest
- image_templates:
- - netbirdio/management:{{ .Version }}-arm64v8
- - netbirdio/management:{{ .Version }}-arm
- - netbirdio/management:{{ .Version }}-amd64
-
- - name_template: netbirdio/management:debug-latest
- image_templates:
- - netbirdio/management:{{ .Version }}-debug-arm64v8
- - netbirdio/management:{{ .Version }}-debug-arm
- - netbirdio/management:{{ .Version }}-debug-amd64
- - name_template: netbirdio/upload:{{ .Version }}
- image_templates:
- - netbirdio/upload:{{ .Version }}-arm64v8
- - netbirdio/upload:{{ .Version }}-arm
- - netbirdio/upload:{{ .Version }}-amd64
-
- - name_template: netbirdio/upload:latest
- image_templates:
- - netbirdio/upload:{{ .Version }}-arm64v8
- - netbirdio/upload:{{ .Version }}-arm
- - netbirdio/upload:{{ .Version }}-amd64
-
- - name_template: netbirdio/netbird-server:{{ .Version }}
- image_templates:
- - netbirdio/netbird-server:{{ .Version }}-arm64v8
- - netbirdio/netbird-server:{{ .Version }}-arm
- - netbirdio/netbird-server:{{ .Version }}-amd64
-
- - name_template: netbirdio/netbird-server:latest
- image_templates:
- - netbirdio/netbird-server:{{ .Version }}-arm64v8
- - netbirdio/netbird-server:{{ .Version }}-arm
- - netbirdio/netbird-server:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird:latest
- image_templates:
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird:{{ .Version }}-rootless
- image_templates:
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
-
- - name_template: ghcr.io/netbirdio/netbird:rootless-latest
- image_templates:
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm64v8
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-arm
- - ghcr.io/netbirdio/netbird:{{ .Version }}-rootless-amd64
-
- - name_template: ghcr.io/netbirdio/relay:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm
- - ghcr.io/netbirdio/relay:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/relay:latest
- image_templates:
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/relay:{{ .Version }}-arm
- - ghcr.io/netbirdio/relay:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/signal:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm
- - ghcr.io/netbirdio/signal:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/signal:latest
- image_templates:
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/signal:{{ .Version }}-arm
- - ghcr.io/netbirdio/signal:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/management:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/management:latest
- image_templates:
- - ghcr.io/netbirdio/management:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/management:debug-latest
- image_templates:
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm64v8
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-arm
- - ghcr.io/netbirdio/management:{{ .Version }}-debug-amd64
-
- - name_template: ghcr.io/netbirdio/upload:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm
- - ghcr.io/netbirdio/upload:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/upload:latest
- image_templates:
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/upload:{{ .Version }}-arm
- - ghcr.io/netbirdio/upload:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird-server:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/netbird-server:latest
- image_templates:
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-arm
- - ghcr.io/netbirdio/netbird-server:{{ .Version }}-amd64
-
- - name_template: netbirdio/reverse-proxy:{{ .Version }}
- image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - netbirdio/reverse-proxy:{{ .Version }}-arm
- - netbirdio/reverse-proxy:{{ .Version }}-amd64
-
- - name_template: netbirdio/reverse-proxy:latest
- image_templates:
- - netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - netbirdio/reverse-proxy:{{ .Version }}-arm
- - netbirdio/reverse-proxy:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/reverse-proxy:{{ .Version }}
- image_templates:
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
-
- - name_template: ghcr.io/netbirdio/reverse-proxy:latest
- image_templates:
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm64v8
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-arm
- - ghcr.io/netbirdio/reverse-proxy:{{ .Version }}-amd64
+dockers_v2:
+ - id: netbird
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird
+ images:
+ - netbirdio/netbird
+ - ghcr.io/netbirdio/netbird
+ tags:
+ - "{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: client/Dockerfile
+ extra_files:
+ - client/netbird-entrypoint.sh
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm/6
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: netbird-rootless
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird
+ images:
+ - netbirdio/netbird
+ - ghcr.io/netbirdio/netbird
+ tags:
+ - "{{ .Version }}-rootless"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}"
+ dockerfile: client/Dockerfile-rootless
+ extra_files:
+ - client/netbird-entrypoint.sh
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm/6
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: relay
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-relay
+ images:
+ - netbirdio/relay
+ - ghcr.io/netbirdio/relay
+ tags:
+ - "{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: relay/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: signal
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-signal
+ images:
+ - netbirdio/signal
+ - ghcr.io/netbirdio/signal
+ tags:
+ - "{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: signal/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: management
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-mgmt
+ images:
+ - netbirdio/management
+ - ghcr.io/netbirdio/management
+ tags:
+ - "{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: management/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: upload
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-upload
+ images:
+ - netbirdio/upload
+ - ghcr.io/netbirdio/upload
+ tags:
+ - "{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: upload-server/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: netbird-server
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-server
+ images:
+ - netbirdio/netbird-server
+ - ghcr.io/netbirdio/netbird-server
+ tags:
+ - "{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: combined/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
+ - id: netbird-proxy
+ disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
+ ids:
+ - netbird-proxy
+ images:
+ - netbirdio/reverse-proxy
+ - ghcr.io/netbirdio/reverse-proxy
+ tags:
+ - "{{ .Version }}"
+ - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
+ dockerfile: proxy/Dockerfile
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/arm
+ annotations:
+ "org.opencontainers.image.created": "{{.Date}}"
+ "org.opencontainers.image.title": "{{.ProjectName}}"
+ "org.opencontainers.image.version": "{{.Version}}"
+ "org.opencontainers.image.revision": "{{.FullCommit}}"
+ "org.opencontainers.image.source": "{{.GitURL}}"
+ "maintainer": "dev@netbird.io"
brews:
- ids:
- default
+ skip_upload: "{{ .Env.SKIP_PUBLISH }}"
repository:
owner: netbirdio
name: homebrew-tap
@@ -902,6 +442,7 @@ brews:
uploads:
- name: debian
+ skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_deb
mode: archive
@@ -910,6 +451,7 @@ uploads:
method: PUT
- name: yum
+ skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_rpm
mode: archive
@@ -922,9 +464,13 @@ checksum:
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
- glob: ./release_files/install.sh
- glob: ./infrastructure_files/getting-started.sh
+ - glob: ./infrastructure_files/getting-started-enterprise.sh
+ - glob: ./infrastructure_files/migrate-to-enterprise.sh
release:
extra_files:
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
- glob: ./release_files/install.sh
- glob: ./infrastructure_files/getting-started.sh
+ - glob: ./infrastructure_files/getting-started-enterprise.sh
+ - glob: ./infrastructure_files/migrate-to-enterprise.sh
diff --git a/.goreleaser_ui.yaml b/.goreleaser_ui.yaml
index 470f1deaa..ca5148823 100644
--- a/.goreleaser_ui.yaml
+++ b/.goreleaser_ui.yaml
@@ -1,6 +1,16 @@
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
@@ -14,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
@@ -29,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
@@ -45,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
@@ -61,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:
@@ -70,16 +88,20 @@ 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
- 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:
@@ -89,18 +111,22 @@ 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)
+
rpm:
signature:
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
uploads:
- name: debian
+ skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_ui_deb
mode: archive
@@ -109,6 +135,7 @@ uploads:
method: PUT
- name: yum
+ skip: "{{ .Env.SKIP_PUBLISH }}"
ids:
- netbird_ui_rpm
mode: archive
diff --git a/.goreleaser_ui_darwin.yaml b/.goreleaser_ui_darwin.yaml
index 0a0082075..47b991344 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
diff --git a/.goreleaser_ui_gtk3.yaml b/.goreleaser_ui_gtk3.yaml
new file mode 100644
index 000000000..d0a7de6e2
--- /dev/null
+++ b/.goreleaser_ui_gtk3.yaml
@@ -0,0 +1,131 @@
+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:
+ # Same package_name as the GTK4 packages -- the two are mutually-exclusive
+ # alternatives served from separate repo paths (see uploads below); a given
+ # distro points at exactly one of them. The file names must still differ:
+ # the Debian pool is shared storage keyed by file name, so a default-named
+ # gtk3 .deb would overwrite the stable one.
+ - 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
+ file_name_template: "{{ .PackageName }}-gtk3_{{ .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
+ dependencies:
+ - netbird (>= 0.75.0)
+ - libgtk-3-0
+ - libwebkit2gtk-4.1-0
+
+ - 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
+ file_name_template: "{{ .PackageName }}-gtk3_{{ .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
+ dependencies:
+ - netbird >= 0.75.0
+ - (gtk3 or libgtk-3-0)
+ - (webkit2gtk4.1 or libwebkit2gtk-4_1-0)
+
+ 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:
+ # The gtk3 packages reuse the netbird-ui package name, so they live in
+ # dedicated repo paths (deb distribution `gtk3`, yum path `yum-gtk3`) that
+ # legacy distros point their repo config at.
+ - name: debian-gtk3
+ skip: "{{ .Env.SKIP_PUBLISH }}"
+ ids:
+ - netbird_ui_deb_gtk3
+ mode: archive
+ target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=gtk3;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
+ username: dev@wiretrustee.com
+ method: PUT
+
+ - name: yum-gtk3
+ skip: "{{ .Env.SKIP_PUBLISH }}"
+ ids:
+ - netbird_ui_rpm_gtk3
+ mode: archive
+ target: https://pkgs.wiretrustee.com/yum-gtk3/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
+ username: dev@wiretrustee.com
+ method: PUT
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..95b02a91d
--- /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..db5097a48 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,7 @@ 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)
- [Other project repositories](#other-project-repositories)
- [Contributor License Agreement](#contributor-license-agreement)
@@ -34,42 +122,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 +191,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 +334,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 +414,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 +446,172 @@ 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.
+
## 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..40c6b9ed5 100644
--- a/README.md
+++ b/README.md
@@ -33,10 +33,15 @@
- 🚀 We are hiring! Join us at careers.netbird.io
+ 🚀 We are hiring! Join us at https://netbird.io/careers
+> ### 🤖 NetBird Agent Network (Beta)
+> Identity-aware access control for AI agents — keyless access to LLM APIs and private
+> resources over the encrypted NetBird tunnel. See [`agent-network/`](agent-network/) or
+> read the docs at **[netbird.ai](https://netbird.ai)**.
+
**NetBird combines a configuration-free peer-to-peer private network and a centralized access control system in a single platform, making it easy to create secure private networks for your organization or home.**
**Connect.** NetBird creates a WireGuard-based overlay network that automatically connects your machines over an encrypted tunnel, leaving behind the hassle of opening ports, complex firewall rules, VPN gateways, and so forth.
diff --git a/SECURITY.md b/SECURITY.md
index 745c66e61..bdf88d670 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..1997ea299
--- /dev/null
+++ b/agent-network/README.md
@@ -0,0 +1,73 @@
+# 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**
+
+## 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.
+
+
+
+
+## Where the code lives
+
+There is no separate "agent-network" service — it reuses the reverse-proxy and management
+components:
+
+- [`proxy/`](../proxy) — the NetBird reverse proxy that serves the agent network endpoint
+ and runs the per-request middleware pipeline.
+- [`management/internals/modules/reverseproxy/`](../management/internals/modules/reverseproxy)
+ — the management-side control plane: providers, policies, guardrails, limits, routing,
+ and usage/access logs.
+
+## Documentation
+
+Full documentation, architecture, and quickstart:
+**https://docs.netbird.io/agent-network**
diff --git a/client/Dockerfile b/client/Dockerfile
index 53e4555ef..478b2d0e2 100644
--- a/client/Dockerfile
+++ b/client/Dockerfile
@@ -4,7 +4,7 @@
# sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
# sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
-FROM alpine:3.23.3
+FROM alpine:3.24
# iproute2: busybox doesn't display ip rules properly
RUN apk add --no-cache \
bash \
@@ -21,7 +21,7 @@ ENV \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
-
-ARG NETBIRD_BINARY=netbird
+ARG TARGETPLATFORM
+ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird
diff --git a/client/Dockerfile-rootless b/client/Dockerfile-rootless
index 706bf40de..8141af6ed 100644
--- a/client/Dockerfile-rootless
+++ b/client/Dockerfile-rootless
@@ -4,7 +4,7 @@
# podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
# podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
-FROM alpine:3.22.0
+FROM alpine:3.24
RUN apk add --no-cache \
bash \
@@ -27,7 +27,7 @@ ENV \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
-
-ARG NETBIRD_BINARY=netbird
+ARG TARGETPLATFORM
+ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird
diff --git a/client/android/client.go b/client/android/client.go
index 99ccdf393..154bd8484 100644
--- a/client/android/client.go
+++ b/client/android/client.go
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"slices"
+ "strings"
"sync"
"time"
@@ -56,6 +57,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())
}
@@ -75,13 +82,34 @@ type Client struct {
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 +119,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()
@@ -143,16 +181,21 @@ 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)
+ 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)
}
@@ -187,7 +230,7 @@ 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)
+ c.setState(cfg, cacheDir, cfgFile, connectClient)
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
}
@@ -216,6 +259,24 @@ 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
+}
+
// 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) {
@@ -247,6 +308,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
}
@@ -274,7 +338,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 +360,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 +377,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
}
@@ -436,10 +521,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 +540,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 +590,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/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..3f367b97f 100644
--- a/client/android/login.go
+++ b/client/android/login.go
@@ -4,6 +4,8 @@ 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/system"
@@ -36,12 +38,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 +63,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 +163,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,17 +178,42 @@ 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 := writeProfileEmail(a.cfgPath, email); err != nil {
+ log.Warnf("failed to store profile account email: %v", err)
+ }
+ }
+
go urlOpener.OnLoginSuccess()
return nil
}
+// loginHintSetter is implemented by both concrete flows (PKCE and device code)
+// but absent from the OAuthFlow interface, hence the assertion below — the same
+// way internal/auth wires it in authenticateWithPKCEFlow.
+type loginHintSetter interface {
+ SetLoginHint(hint string)
+}
+
func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) {
oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV)
if err != nil {
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
}
+ // An empty hint is deliberate, not a fallback: a fresh or logged-out profile
+ // leaves the choice to the IdP, which is how accounts get switched.
+ if a.cfgPath != "" {
+ if hint := readProfileEmail(a.cfgPath); hint != "" {
+ if setter, ok := oAuthFlow.(loginHintSetter); ok {
+ setter.SetLoginHint(hint)
+ }
+ }
+ }
+
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
if err != nil {
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
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_manager.go b/client/android/profile_manager.go
index 60e4d5c32..3197124d7 100644
--- a/client/android/profile_manager.go
+++ b/client/android/profile_manager.go
@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"path/filepath"
- "strings"
log "github.com/sirupsen/logrus"
@@ -14,17 +13,17 @@ import (
)
const (
- // Android-specific config filename (different from desktop default.json)
- defaultConfigFilename = "netbird.cfg"
- // Subdirectory for non-default profiles (must match Java Preferences.java)
- profilesSubdir = "profiles"
// Android uses a single user context per app (non-empty username required by ServiceManager)
androidUsername = "android"
)
// Profile represents a profile for gomobile
type Profile struct {
- Name string
+ ID string
+ Name string
+ // Email is the account this profile last logged in with, "" if it never
+ // completed an SSO login or was logged out. See profile_state.go.
+ Email string
IsActive bool
}
@@ -53,10 +52,10 @@ func (p *ProfileArray) Get(i int) *Profile {
├── state.json ← Default profile state
├── active_profile.json ← Active profile tracker (JSON with Name + Username)
└── profiles/ ← Subdirectory for non-default profiles
- ├── work.json ← Work profile config
- ├── work.state.json ← Work profile state
- ├── personal.json ← Personal profile config
- └── personal.state.json ← Personal profile state
+ ├── work.json ← Legacy work profile config
+ ├── work.state.json ← Legacy work profile state
+ ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config
+ ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state
*/
// ProfileManager manages profiles for Android
@@ -99,7 +98,9 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
var profiles []*Profile
for _, p := range internalProfiles {
profiles = append(profiles, &Profile{
+ ID: p.ID.String(),
Name: p.Name,
+ Email: pm.profileEmail(p.ID.String()),
IsActive: p.IsActive,
})
}
@@ -108,55 +109,80 @@ func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) {
}
// GetActiveProfile returns the currently active profile name
-func (pm *ProfileManager) GetActiveProfile() (string, error) {
+func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
// Use ServiceManager to stay consistent with ListProfiles
// ServiceManager uses active_profile.json
activeState, err := pm.serviceMgr.GetActiveProfileState()
if err != nil {
- return "", fmt.Errorf("failed to get active profile: %w", err)
+ return nil, fmt.Errorf("failed to get active profile: %w", err)
}
- return activeState.Name, nil
+
+ // ActiveProfileState only stores the ID (and username), not the display
+ // name. Resolve the ID to the full profile so callers get the real Name.
+ prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err)
+ }
+ return &Profile{
+ ID: prof.ID.String(),
+ Name: prof.Name,
+ Email: pm.profileEmail(prof.ID.String()),
+ IsActive: true,
+ }, nil
+}
+
+// profileEmail returns the account email recorded for a profile. Display-only, so
+// an unresolvable path degrades to "" rather than an error.
+func (pm *ProfileManager) profileEmail(id string) string {
+ configPath, err := pm.getProfileConfigPath(id)
+ if err != nil {
+ return ""
+ }
+ return readProfileEmail(configPath)
}
// SwitchProfile switches to a different profile
-func (pm *ProfileManager) SwitchProfile(profileName string) error {
+func (pm *ProfileManager) SwitchProfile(id string) error {
// Use ServiceManager to stay consistent with ListProfiles
// ServiceManager uses active_profile.json
err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: profileName,
+ ID: profilemanager.ID(id),
Username: androidUsername,
})
if err != nil {
return fmt.Errorf("failed to switch profile: %w", err)
}
- log.Infof("switched to profile: %s", profileName)
+ log.Infof("switched to profile: %s", id)
return nil
}
// AddProfile creates a new profile
func (pm *ProfileManager) AddProfile(profileName string) error {
// Use ServiceManager (creates profile in profiles/ directory)
- if err := pm.serviceMgr.AddProfile(profileName, androidUsername); err != nil {
+ profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
+ if err != nil {
return fmt.Errorf("failed to add profile: %w", err)
}
- log.Infof("created new profile: %s", profileName)
+ log.Infof("created new profile: %s", profile.ID)
return nil
}
// LogoutProfile logs out from a profile (clears authentication)
-func (pm *ProfileManager) LogoutProfile(profileName string) error {
- profileName = sanitizeProfileName(profileName)
-
- configPath, err := pm.getProfileConfigPath(profileName)
+func (pm *ProfileManager) LogoutProfile(id string) error {
+ configPath, err := pm.getProfileConfigPath(id)
if err != nil {
return err
}
+ if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
+ return fmt.Errorf("id '%s' is not valid", id)
+ }
+
// Check if profile exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
- return fmt.Errorf("profile '%s' does not exist", profileName)
+ return fmt.Errorf("profile '%s' does not exist", id)
}
// Read current config using internal profilemanager
@@ -174,53 +200,75 @@ func (pm *ProfileManager) LogoutProfile(profileName string) error {
return fmt.Errorf("failed to save config: %w", err)
}
- log.Infof("logged out from profile: %s", profileName)
+ // Not fatal: a stale hint costs an account switch, not the logout itself.
+ if err := removeProfileEmail(configPath); err != nil {
+ log.Warnf("failed to clear stored account email for profile %s: %v", id, err)
+ }
+
+ log.Infof("logged out from profile: %s", id)
+ return nil
+}
+
+// RenameProfile changes a profile's display name. The profile ID, and therefore
+// its on-disk filename, is left untouched: only the "name" field of the config
+// is rewritten. This works for the default profile too, whose config lives in
+// netbird.cfg rather than under profiles/.
+func (pm *ProfileManager) RenameProfile(id string, newName string) error {
+ if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil {
+ return fmt.Errorf("failed to rename profile: %w", err)
+ }
+
+ log.Infof("renamed profile %s to: %s", id, newName)
return nil
}
// RemoveProfile deletes a profile
-func (pm *ProfileManager) RemoveProfile(profileName string) error {
+func (pm *ProfileManager) RemoveProfile(id string) error {
// Use ServiceManager (removes profile from profiles/ directory)
- if err := pm.serviceMgr.RemoveProfile(profileName, androidUsername); err != nil {
+ if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil {
return fmt.Errorf("failed to remove profile: %w", err)
}
- log.Infof("removed profile: %s", profileName)
+ log.Infof("removed profile: %s", id)
return nil
}
// getProfileConfigPath returns the config file path for a profile
// This is needed for Android-specific path handling (netbird.cfg for default profile)
-func (pm *ProfileManager) getProfileConfigPath(profileName string) (string, error) {
- if profileName == "" || profileName == profilemanager.DefaultProfileName {
+func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) {
+ if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
+ return "", fmt.Errorf("id %q is not valid", id)
+ }
+
+ if id == profilemanager.DefaultProfileName {
// Android uses netbird.cfg for default profile instead of default.json
// Default profile is stored in root configDir, not in profiles/
return filepath.Join(pm.configDir, defaultConfigFilename), nil
}
- // Non-default profiles are stored in profiles subdirectory
- // This matches the Java Preferences.java expectation
- profileName = sanitizeProfileName(profileName)
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
- return filepath.Join(profilesDir, profileName+".json"), nil
+ return filepath.Join(profilesDir, id+".json"), nil
}
-// GetConfigPath returns the config file path for a given profile
+// GetConfigPath returns the config file path for a given profile id
// Java should call this instead of constructing paths with Preferences.configFile()
-func (pm *ProfileManager) GetConfigPath(profileName string) (string, error) {
- return pm.getProfileConfigPath(profileName)
+func (pm *ProfileManager) GetConfigPath(id string) (string, error) {
+ return pm.getProfileConfigPath(id)
}
// GetStateFilePath returns the state file path for a given profile
// Java should call this instead of constructing paths with Preferences.stateFile()
-func (pm *ProfileManager) GetStateFilePath(profileName string) (string, error) {
- if profileName == "" || profileName == profilemanager.DefaultProfileName {
+func (pm *ProfileManager) GetStateFilePath(id string) (string, error) {
+ if id == "" || id == profilemanager.DefaultProfileName {
return filepath.Join(pm.configDir, "state.json"), nil
}
- profileName = sanitizeProfileName(profileName)
+ if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) {
+ return "", fmt.Errorf("id %q is not valid", id)
+ }
+
profilesDir := filepath.Join(pm.configDir, profilesSubdir)
- return filepath.Join(profilesDir, profileName+".state.json"), nil
+ return filepath.Join(profilesDir, id+".state.json"), nil
}
// GetActiveConfigPath returns the config file path for the currently active profile
@@ -230,7 +278,7 @@ func (pm *ProfileManager) GetActiveConfigPath() (string, error) {
if err != nil {
return "", fmt.Errorf("failed to get active profile: %w", err)
}
- return pm.GetConfigPath(activeProfile)
+ return pm.GetConfigPath(activeProfile.ID)
}
// GetActiveStateFilePath returns the state file path for the currently active profile
@@ -240,18 +288,5 @@ func (pm *ProfileManager) GetActiveStateFilePath() (string, error) {
if err != nil {
return "", fmt.Errorf("failed to get active profile: %w", err)
}
- return pm.GetStateFilePath(activeProfile)
-}
-
-// sanitizeProfileName removes invalid characters from profile name
-func sanitizeProfileName(name string) string {
- // Keep only alphanumeric, underscore, and hyphen
- var result strings.Builder
- for _, r := range name {
- if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
- (r >= '0' && r <= '9') || r == '_' || r == '-' {
- result.WriteRune(r)
- }
- }
- return result.String()
+ return pm.GetStateFilePath(activeProfile.ID)
}
diff --git a/client/android/profile_state.go b/client/android/profile_state.go
new file mode 100644
index 000000000..3f0a09701
--- /dev/null
+++ b/client/android/profile_state.go
@@ -0,0 +1,108 @@
+package android
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/util"
+)
+
+const (
+ // Android-specific config filename (different from desktop default.json)
+ defaultConfigFilename = "netbird.cfg"
+ // Subdirectory for non-default profiles (must match Java Preferences.java)
+ profilesSubdir = "profiles"
+ // profileAccountSuffix names the file holding the profile's account email.
+ // Deliberately not ".state.json", which desktop uses for the same data:
+ // there the email and the engine's state manager live in different
+ // directories, but on Android both resolve under files/, so sharing the name
+ // would have the two overwrite each other — the state manager rewrites the
+ // whole file from its own keys (see statemanager.Manager.PersistState), and
+ // this package's writer does the same in reverse.
+ profileAccountSuffix = ".account.json"
+)
+
+// profileAccountPathFor derives the account file path from a profile's config
+// path: netbird.cfg -> netbird.account.json, .json -> .account.json.
+//
+// Deriving from the config path rather than resolving the active profile keeps
+// the write on the profile the login actually ran for: Auth.login runs in a
+// goroutine, so the active profile can change under a flow already in flight.
+func profileAccountPathFor(configPath string) (string, error) {
+ if configPath == "" {
+ return "", fmt.Errorf("empty config path")
+ }
+
+ base := filepath.Base(configPath)
+ stem := strings.TrimSuffix(base, filepath.Ext(base))
+ if stem == "" || stem == "." {
+ return "", fmt.Errorf("config path %q has no filename stem", configPath)
+ }
+
+ return filepath.Join(filepath.Dir(configPath), stem+profileAccountSuffix), nil
+}
+
+// readProfileEmail returns the account email stored for the profile whose config
+// lives at configPath. A missing or unreadable file yields "", which leaves the
+// account choice to the IdP.
+func readProfileEmail(configPath string) string {
+ accountPath, err := profileAccountPathFor(configPath)
+ if err != nil {
+ log.Debugf("no profile account path for login hint: %v", err)
+ return ""
+ }
+
+ var state profilemanager.ProfileState
+ if _, err := util.ReadJson(accountPath, &state); err != nil {
+ if !os.IsNotExist(err) {
+ log.Debugf("failed to read profile account for login hint: %v", err)
+ }
+ return ""
+ }
+
+ return state.Email
+}
+
+// writeProfileEmail records the account email for the profile whose config lives
+// at configPath, so later logins can pass it as an OIDC login_hint. An empty
+// email is ignored rather than blanking what is already stored.
+func writeProfileEmail(configPath string, email string) error {
+ if email == "" {
+ return nil
+ }
+
+ accountPath, err := profileAccountPathFor(configPath)
+ if err != nil {
+ return fmt.Errorf("resolve profile account path: %w", err)
+ }
+
+ state := profilemanager.ProfileState{Email: email}
+ if err := util.WriteJsonWithRestrictedPermission(context.Background(), accountPath, state); err != nil {
+ return fmt.Errorf("write profile account: %w", err)
+ }
+
+ return nil
+}
+
+// removeProfileEmail drops the stored account email. Called on logout: while the
+// email is on disk it goes out as a login_hint, which would steer the next login
+// straight back into the account just logged out of. Mirrors the desktop UI's
+// RemoveProfileState call.
+func removeProfileEmail(configPath string) error {
+ accountPath, err := profileAccountPathFor(configPath)
+ if err != nil {
+ return fmt.Errorf("resolve profile account path: %w", err)
+ }
+
+ if err := os.Remove(accountPath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("remove profile account: %w", err)
+ }
+
+ return nil
+}
diff --git a/client/android/profile_state_test.go b/client/android/profile_state_test.go
new file mode 100644
index 000000000..623e16c3b
--- /dev/null
+++ b/client/android/profile_state_test.go
@@ -0,0 +1,161 @@
+package android
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestProfileAccountPathFor(t *testing.T) {
+ tests := []struct {
+ name string
+ configPath string
+ want string
+ wantErr bool
+ }{
+ {
+ name: "default profile",
+ configPath: "/data/data/io.netbird.client/files/netbird.cfg",
+ want: filepath.FromSlash("/data/data/io.netbird.client/files/netbird.account.json"),
+ },
+ {
+ name: "id profile",
+ configPath: "/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.json",
+ want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/4c5f5c8198c3989cffb5b5394f5a7ae0.account.json"),
+ },
+ {
+ name: "legacy name-keyed profile is handled the same way",
+ configPath: "/data/data/io.netbird.client/files/profiles/work.json",
+ want: filepath.FromSlash("/data/data/io.netbird.client/files/profiles/work.account.json"),
+ },
+ {
+ name: "empty path is rejected",
+ configPath: "",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := profileAccountPathFor(tt.configPath)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatalf("expected an error, got path %q", got)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != tt.want {
+ t.Errorf("got %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestProfileAccountPathForDefaultDoesNotCollide(t *testing.T) {
+ root := "/data/data/io.netbird.client/files"
+
+ defaultAccount, err := profileAccountPathFor(filepath.Join(root, defaultConfigFilename))
+ if err != nil {
+ t.Fatalf("default profile: %v", err)
+ }
+
+ idAccount, err := profileAccountPathFor(filepath.Join(root, profilesSubdir, "abc123.json"))
+ if err != nil {
+ t.Fatalf("id profile: %v", err)
+ }
+
+ if defaultAccount == idAccount {
+ t.Fatalf("default and id profile share an account file: %q", defaultAccount)
+ }
+}
+
+// The account file must never land on the engine state file: on Android both
+// resolve under files/, and the state manager rewrites the whole file from its
+// own keys, so sharing a path would have the two overwrite each other. The
+// expected names here mirror ProfileManager.GetStateFilePath.
+func TestProfileAccountPathAvoidsEngineStateFile(t *testing.T) {
+ root := "/data/data/io.netbird.client/files"
+
+ cases := []struct {
+ configPath string
+ engineState string
+ }{
+ {
+ configPath: filepath.Join(root, defaultConfigFilename),
+ engineState: filepath.Join(root, "state.json"),
+ },
+ {
+ configPath: filepath.Join(root, profilesSubdir, "abc123.json"),
+ engineState: filepath.Join(root, profilesSubdir, "abc123.state.json"),
+ },
+ }
+
+ for _, c := range cases {
+ account, err := profileAccountPathFor(c.configPath)
+ if err != nil {
+ t.Fatalf("%s: %v", c.configPath, err)
+ }
+ if account == c.engineState {
+ t.Errorf("account file collides with the engine state file: %q", account)
+ }
+ }
+}
+
+func TestWriteThenReadProfileEmail(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
+ if err := ensureDirFor(t, configPath); err != nil {
+ t.Fatalf("prepare dir: %v", err)
+ }
+
+ if got := readProfileEmail(configPath); got != "" {
+ t.Errorf("expected no email before a login, got %q", got)
+ }
+
+ const email = "user@example.com"
+ if err := writeProfileEmail(configPath, email); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ if got := readProfileEmail(configPath); got != email {
+ t.Errorf("got %q, want %q", got, email)
+ }
+
+ if err := removeProfileEmail(configPath); err != nil {
+ t.Fatalf("remove: %v", err)
+ }
+ if got := readProfileEmail(configPath); got != "" {
+ t.Errorf("expected no email after logout, got %q", got)
+ }
+
+ // Logout may run on a never-logged-in profile, so a second remove must pass.
+ if err := removeProfileEmail(configPath); err != nil {
+ t.Fatalf("second remove should be a no-op: %v", err)
+ }
+}
+
+func TestWriteProfileEmailIgnoresEmpty(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "profiles", "abc123.json")
+ if err := ensureDirFor(t, configPath); err != nil {
+ t.Fatalf("prepare dir: %v", err)
+ }
+
+ const email = "user@example.com"
+ if err := writeProfileEmail(configPath, email); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ if err := writeProfileEmail(configPath, ""); err != nil {
+ t.Fatalf("write empty: %v", err)
+ }
+
+ if got := readProfileEmail(configPath); got != email {
+ t.Errorf("empty write clobbered the stored email: got %q, want %q", got, email)
+ }
+}
+
+func ensureDirFor(t *testing.T, path string) error {
+ t.Helper()
+ return os.MkdirAll(filepath.Dir(path), 0o700)
+}
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/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 2a8cdc887..7ddc3afc4 100644
--- a/client/cmd/debug.go
+++ b/client/cmd/debug.go
@@ -3,12 +3,14 @@ package cmd
import (
"context"
"fmt"
+ "os/user"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"google.golang.org/grpc/status"
+ "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/netbirdio/netbird/client/internal"
@@ -19,6 +21,7 @@ import (
"github.com/netbirdio/netbird/client/server"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/upload-server/types"
+ "github.com/netbirdio/netbird/version"
)
const errCloseConnection = "Failed to close connection: %v"
@@ -26,8 +29,9 @@ const errCloseConnection = "Failed to close connection: %v"
var (
logFileCount uint32
systemInfoFlag bool
- uploadBundleFlag bool
- uploadBundleURLFlag string
+ uploadBundleFlag bool
+ uploadBundleURLFlag string
+ uploadBundleInsecureFlag bool
)
var debugCmd = &cobra.Command{
@@ -84,6 +88,73 @@ var persistenceCmd = &cobra.Command{
RunE: setSyncResponsePersistence,
}
+var debugConfigCmd = &cobra.Command{
+ Use: "config",
+ Example: " netbird debug config",
+ Short: "Dump the effective configuration",
+ Long: "Prints the daemon's resolved configuration (after applying defaults, file, env, CLI input, and MDM policy overrides) as JSON. Includes the list of MDM-managed fields.",
+ RunE: debugConfigDump,
+}
+
+// debugConfigDump implements `netbird debug config`. It resolves the
+// active profile, queries the daemon for the effective configuration
+// via GetConfig, and prints the resulting GetConfigResponse as JSON
+// (via protojson with EmitUnpopulated=true so the output is stable
+// across runs and includes zero-valued fields).
+//
+// Useful for verifying MDM enforcement end-to-end: the response's
+// mDMManagedFields array is the single source of truth for "which
+// fields is the daemon currently enforcing from the MDM source", and
+// every config field side-by-side with that list confirms the merge
+// result. Secrets in the response (e.g. PreSharedKey) are already
+// redacted by the daemon-side handler.
+func debugConfigDump(cmd *cobra.Command, _ []string) error {
+ pm := profilemanager.NewProfileManager()
+ activeProf, err := pm.GetActiveProfile()
+ if err != nil {
+ return fmt.Errorf("get active profile: %v", err)
+ }
+ currUser, err := user.Current()
+ if err != nil {
+ return fmt.Errorf("get current user: %v", err)
+ }
+
+ conn, err := getClient(cmd)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err := conn.Close(); err != nil {
+ log.Errorf(errCloseConnection, err)
+ }
+ }()
+
+ client := proto.NewDaemonServiceClient(conn)
+ resp, err := client.GetConfig(cmd.Context(), &proto.GetConfigRequest{
+ ProfileName: string(activeProf.ID),
+ Username: currUser.Username,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to get config: %v", status.Convert(err).Message())
+ }
+
+ // Use protojson so well-known fields render correctly; emit defaults so
+ // the operator sees every field even when zero/empty.
+ m := protojson.MarshalOptions{Multiline: true, Indent: " ", EmitUnpopulated: true}
+ out, err := m.Marshal(resp)
+ if err != nil {
+ return fmt.Errorf("marshal config: %w", err)
+ }
+ cmd.Println(string(out))
+ return nil
+}
+
+// debugBundle requests the daemon to create a debug bundle and prints
+// the resulting local file path and, if uploaded, the uploaded file
+// key. It uses the package flags (anonymize, system info, log file
+// count, CLI version, optional upload URL) to configure the bundle
+// request. Returns an error if the RPC fails or if the daemon reports
+// an upload failure reason.
func debugBundle(cmd *cobra.Command, _ []string) error {
conn, err := getClient(cmd)
if err != nil {
@@ -100,13 +171,15 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
Anonymize: anonymizeFlag,
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())
@@ -298,13 +371,15 @@ func runForDuration(cmd *cobra.Command, args []string) error {
Anonymize: anonymizeFlag,
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 {
@@ -432,6 +507,7 @@ func generateDebugBundle(config *profilemanager.Config, recorder *peer.Status, c
SyncResponse: syncResponse,
LogPath: logFilePath,
CPUProfile: nil,
+ DaemonVersion: version.NetbirdVersion(), // acting as daemon
},
debug.BundleConfig{
IncludeSystemInfo: true,
@@ -451,10 +527,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/kubernetes.go b/client/cmd/kubernetes.go
new file mode 100644
index 000000000..cc91477c6
--- /dev/null
+++ b/client/cmd/kubernetes.go
@@ -0,0 +1,301 @@
+package cmd
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "github.com/goccy/go-yaml"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+const (
+ KubernetesDNSSuffix = "netbird-kubeapi-proxy"
+)
+
+var kubernetesCmd = &cobra.Command{
+ Use: "kubernetes",
+ Short: "Kubernetes cluster commands.",
+ Long: "Kubernetes cluster commands.",
+}
+
+var kubernetesListCmd = &cobra.Command{
+ Use: "list",
+ RunE: kubernetesList,
+ Short: "List Kubernetes clusters.",
+ Long: "List Kubernetes clusters by discovering NetBird peers running netbird-kubeapi-proxy.",
+}
+
+var kubernetesWriteKubeconfigCmd = &cobra.Command{
+ Use: "write-kubeconfig",
+ RunE: kubernetesWriteKubeconfig,
+ Args: cobra.ExactArgs(1),
+ Short: "Write kubeconfig for a Kubernetes cluster.",
+ Long: "Updates kubeconfig in place to allow token-less access to the Kubernetes cluster through NetBird.",
+}
+
+func init() {
+ kubernetesWriteKubeconfigCmd.Flags().String("kubeconfig", "", "path to kubeconfig file")
+}
+
+func kubernetesList(cmd *cobra.Command, _ []string) error {
+ conn, err := getClient(cmd)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+ client := proto.NewDaemonServiceClient(conn)
+ statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true})
+ if err != nil {
+ return err
+ }
+
+ kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, "")
+ if err != nil {
+ return err
+ }
+ if len(kcs) == 0 {
+ cmd.Println("No Kubernetes clusters available.")
+ return nil
+ }
+ cmd.Println("Available Kubernetes clusters:")
+ for _, k := range kcs {
+ cmd.Printf("\n - Name: %s\n FQDN: %s\n Version: %s\n", k.name, k.url.Host, k.version)
+ }
+ return nil
+}
+
+func kubernetesWriteKubeconfig(cmd *cobra.Command, args []string) error {
+ kubeconfigPath, err := resolveKubeconfigPath(cmd)
+ if err != nil {
+ return err
+ }
+
+ conn, err := getClient(cmd)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+ client := proto.NewDaemonServiceClient(conn)
+ statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true})
+ if err != nil {
+ return err
+ }
+
+ clusterName := args[0]
+ kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, clusterName)
+ if err != nil {
+ return err
+ }
+ if len(kcs) == 0 {
+ return fmt.Errorf("kubernetes cluster named %s not found", clusterName)
+ }
+ if len(kcs) > 1 {
+ return fmt.Errorf("too many Kubernetes clusters returned")
+ }
+ err = writeKubeconfig(kubeconfigPath, kcs[0])
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+type kubernetesCluster struct {
+ name string
+ url *url.URL
+ version string
+}
+
+func getKubernetesClusters(ctx context.Context, peers []*proto.PeerState, nameFilter string) ([]kubernetesCluster, error) {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.TLSClientConfig = &tls.Config{
+ InsecureSkipVerify: true,
+ }
+ httpClient := &http.Client{
+ Transport: transport,
+ }
+ resolver := net.Resolver{
+ // Required so both DNS records are returned.
+ // https://github.com/golang/go/issues/17093
+ PreferGo: true,
+ }
+
+ kcs := []kubernetesCluster{}
+ attempted := map[string]struct{}{}
+ for _, peer := range peers {
+ fqdns, err := resolver.LookupAddr(ctx, peer.IP)
+ if err != nil {
+ return nil, err
+ }
+ for _, fqdn := range fqdns {
+ if _, ok := attempted[fqdn]; ok {
+ continue
+ }
+ attempted[fqdn] = struct{}{}
+ comps := strings.Split(fqdn, ".")
+ if len(comps) < 2 {
+ continue
+ }
+ if comps[1] != KubernetesDNSSuffix {
+ continue
+ }
+ if nameFilter != "" && nameFilter != comps[0] {
+ continue
+ }
+ clusterURL, clusterVersion, err := fingerprintClusters(ctx, httpClient, fqdn)
+ if err != nil {
+ log.Debugf("could not fingerprint Kubernetes cluster %s %q", fqdn, err)
+ continue
+ }
+ kc := kubernetesCluster{
+ name: comps[0],
+ url: clusterURL,
+ version: clusterVersion,
+ }
+ if nameFilter != "" {
+ return []kubernetesCluster{kc}, nil
+ }
+ kcs = append(kcs, kc)
+ }
+ }
+ return kcs, nil
+}
+
+func fingerprintClusters(ctx context.Context, httpClient *http.Client, fqdn string) (*url.URL, string, error) {
+ clusterURL, err := url.Parse("https://" + fqdn)
+ if err != nil {
+ return nil, "", err
+ }
+ versionURL, err := clusterURL.Parse("/version")
+ if err != nil {
+ return nil, "", err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL.String(), nil)
+ if err != nil {
+ return nil, "", err
+ }
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, "", fmt.Errorf("expected %d response but got %s", http.StatusOK, resp.Status)
+ }
+ b, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, "", err
+ }
+ versionData := map[string]string{}
+ err = json.Unmarshal(b, &versionData)
+ if err != nil {
+ return nil, "", err
+ }
+ version, ok := versionData["gitVersion"]
+ if !ok {
+ return nil, "", errors.New("no version found in response")
+ }
+ return clusterURL, version, nil
+}
+
+func resolveKubeconfigPath(cmd *cobra.Command) (string, error) {
+ if cmd.Flags().Changed("kubeconfig") {
+ path, err := cmd.Flags().GetString("kubeconfig")
+ if err != nil {
+ return "", err
+ }
+ return path, nil
+ }
+ if env := os.Getenv("KUBECONFIG"); env != "" {
+ return env, nil
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("could not determine home directory: %w", err)
+ }
+ return filepath.Join(home, ".kube", "config"), nil
+}
+
+func writeKubeconfig(kubeconfigPath string, kc kubernetesCluster) error {
+ b, err := os.ReadFile(kubeconfigPath)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ var cfg map[string]any
+ if err := yaml.Unmarshal(b, &cfg); err != nil {
+ return err
+ }
+ if cfg == nil {
+ cfg = map[string]any{
+ "apiVersion": "v1",
+ "kind": "Config",
+ }
+ }
+
+ cfg["clusters"] = appendWithName(cfg["clusters"], map[string]any{
+ "name": kc.name,
+ "cluster": map[string]any{
+ "server": kc.url.String(),
+ "insecure-skip-tls-verify": true,
+ },
+ })
+ cfg["users"] = appendWithName(cfg["users"], map[string]any{
+ "name": "netbird",
+ "user": map[string]any{
+ "token": "none",
+ },
+ })
+ cfg["contexts"] = appendWithName(cfg["contexts"], map[string]any{
+ "name": kc.name,
+ "context": map[string]any{
+ "cluster": kc.name,
+ "user": "netbird",
+ "namespace": "default",
+ },
+ })
+ cfg["current-context"] = kc.name
+
+ out, err := yaml.Marshal(cfg)
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(kubeconfigPath, out, 0o600); err != nil {
+ return err
+ }
+ return nil
+}
+
+func appendWithName(data any, add map[string]any) any {
+ if data == nil {
+ return []any{add}
+ }
+ v, ok := data.([]any)
+ if !ok {
+ return []any{add}
+ }
+ i := slices.IndexFunc(v, func(item any) bool {
+ m, ok := item.(map[string]any)
+ if !ok {
+ return false
+ }
+ return m["name"] == add["name"]
+ })
+ if i == -1 {
+ return append(v, add)
+ }
+ v[i] = add
+ return v
+}
diff --git a/client/cmd/kubernetes_test.go b/client/cmd/kubernetes_test.go
new file mode 100644
index 000000000..c40d20996
--- /dev/null
+++ b/client/cmd/kubernetes_test.go
@@ -0,0 +1,120 @@
+package cmd
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/spf13/cobra"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFingerprintClusters(t *testing.T) {
+ t.Parallel()
+
+ srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ //nolint: errcheck
+ w.Write([]byte(`{"gitVersion": "foobar"}`))
+ }))
+ defer srv.Close()
+
+ clusterURL, clusterVersion, err := fingerprintClusters(t.Context(), srv.Client(), srv.Listener.Addr().String())
+ require.NoError(t, err)
+ require.Equal(t, srv.URL, clusterURL.String())
+ require.Equal(t, "foobar", clusterVersion)
+}
+
+func TestResolveKubeconfigPath(t *testing.T) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ t.Fatalf("could not determine home directory: %v", err)
+ }
+ defaultPath := filepath.Join(home, ".kube", "config")
+ path, err := resolveKubeconfigPath(&cobra.Command{})
+ require.NoError(t, err)
+ require.Equal(t, defaultPath, path)
+
+ flagPath := "flag-path"
+ cmd := &cobra.Command{}
+ cmd.Flags().String("kubeconfig", "", "")
+ err = cmd.Flags().Set("kubeconfig", flagPath)
+ require.NoError(t, err)
+ path, err = resolveKubeconfigPath(cmd)
+ require.NoError(t, err)
+ require.Equal(t, flagPath, path)
+
+ envPath := "env-path"
+ t.Setenv("KUBECONFIG", envPath)
+ path, err = resolveKubeconfigPath(&cobra.Command{})
+ require.NoError(t, err)
+ require.Equal(t, envPath, path)
+}
+
+func TestWriteKubeconfig(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ existing string
+ }{
+ {
+ name: "empty file",
+ },
+ {
+ name: "existing content",
+ existing: `apiVersion: v1
+clusters:
+- cluster:
+ insecure-skip-tls-verify: true
+ server: https://foobar.com
+ name: foo
+current-context: test
+kind: Config
+users: []
+`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ kubeconfigPath := filepath.Join(t.TempDir(), "config")
+ err := os.WriteFile(kubeconfigPath, []byte(tt.existing), 0o644)
+ require.NoError(t, err)
+
+ kc := kubernetesCluster{
+ name: "foo",
+ url: &url.URL{Scheme: "https", Host: "example.com"},
+ }
+ err = writeKubeconfig(kubeconfigPath, kc)
+ require.NoError(t, err)
+
+ b, err := os.ReadFile(kubeconfigPath)
+ require.NoError(t, err)
+ expected := `apiVersion: v1
+clusters:
+- cluster:
+ insecure-skip-tls-verify: true
+ server: https://example.com
+ name: foo
+contexts:
+- context:
+ cluster: foo
+ namespace: default
+ user: netbird
+ name: foo
+current-context: foo
+kind: Config
+users:
+- name: netbird
+ user:
+ token: none
+`
+ require.Equal(t, expected, string(b))
+ })
+ }
+
+}
diff --git a/client/cmd/login.go b/client/cmd/login.go
index bd37e30f1..a53cb6d5f 100644
--- a/client/cmd/login.go
+++ b/client/cmd/login.go
@@ -17,16 +17,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 +71,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 {
@@ -96,17 +116,19 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
dnsLabelsReq = dnsLabelsValidated.ToSafeStringList()
}
+ handle := activeProf.ID.String()
+
loginRequest := proto.LoginRequest{
SetupKey: providedSetupKey,
ManagementUrl: managementURL,
IsUnixDesktopClient: isUnixRunningDesktop(),
Hostname: hostName,
DnsLabels: dnsLabelsReq,
- ProfileName: &activeProf.Name,
+ ProfileName: &handle,
Username: &username,
}
- profileState, err := pm.GetProfileState(activeProf.Name)
+ profileState, err := pm.GetProfileState(activeProf.ID)
if err != nil {
log.Debugf("failed to get profile state for login hint: %v", err)
} else if profileState.Email != "" {
@@ -150,6 +172,65 @@ 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)
+
+ req := &proto.RequestExtendAuthSessionRequest{}
+ // 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
@@ -170,14 +251,13 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr
return activeProf, nil
}
-func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, profileName string, username string) error {
- err := switchProfile(context.Background(), profileName, username)
+func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManager, handle string, username string) error {
+ resolvedID, err := switchProfile(ctx, handle, username)
if err != nil {
return fmt.Errorf("switch profile on daemon: %v", err)
}
- err = pm.SwitchProfile(profileName)
- if err != nil {
+ if err := pm.SwitchProfile(resolvedID); err != nil {
return fmt.Errorf("switch profile: %v", err)
}
@@ -205,11 +285,15 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage
return nil
}
-func switchProfile(ctx context.Context, profileName string, username string) error {
+// switchProfile asks the daemon to switch to the profile identified by
+// handle (a name, ID, or unique ID prefix). Returns the resolved profile
+// ID so the caller can update the local active-profile state without
+// re-resolving the handle.
+func switchProfile(ctx context.Context, handle string, username string) (profilemanager.ID, error) {
conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
//nolint
- return fmt.Errorf("failed to connect to daemon error: %v\n"+
+ return "", fmt.Errorf("failed to connect to daemon error: %v\n"+
"If the daemon is not running please run: "+
"\nnetbird service install \nnetbird service start\n", err)
}
@@ -217,15 +301,15 @@ func switchProfile(ctx context.Context, profileName string, username string) err
client := proto.NewDaemonServiceClient(conn)
- _, err = client.SwitchProfile(ctx, &proto.SwitchProfileRequest{
- ProfileName: &profileName,
+ resp, err := client.SwitchProfile(ctx, &proto.SwitchProfileRequest{
+ ProfileName: &handle,
Username: &username,
})
if err != nil {
- return fmt.Errorf("switch profile failed: %v", err)
+ return "", fmt.Errorf("switch profile failed: %w", err)
}
- return nil
+ return profilemanager.ID(resp.Id), nil
}
func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, activeProf *profilemanager.Profile) error {
@@ -249,7 +333,15 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
return fmt.Errorf("read config file %s: %v", configFilePath, err)
}
- err = foregroundLogin(ctx, cmd, config, setupKey, activeProf.Name)
+ // 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)
}
@@ -277,7 +369,7 @@ func handleSSOLogin(ctx context.Context, cmd *cobra.Command, loginResp *proto.Lo
return nil
}
-func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey, profileName string) error {
+func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, setupKey string, profileID profilemanager.ID) error {
authClient, err := auth.NewAuth(ctx, config.PrivateKey, config.ManagementURL, config)
if err != nil {
return fmt.Errorf("failed to create auth client: %v", err)
@@ -291,7 +383,7 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman
jwtToken := ""
if setupKey == "" && needsLogin {
- tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileName)
+ tokenInfo, err := foregroundGetTokenInfo(ctx, cmd, config, profileID)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)
}
@@ -306,10 +398,10 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *profileman
return nil
}
-func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileName string) (*auth.TokenInfo, error) {
+func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *profilemanager.Config, profileID profilemanager.ID) (*auth.TokenInfo, error) {
hint := ""
pm := profilemanager.NewProfileManager()
- profileState, err := pm.GetProfileState(profileName)
+ profileState, err := pm.GetProfileState(profileID)
if err != nil {
log.Debugf("failed to get profile state for login hint: %v", err)
} else if profileState.Email != "" {
diff --git a/client/cmd/login_test.go b/client/cmd/login_test.go
index 47522e189..0aa1856b1 100644
--- a/client/cmd/login_test.go
+++ b/client/cmd/login_test.go
@@ -27,7 +27,7 @@ func TestLogin(t *testing.T) {
profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json"
sm := profilemanager.ServiceManager{}
err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: "default",
+ ID: "default",
Username: currUser.Username,
})
if err != nil {
diff --git a/client/cmd/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/profile.go b/client/cmd/profile.go
index d6e81760f..268034e70 100644
--- a/client/cmd/profile.go
+++ b/client/cmd/profile.go
@@ -2,11 +2,16 @@ package cmd
import (
"context"
+ "errors"
"fmt"
"os/user"
+ "strings"
+ "text/tabwriter"
"time"
"github.com/spf13/cobra"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/profilemanager"
@@ -14,6 +19,8 @@ import (
"github.com/netbirdio/netbird/util"
)
+var profileListShowID bool
+
var profileCmd = &cobra.Command{
Use: "profile",
Short: "Manage NetBird client profiles",
@@ -31,27 +38,40 @@ var profileListCmd = &cobra.Command{
var profileAddCmd = &cobra.Command{
Use: "add ",
Short: "Add a new profile",
- Long: `Add a new profile to the NetBird client. The profile name must be unique.`,
+ Long: `Add a new profile. Profile name is free-form, a unique ID is generated for the on-disk config file.`,
Args: cobra.ExactArgs(1),
RunE: addProfileFunc,
}
+var profileRenameCmd = &cobra.Command{
+ Use: "rename ",
+ Short: "Renames an existing profile",
+ Long: `Renames an existing profile (by a name, ID, or unique ID prefix). Profile name is free-form.`,
+ Args: cobra.ExactArgs(2),
+ RunE: renameProfileFunc,
+}
+
var profileRemoveCmd = &cobra.Command{
- Use: "remove ",
- Short: "Remove a profile",
- Long: `Remove a profile from the NetBird client. The profile must not be inactive.`,
- Args: cobra.ExactArgs(1),
- RunE: removeProfileFunc,
+ Use: "remove ",
+ Short: "Remove a profile",
+ Long: `Remove a profile by name, ID, or unique ID prefix.`,
+ Aliases: []string{"rm"},
+ Args: cobra.ExactArgs(1),
+ RunE: removeProfileFunc,
}
var profileSelectCmd = &cobra.Command{
- Use: "select ",
+ Use: "select ",
Short: "Select a profile",
- Long: `Make the specified profile active. This will switch the client to use the selected profile's configuration.`,
+ Long: `Make the specified profile active. Accepts a name, ID, or unique ID prefix.`,
Args: cobra.ExactArgs(1),
RunE: selectProfileFunc,
}
+func init() {
+ profileListCmd.Flags().BoolVar(&profileListShowID, "show-id", false, "show the profile ID column")
+}
+
func setupCmd(cmd *cobra.Command) error {
SetFlagsFromEnvVars(rootCmd)
SetFlagsFromEnvVars(cmd)
@@ -65,6 +85,7 @@ func setupCmd(cmd *cobra.Command) error {
return nil
}
+
func listProfilesFunc(cmd *cobra.Command, _ []string) error {
if err := setupCmd(cmd); err != nil {
return err
@@ -83,25 +104,33 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
daemonClient := proto.NewDaemonServiceClient(conn)
- profiles, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{
+ resp, err := daemonClient.ListProfiles(cmd.Context(), &proto.ListProfilesRequest{
Username: currUser.Username,
})
if err != nil {
return err
}
- // list profiles, add a tick if the profile is active
- cmd.Println("Found", len(profiles.Profiles), "profiles:")
- for _, profile := range profiles.Profiles {
- // use a cross to indicate the passive profiles
- activeMarker := "✗"
- if profile.IsActive {
- activeMarker = "✓"
- }
- cmd.Println(activeMarker, profile.Name)
+ tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
+ if profileListShowID {
+ fmt.Fprintln(tw, "ID\tNAME\tACTIVE")
+ } else {
+ fmt.Fprintln(tw, "NAME\tACTIVE")
}
-
- return nil
+ for _, profile := range resp.Profiles {
+ marker := ""
+ if profile.IsActive {
+ marker = "✓"
+ }
+ name := profilemanager.StripCtrlChars(profile.Name)
+ id := profilemanager.ID(profile.Id)
+ if profileListShowID {
+ fmt.Fprintf(tw, "%s\t%s\t%s\n", id.ShortID(), name, marker)
+ } else {
+ fmt.Fprintf(tw, "%s\t%s\n", name, marker)
+ }
+ }
+ return tw.Flush()
}
func addProfileFunc(cmd *cobra.Command, args []string) error {
@@ -109,6 +138,41 @@ func addProfileFunc(cmd *cobra.Command, args []string) error {
return err
}
+ currUser, err := user.Current()
+ if err != nil {
+ return fmt.Errorf("get current user: %w", err)
+ }
+
+ conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
+ if err != nil {
+ return fmt.Errorf("connect to service CLI interface: %w", err)
+ }
+ defer conn.Close()
+
+ daemonClient := proto.NewDaemonServiceClient(conn)
+ profileName := args[0]
+
+ id, err := addProfileOnDaemon(cmd.Context(), daemonClient, profileName, currUser.Username)
+ if err != nil {
+ return err
+ }
+
+ dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, profileName)
+ if dupCount > 1 {
+ cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, profileName)
+ cmd.Println("Use `netbird profile list --show-id` to disambiguate later.")
+ }
+
+ cmd.Printf("Profile added: %s %s\n", id.ShortID(), profilemanager.StripCtrlChars(profileName))
+ return nil
+
+}
+
+func renameProfileFunc(cmd *cobra.Command, args []string) error {
+ if err := setupCmd(cmd); err != nil {
+ return err
+ }
+
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
if err != nil {
return fmt.Errorf("connect to service CLI interface: %w", err)
@@ -121,21 +185,43 @@ func addProfileFunc(cmd *cobra.Command, args []string) error {
}
daemonClient := proto.NewDaemonServiceClient(conn)
+ handle := args[0]
+ newProfilename := args[1]
- profileName := args[0]
-
- _, err = daemonClient.AddProfile(cmd.Context(), &proto.AddProfileRequest{
- ProfileName: profileName,
- Username: currUser.Username,
+ resp, err := daemonClient.RenameProfile(cmd.Context(), &proto.RenameProfileRequest{
+ Handle: handle,
+ Username: currUser.Username,
+ NewProfileName: newProfilename,
})
if err != nil {
- return err
+ return wrapAmbiguityError(err, handle)
}
- cmd.Println("Profile added successfully:", profileName)
+ dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, newProfilename)
+ if dupCount > 1 {
+ cmd.Printf("Warning: %d other profile(s) already use the name %q.\n", dupCount-1, newProfilename)
+ cmd.Println("Use `netbird profile list --show-id` to disambiguate later.")
+ }
+
+ cmd.Printf("Profile renamed from %s to %s\n", profilemanager.StripCtrlChars(resp.OldProfileName), profilemanager.StripCtrlChars(newProfilename))
+
return nil
}
+func countProfilesWithName(ctx context.Context, c proto.DaemonServiceClient, username, name string) (int, error) {
+ resp, err := c.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username})
+ if err != nil {
+ return 0, err
+ }
+ n := 0
+ for _, p := range resp.Profiles {
+ if p.Name == name {
+ n++
+ }
+ }
+ return n, nil
+}
+
func removeProfileFunc(cmd *cobra.Command, args []string) error {
if err := setupCmd(cmd); err != nil {
return err
@@ -153,18 +239,17 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error {
}
daemonClient := proto.NewDaemonServiceClient(conn)
+ handle := args[0]
- profileName := args[0]
-
- _, err = daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{
- ProfileName: profileName,
+ resp, err := daemonClient.RemoveProfile(cmd.Context(), &proto.RemoveProfileRequest{
+ ProfileName: handle,
Username: currUser.Username,
})
if err != nil {
- return err
+ return wrapAmbiguityError(err, handle)
}
- cmd.Println("Profile removed successfully:", profileName)
+ cmd.Printf("Profile removed: %s\n", resp.Id)
return nil
}
@@ -174,7 +259,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
}
profileManager := profilemanager.NewProfileManager()
- profileName := args[0]
+ handle := args[0]
currUser, err := user.Current()
if err != nil {
@@ -191,32 +276,15 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
daemonClient := proto.NewDaemonServiceClient(conn)
- profiles, err := daemonClient.ListProfiles(ctx, &proto.ListProfilesRequest{
- Username: currUser.Username,
+ switchResp, err := daemonClient.SwitchProfile(ctx, &proto.SwitchProfileRequest{
+ ProfileName: &handle,
+ Username: &currUser.Username,
})
if err != nil {
- return fmt.Errorf("list profiles: %w", err)
+ return wrapAmbiguityError(err, handle)
}
- var profileExists bool
-
- for _, profile := range profiles.Profiles {
- if profile.Name == profileName {
- profileExists = true
- break
- }
- }
-
- if !profileExists {
- return fmt.Errorf("profile %s does not exist", profileName)
- }
-
- if err := switchProfile(cmd.Context(), profileName, currUser.Username); err != nil {
- return err
- }
-
- err = profileManager.SwitchProfile(profileName)
- if err != nil {
+ if err := profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)); err != nil {
return err
}
@@ -231,6 +299,46 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error {
}
}
- cmd.Println("Profile switched successfully to:", profileName)
+ id := profilemanager.ID(switchResp.Id)
+ cmd.Printf("Profile switched to: %s\n", id.ShortID())
return nil
}
+
+// wrapAmbiguityError turns the daemon's gRPC InvalidArgument errors
+// (which carry the resolver's message verbatim) into CLI-friendly text
+// that points the user at --show-id.
+func wrapAmbiguityError(err error, handle string) error {
+ if err == nil {
+ return nil
+ }
+ st, ok := gstatus.FromError(err)
+ if !ok {
+ return err
+ }
+ switch st.Code() {
+ case codes.InvalidArgument:
+ msg := st.Message()
+ if strings.Contains(msg, "ambiguous") {
+ return errors.New(msg + "\nRun `netbird profile list --show-id` to see IDs, then select by ID prefix:\n netbird profile select|remove ")
+ }
+ case codes.NotFound:
+ return fmt.Errorf("profile %q not found", handle)
+ }
+ return err
+}
+
+// addProfileOnDaemon issues the AddProfile RPC on an existing daemon client
+// and returns the new profile's ID. It is the single entry point for profile
+// creation, shared by `netbird profile add` and the `netbird up --profile
+// ` auto-create path.
+func addProfileOnDaemon(ctx context.Context, client proto.DaemonServiceClient, profileName, username string) (profilemanager.ID, error) {
+ resp, err := client.AddProfile(ctx, &proto.AddProfileRequest{
+ ProfileName: profileName,
+ Username: username,
+ })
+ if err != nil {
+ return "", fmt.Errorf("add profile failed: %w", err)
+ }
+
+ return profilemanager.ID(resp.Id), nil
+}
diff --git a/client/cmd/root.go b/client/cmd/root.go
index 0a0aa4197..ebaae7e3e 100644
--- a/client/cmd/root.go
+++ b/client/cmd/root.go
@@ -20,7 +20,6 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"google.golang.org/grpc"
- "google.golang.org/grpc/credentials/insecure"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/profilemanager"
@@ -71,12 +70,14 @@ var (
extraIFaceBlackList []string
anonymizeFlag bool
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,13 +90,16 @@ 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
},
}
)
-// Execute executes the root command.
+// Execute runs the appropriate Cobra command for the CLI.
+// If the process is the update binary it delegates to updateCmd; otherwise it runs the root command.
+// It returns any error produced during command execution.
func Execute() error {
if isUpdateBinary() {
return updateCmd.Execute()
@@ -103,6 +107,16 @@ func Execute() error {
return rootCmd.Execute()
}
+// init initialises package-level defaults and configures the root
+// Cobra command tree. Sets platform-specific config / log directory
+// paths (including legacy Wiretrustee fallbacks) and a default daemon
+// address; registers persistent CLI flags (daemon address,
+// management / admin URLs, logging, setup key (file and inline,
+// mutually exclusive), preshared key, hostname, anonymise, config
+// path); attaches top-level and nested subcommands to the root
+// command; and registers `up`-specific persistent flags (external IP
+// maps, custom DNS resolver address, Rosenpass options, auto-connect
+// disabling, lazy connection).
func init() {
defaultConfigPathDir = "/etc/netbird/"
defaultLogFileDir = "/var/log/netbird/"
@@ -129,10 +143,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")
@@ -168,10 +182,17 @@ func init() {
logCmd.AddCommand(logLevelCmd)
debugCmd.AddCommand(forCmd)
debugCmd.AddCommand(persistenceCmd)
+ debugCmd.AddCommand(debugConfigCmd)
+
+ // kubernetes commands
+ rootCmd.AddCommand(kubernetesCmd)
+ kubernetesCmd.AddCommand(kubernetesListCmd)
+ kubernetesCmd.AddCommand(kubernetesWriteKubeconfigCmd)
// profile commands
profileCmd.AddCommand(profileListCmd)
profileCmd.AddCommand(profileAddCmd)
+ profileCmd.AddCommand(profileRenameCmd)
profileCmd.AddCommand(profileRemoveCmd)
profileCmd.AddCommand(profileSelectCmd)
@@ -191,7 +212,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")
}
@@ -247,12 +269,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.
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 88121c067..9ba3bce25 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()
+ if creds == nil {
+ 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()
}
@@ -102,7 +207,7 @@ func (p *program) Stop(srv service.Service) error {
}
// Common setup for service control commands
-func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) {
+func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc, consoleLog bool) (service.Service, error) {
// rootCmd env vars are already applied by PersistentPreRunE.
SetFlagsFromEnvVars(serviceCmd)
@@ -112,8 +217,14 @@ func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel
return nil, err
}
- if err := util.InitLog(logLevel, logFiles...); err != nil {
- return nil, fmt.Errorf("init log: %w", err)
+ if consoleLog {
+ if err := util.InitLog(logLevel, util.LogConsole); err != nil {
+ return nil, fmt.Errorf("init log: %w", err)
+ }
+ } else {
+ if err := util.InitLog(logLevel, logFiles...); err != nil {
+ return nil, fmt.Errorf("init log: %w", err)
+ }
}
cfg, err := newSVCConfig()
@@ -138,10 +249,13 @@ var runCmd = &cobra.Command{
SetupCloseHandler(ctx, cancel)
SetupDebugHandler(ctx, nil, nil, nil, util.FindFirstLogPath(logFiles))
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
+ if err := validateJSONSocketFlags(); err != nil {
+ return err
+ }
return s.Run()
},
@@ -152,10 +266,13 @@ var startCmd = &cobra.Command{
Short: "starts NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
+ if err := validateJSONSocketFlags(); err != nil {
+ return err
+ }
if err := s.Start(); err != nil {
return fmt.Errorf("start service: %w", err)
@@ -170,7 +287,7 @@ var stopCmd = &cobra.Command{
Short: "stops NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
@@ -188,10 +305,13 @@ var restartCmd = &cobra.Command{
Short: "restarts NetBird service",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
if err != nil {
return err
}
+ if err := validateJSONSocketFlags(); err != nil {
+ return err
+ }
if err := s.Restart(); err != nil {
return fmt.Errorf("restart service: %w", err)
@@ -206,7 +326,7 @@ var svcStatusCmd = &cobra.Command{
Short: "shows NetBird service status",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithCancel(cmd.Context())
- s, err := setupServiceControlCommand(cmd, ctx, cancel)
+ s, err := setupServiceControlCommand(cmd, ctx, cancel, true)
if err != nil {
return err
}
diff --git a/client/cmd/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..ed1f001a7
--- /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)
+ if err != nil {
+ 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 103b3044a..c4057ed82 100644
--- a/client/cmd/status.go
+++ b/client/cmd/status.go
@@ -6,12 +6,12 @@ import (
"net"
"net/netip"
"strings"
+ "time"
"github.com/spf13/cobra"
"google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
- "github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
nbstatus "github.com/netbirdio/netbird/client/status"
"github.com/netbirdio/netbird/util"
@@ -111,10 +111,14 @@ func statusFunc(cmd *cobra.Command, args []string) error {
return nil
}
- pm := profilemanager.NewProfileManager()
- var profName string
- if activeProf, err := pm.GetActiveProfile(); err == nil {
- profName = activeProf.Name
+ // Resolve the active profile's display name via the daemon, which runs
+ // as root and can read the per-user profile files. The local profile
+ // manager only knows the active profile ID, not its display name.
+ profName := getActiveProfileName(ctx)
+
+ var sessionExpiresAt time.Time
+ if ts := resp.GetSessionExpiresAt(); ts.IsValid() {
+ sessionExpiresAt = ts.AsTime().UTC()
}
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
@@ -127,6 +131,7 @@ func statusFunc(cmd *cobra.Command, args []string) error {
IPsFilter: ipsFilterMap,
ConnectionTypeFilter: connectionTypeFilter,
ProfileName: profName,
+ SessionExpiresAt: sessionExpiresAt,
})
var statusOutputString string
switch {
@@ -167,6 +172,25 @@ func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (
return resp, nil
}
+// getActiveProfileName asks the daemon for the active profile's display
+// name. The daemon runs as root and can read the per-user profile files to
+// resolve the ID to its human-readable name. Returns an empty string on any
+// error so status output degrades gracefully.
+func getActiveProfileName(ctx context.Context) string {
+ conn, err := DialClientGRPCServer(ctx, daemonAddr)
+ if err != nil {
+ return ""
+ }
+ defer conn.Close()
+
+ resp, err := proto.NewDaemonServiceClient(conn).GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
+ if err != nil {
+ return ""
+ }
+
+ return resp.GetProfileName()
+}
+
func parseFilters() error {
switch strings.ToLower(statusFilter) {
case "", "idle", "connecting", "connected":
diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go
index c24965e8d..205327ef5 100644
--- a/client/cmd/testutil_test.go
+++ b/client/cmd/testutil_test.go
@@ -11,7 +11,7 @@ import (
"go.opentelemetry.io/otel"
"google.golang.org/grpc"
- "github.com/netbirdio/management-integrations/integrations"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
nbcache "github.com/netbirdio/netbird/management/server/cache"
@@ -109,7 +109,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
t.Fatal(err)
}
- iv, _ := integrations.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
+ iv, _ := validator.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
require.NoError(t, err)
diff --git a/client/cmd/up.go b/client/cmd/up.go
index cabd0aacf..142bcf6bd 100644
--- a/client/cmd/up.go
+++ b/client/cmd/up.go
@@ -22,6 +22,8 @@ import (
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
+ nbnet "github.com/netbirdio/netbird/client/net"
+ "github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/util"
@@ -128,16 +130,9 @@ func upFunc(cmd *cobra.Command, args []string) error {
var profileSwitched bool
// switch profile if provided
if profileName != "" {
- err = switchProfile(cmd.Context(), profileName, username.Username)
- if err != nil {
+ if err := switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username); err != nil {
return fmt.Errorf("switch profile: %v", err)
}
-
- err = pm.SwitchProfile(profileName)
- if err != nil {
- return fmt.Errorf("switch profile: %v", err)
- }
-
profileSwitched = true
}
@@ -152,6 +147,52 @@ func upFunc(cmd *cobra.Command, args []string) error {
return runInDaemonMode(ctx, cmd, pm, activeProf, profileSwitched)
}
+// switchOrCreateProfile switches the active profile to the one identified by
+// handle, creating it first when it does not exist yet. This restores the
+// pre-0.73 behaviour where `netbird up --profile ` auto-creates a
+// missing profile instead of failing.
+func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) error {
+ resolvedID, err := switchProfile(ctx, handle, username)
+ if err != nil {
+ st, ok := gstatus.FromError(err)
+ if !ok || st.Code() != codes.NotFound {
+ return err
+ }
+ // Don't fail immediately on a create error: a concurrent run may
+ // have created the profile between the NotFound above and this
+ // call, in which case the retried switch still succeeds. Only
+ // surface the create error if the switch also fails.
+ _, createErr := createProfile(ctx, handle, username)
+ if resolvedID, err = switchProfile(ctx, handle, username); err != nil {
+ if createErr != nil {
+ return fmt.Errorf("create profile: %w", createErr)
+ }
+ return err
+ }
+ }
+
+ if err := pm.SwitchProfile(resolvedID); err != nil {
+ return err
+ }
+ return nil
+}
+
+// createProfile dials the daemon and creates a new profile with the given
+// display name, returning its generated ID. Use addProfileOnDaemon directly
+// when a daemon client is already available to reuse the connection.
+func createProfile(ctx context.Context, profileName, username string) (profilemanager.ID, error) {
+ conn, err := DialClientGRPCServer(ctx, daemonAddr)
+ if err != nil {
+ //nolint
+ return "", fmt.Errorf("failed to connect to daemon error: %v\n"+
+ "If the daemon is not running please run: "+
+ "\nnetbird service install \nnetbird service start\n", err)
+ }
+ defer conn.Close()
+
+ return addProfileOnDaemon(ctx, proto.NewDaemonServiceClient(conn), profileName, username)
+}
+
func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *profilemanager.Profile) error {
// override the default profile filepath if provided
if configPath != "" {
@@ -190,7 +231,25 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr
_, _ = profilemanager.UpdateOldManagementURL(ctx, config, configFilePath)
- err = foregroundLogin(ctx, cmd, config, providedSetupKey, activeProf.Name)
+ // 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)
}
@@ -261,12 +320,12 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
}
// set the new config
- req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.Name, username.Username)
+ req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username)
if _, err := client.SetConfig(ctx, req); err != nil {
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
- log.Warnf("setConfig method is not available in the daemon")
+ log.Warnf("setConfig method is not available in the daemon: %s", st.Message())
} else {
- return fmt.Errorf("call service setConfig method: %v", err)
+ return daemonCallError("call service setConfig method", err)
}
}
@@ -289,10 +348,11 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
return fmt.Errorf("setup login request: %v", err)
}
- loginRequest.ProfileName = &activeProf.Name
+ profileID := activeProf.ID.String()
+ loginRequest.ProfileName = &profileID
loginRequest.Username = &username
- profileState, err := pm.GetProfileState(activeProf.Name)
+ profileState, err := pm.GetProfileState(activeProf.ID)
if err != nil {
log.Debugf("failed to get profile state for login hint: %v", err)
} else if profileState.Email != "" {
@@ -319,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 {
@@ -329,10 +389,10 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
}
if _, err := client.Up(ctx, &proto.UpRequest{
- ProfileName: &activeProf.Name,
+ ProfileName: &profileID,
Username: &username,
}); err != nil {
- return fmt.Errorf("call service up method: %v", err)
+ return daemonCallError("call service up method", err)
}
return nil
@@ -439,10 +499,6 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
req.DisableIpv6 = &disableIPv6
}
- if cmd.Flag(enableLazyConnectionFlag).Changed {
- req.LazyConnectionEnabled = &lazyConnEnabled
- }
-
return &req
}
@@ -560,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
}
@@ -678,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/cmd/up_daemon_test.go b/client/cmd/up_daemon_test.go
index 682a45365..ea4cdf162 100644
--- a/client/cmd/up_daemon_test.go
+++ b/client/cmd/up_daemon_test.go
@@ -29,14 +29,14 @@ func TestUpDaemon(t *testing.T) {
}
sm := profilemanager.ServiceManager{}
- err = sm.AddProfile("test1", currUser.Username)
+ created, err := sm.AddProfile("test1", currUser.Username)
if err != nil {
t.Fatalf("failed to add profile: %v", err)
return
}
err = sm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: "test1",
+ ID: created.ID,
Username: currUser.Username,
})
if err != nil {
diff --git a/client/cmd/version.go b/client/cmd/version.go
index 249854444..5deeae1a0 100644
--- a/client/cmd/version.go
+++ b/client/cmd/version.go
@@ -12,7 +12,13 @@ var (
Short: "Print the NetBird's client application version",
Run: func(cmd *cobra.Command, args []string) {
cmd.SetOut(cmd.OutOrStdout())
- cmd.Println(version.NetbirdVersion())
+ out := version.NetbirdVersion()
+ if version.IsDevelopmentVersion(out) {
+ if commit := version.NetbirdCommit(); commit != "" {
+ out += "-" + commit
+ }
+ }
+ cmd.Println(out)
},
}
)
diff --git a/client/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 8b669e547..99a6b8229 100644
--- a/client/embed/embed.go
+++ b/client/embed/embed.go
@@ -12,6 +12,7 @@ import (
"sync"
"github.com/sirupsen/logrus"
+ wgdevice "golang.zx2c4.com/wireguard/device"
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
"github.com/netbirdio/netbird/client/iface"
@@ -84,6 +85,12 @@ type Options struct {
DisableIPv6 bool
// BlockInbound blocks all inbound connections from peers
BlockInbound bool
+ // BlockLANAccess blocks the embedded peer from reaching the host's
+ // LAN (RFC 1918, link-local, loopback) when it's used as a routing
+ // peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful
+ // when the embedded client must never act as a stepping stone into
+ // the host's local network (e.g. the proxy's overlay peer).
+ BlockLANAccess bool
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
WireguardPort *int
// MTU is the MTU for the tunnel interface.
@@ -94,6 +101,26 @@ type Options struct {
MTU *uint16
// DNSLabels defines additional DNS labels configured in the peer.
DNSLabels []string
+ // Performance configures the tunnel's buffer pool cap and batch size.
+ Performance Performance
+}
+
+// Performance configures the embedded client's tunnel memory/throughput knobs.
+//
+// These settings are process-global: any non-nil field also becomes the
+// default for Clients constructed by later embed.New calls in the same
+// process. Nil fields are ignored.
+type Performance struct {
+ // PreallocatedBuffersPerPool caps the per-tunnel buffer pool. Zero
+ // leaves the pool unbounded. Lower values trade throughput for a
+ // tighter memory ceiling. May also be changed on a running Client via
+ // Client.SetPerformance, provided this field was nonzero at construction.
+ PreallocatedBuffersPerPool *uint32
+ // MaxBatchSize overrides the number of packets the tunnel reads or
+ // writes per syscall, which also bounds eager buffer allocation per
+ // worker. Zero uses the platform default. Applied at construction
+ // only; ignored by Client.SetPerformance.
+ MaxBatchSize *uint32
}
// validateCredentials checks that exactly one credential type is provided
@@ -175,6 +202,7 @@ func New(opts Options) (*Client, error) {
DisableClientRoutes: &opts.DisableClientRoutes,
DisableIPv6: &opts.DisableIPv6,
BlockInbound: &opts.BlockInbound,
+ BlockLANAccess: &opts.BlockLANAccess,
WireguardPort: opts.WireguardPort,
MTU: opts.MTU,
DNSLabels: parsedLabels,
@@ -192,6 +220,13 @@ func New(opts Options) (*Client, error) {
config.PrivateKey = opts.PrivateKey
}
+ if opts.Performance.PreallocatedBuffersPerPool != nil {
+ wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
+ }
+ if opts.Performance.MaxBatchSize != nil {
+ wgdevice.SetMaxBatchSizeOverride(*opts.Performance.MaxBatchSize)
+ }
+
return &Client{
deviceName: opts.DeviceName,
setupKey: opts.SetupKey,
@@ -244,6 +279,12 @@ func (c *Client) Start(startCtx context.Context) error {
select {
case <-startCtx.Done():
+ // 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())
}
@@ -405,6 +446,21 @@ func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession,
}, nil
}
+// IdentityForIP looks up a remote peer by its tunnel IP using the
+// embedded client's status recorder. Returns the peer's WireGuard public
+// key and FQDN. ok=false means the IP doesn't belong to an active peer
+// — offline roster peers are treated as unknown, same as foreign IPs.
+func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
+ if !ip.IsValid() || c.recorder == nil {
+ return "", "", false
+ }
+ state, found := c.recorder.PeerStateByIP(ip.String())
+ if !found {
+ return "", "", false
+ }
+ return state.PubKey, state.FQDN, true
+}
+
// Status returns the current status of the client.
func (c *Client) Status() (peer.FullStatus, error) {
c.mu.Lock()
@@ -414,7 +470,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)
}
}
@@ -473,6 +529,25 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
}
+// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool
+// takes effect, and only when it was nonzero at construction;
+// MaxBatchSize is construction-only and returns an error if set here.
+//
+// Returns ErrClientNotStarted / ErrEngineNotStarted if the Client is not
+// running yet.
+func (c *Client) SetPerformance(t Performance) error {
+ if t.MaxBatchSize != nil {
+ return errors.New("MaxBatchSize is construction-only and cannot be changed at runtime")
+ }
+ engine, err := c.getEngine()
+ if err != nil {
+ return err
+ }
+ return engine.SetPerformance(internal.Performance{
+ PreallocatedBuffersPerPool: t.PreallocatedBuffersPerPool,
+ })
+}
+
// StartCapture begins capturing packets on this client's tunnel device.
// Only one capture can be active at a time; starting a new one stops the previous.
// Call StopCapture (or CaptureSession.Stop) to end it.
diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go
new file mode 100644
index 000000000..a2f438975
--- /dev/null
+++ b/client/embed/embed_test.go
@@ -0,0 +1,168 @@
+package embed
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+
+ "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"
+ mgmt "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"
+ mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
+ "github.com/netbirdio/netbird/util"
+)
+
+const testSetupKey = "A2C8E62B-38F5-4553-B31E-DD66C696CEBB"
+
+// TestClientStartTimeoutRollback reproduces a deadlock between Engine.Start and
+// Engine.Stop. The signal endpoint accepts gRPC connections but never serves the
+// SignalExchange service, so Engine.Start parks in WaitStreamConnected while
+// holding the engine mutex. When the Start context expires, the rollback path
+// calls ConnectClient.Stop, which must not block forever acquiring that mutex.
+func TestClientStartTimeoutRollback(t *testing.T) {
+ signalAddr := startBlackholeSignal(t)
+ mgmAddr := startManagement(t, signalAddr)
+
+ wgPort := 0
+ client, err := New(Options{
+ DeviceName: "embed-rollback-test",
+ SetupKey: testSetupKey,
+ ManagementURL: "http://" + mgmAddr,
+ WireguardPort: &wgPort,
+ })
+ require.NoError(t, err, "embed client creation must succeed")
+
+ startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ startErr := make(chan error, 1)
+ go func() {
+ startErr <- client.Start(startCtx)
+ }()
+
+ select {
+ case err := <-startErr:
+ require.ErrorIs(t, err, context.DeadlineExceeded)
+ case <-time.After(60 * time.Second):
+ t.Fatal("client.Start did not return after its context expired: Engine.Stop deadlocked against Engine.Start waiting for the signal stream")
+ }
+}
+
+// startBlackholeSignal starts a gRPC server without the SignalExchange service
+// registered. Connections succeed, but the signal stream can never be
+// established, which keeps Engine.Start parked in WaitStreamConnected.
+func startBlackholeSignal(t *testing.T) string {
+ t.Helper()
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ require.NoError(t, err)
+
+ s := grpc.NewServer()
+ go func() {
+ if err := s.Serve(lis); err != nil {
+ t.Error(err)
+ }
+ }()
+ t.Cleanup(s.Stop)
+
+ return lis.Addr().String()
+}
+
+func startManagement(t *testing.T, signalAddr string) string {
+ t.Helper()
+
+ cfg := &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: signalAddr,
+ },
+ Datadir: t.TempDir(),
+ HttpConfig: nil,
+ }
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ require.NoError(t, err)
+
+ s := grpc.NewServer()
+
+ testStore, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", cfg.Datadir)
+ require.NoError(t, err)
+ t.Cleanup(cleanUp)
+
+ eventStore := &activity.InMemoryEventStore{}
+
+ permissionsManager := permissions.NewManager(testStore)
+ peersManager := peers.NewManager(testStore, permissionsManager)
+ jobManager := job.NewJobManager(nil, testStore, peersManager)
+
+ cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
+ require.NoError(t, err)
+
+ iv, err := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
+ require.NoError(t, err)
+ 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 := mgmt.NewAccountRequestBuffer(context.Background(), testStore)
+ networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg)
+ accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
+ require.NoError(t, err)
+
+ secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, cfg.TURNConfig, cfg.Relay, settingsMockManager, groupsManager)
+ require.NoError(t, err)
+
+ mgmtServer, err := nbgrpc.NewServer(cfg, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil, nil)
+ require.NoError(t, err)
+ mgmtProto.RegisterManagementServiceServer(s, mgmtServer)
+
+ go func() {
+ if err := s.Serve(lis); err != nil {
+ t.Error(err)
+ }
+ }()
+ t.Cleanup(s.Stop)
+
+ return lis.Addr().String()
+}
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 e5e19cec9..4b4cebf9c 100644
--- a/client/firewall/iptables/acl_linux.go
+++ b/client/firewall/iptables/acl_linux.go
@@ -3,6 +3,7 @@ package iptables
import (
"errors"
"fmt"
+ "maps"
"net"
"slices"
@@ -421,12 +422,17 @@ func (m *aclManager) updateState() {
currentState.Lock()
defer currentState.Unlock()
+ // Clone the maps so the persisted state holds a private snapshot. The
+ // live maps keep being mutated by subsequent rule operations while the
+ // state manager marshals the state from its periodic-save goroutine.
+ // Sharing them by reference races the two and aborts the process with a
+ // concurrent map iteration and write.
if m.v6 {
- currentState.ACLEntries6 = m.entries
- currentState.ACLIPsetStore6 = m.ipsetStore
+ currentState.ACLEntries6 = maps.Clone(m.entries)
+ currentState.ACLIPsetStore6 = m.ipsetStore.clone()
} else {
- currentState.ACLEntries = m.entries
- currentState.ACLIPsetStore = m.ipsetStore
+ currentState.ACLEntries = maps.Clone(m.entries)
+ currentState.ACLIPsetStore = m.ipsetStore.clone()
}
if err := m.stateManager.UpdateState(currentState); err != nil {
diff --git a/client/firewall/iptables/manager_linux_test.go b/client/firewall/iptables/manager_linux_test.go
index cc4bda0e0..7b0989f6c 100644
--- a/client/firewall/iptables/manager_linux_test.go
+++ b/client/firewall/iptables/manager_linux_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package iptables
import (
diff --git a/client/firewall/iptables/router_linux.go b/client/firewall/iptables/router_linux.go
index eeb86ca0d..4c1b4973a 100644
--- a/client/firewall/iptables/router_linux.go
+++ b/client/firewall/iptables/router_linux.go
@@ -4,6 +4,7 @@ package iptables
import (
"fmt"
+ "maps"
"net/netip"
"strconv"
"strings"
@@ -749,11 +750,17 @@ func (r *router) updateState() {
currentState.Lock()
defer currentState.Unlock()
+ // Clone the rule map so the persisted state holds a private snapshot. The
+ // live map keeps being mutated by subsequent rule operations while the
+ // state manager marshals the state from its periodic-save goroutine.
+ // Sharing it by reference races the two and aborts the process with a
+ // concurrent map iteration and write. The ipset counter guards itself
+ // during marshaling, so it can be shared directly.
if r.v6 {
- currentState.RouteRules6 = r.rules
+ currentState.RouteRules6 = maps.Clone(r.rules)
currentState.RouteIPsetCounter6 = r.ipsetCounter
} else {
- currentState.RouteRules = r.rules
+ currentState.RouteRules = maps.Clone(r.rules)
currentState.RouteIPsetCounter = r.ipsetCounter
}
diff --git a/client/firewall/iptables/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/iptables/rulestore_linux.go b/client/firewall/iptables/rulestore_linux.go
index 004c512a4..a6d36540e 100644
--- a/client/firewall/iptables/rulestore_linux.go
+++ b/client/firewall/iptables/rulestore_linux.go
@@ -1,6 +1,9 @@
package iptables
-import "encoding/json"
+import (
+ "encoding/json"
+ "maps"
+)
type ipList struct {
ips map[string]struct{}
@@ -19,6 +22,14 @@ func (s *ipList) addIP(ip string) {
s.ips[ip] = struct{}{}
}
+// clone returns a deep copy of the ipList with its own ips map.
+func (s *ipList) clone() *ipList {
+ if s == nil {
+ return nil
+ }
+ return &ipList{ips: maps.Clone(s.ips)}
+}
+
// MarshalJSON implements json.Marshaler
func (s *ipList) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
@@ -55,6 +66,19 @@ func newIpsetStore() *ipsetStore {
}
}
+// clone returns a deep copy of the ipsetStore with its own ipsets map and
+// independent ipList entries.
+func (s *ipsetStore) clone() *ipsetStore {
+ if s == nil {
+ return nil
+ }
+ cloned := &ipsetStore{ipsets: make(map[string]*ipList, len(s.ipsets))}
+ for name, list := range s.ipsets {
+ cloned.ipsets[name] = list.clone()
+ }
+ return cloned
+}
+
func (s *ipsetStore) ipset(ipsetName string) (*ipList, bool) {
r, ok := s.ipsets[ipsetName]
return r, ok
diff --git a/client/firewall/nftables/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_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 27f6e0a68..cd409559e 100644
--- a/client/firewall/nftables/router_linux.go
+++ b/client/firewall/nftables/router_linux.go
@@ -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)
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/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/forwarder/icmp.go b/client/firewall/uspfilter/forwarder/icmp.go
index d6d4e705e..94a50570f 100644
--- a/client/firewall/uspfilter/forwarder/icmp.go
+++ b/client/firewall/uspfilter/forwarder/icmp.go
@@ -362,6 +362,10 @@ func (f *Forwarder) injectICMPv6Reply(id stack.TransportEndpointID, icmpPayload
return 0
}
+ if pc := f.endpoint.capture.Load(); pc != nil {
+ (*pc).Offer(fullPacket, true)
+ }
+
return len(fullPacket)
}
diff --git a/client/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/iface/bind/ice_bind.go b/client/iface/bind/ice_bind.go
index bf79ecd79..156450c61 100644
--- a/client/iface/bind/ice_bind.go
+++ b/client/iface/bind/ice_bind.go
@@ -41,7 +41,6 @@ type ICEBind struct {
*wgConn.StdNetBind
transportNet transport.Net
- filterFn udpmux.FilterFn
address wgaddr.Address
mtu uint16
@@ -61,12 +60,11 @@ type ICEBind struct {
ipv6Conn *net.UDPConn
}
-func NewICEBind(transportNet transport.Net, filterFn udpmux.FilterFn, address wgaddr.Address, mtu uint16) *ICEBind {
+func NewICEBind(transportNet transport.Net, address wgaddr.Address, mtu uint16) *ICEBind {
b, _ := wgConn.NewStdNetBind().(*wgConn.StdNetBind)
ib := &ICEBind{
StdNetBind: b,
transportNet: transportNet,
- filterFn: filterFn,
address: address,
mtu: mtu,
endpoints: make(map[netip.Addr]net.Conn),
@@ -265,7 +263,6 @@ func (s *ICEBind) createOrUpdateMux() {
udpmux.UniversalUDPMuxParams{
UDPConn: muxConn,
Net: s.transportNet,
- FilterFn: s.filterFn,
WGAddress: s.address,
MTU: s.mtu,
},
diff --git a/client/iface/bind/ice_bind_test.go b/client/iface/bind/ice_bind_test.go
index f49e68508..0b8db7640 100644
--- a/client/iface/bind/ice_bind_test.go
+++ b/client/iface/bind/ice_bind_test.go
@@ -289,7 +289,7 @@ func setupICEBind(t *testing.T) *ICEBind {
IP: netip.MustParseAddr("100.64.0.1"),
Network: netip.MustParsePrefix("100.64.0.0/10"),
}
- return NewICEBind(transportNet, nil, address, 1280)
+ return NewICEBind(transportNet, address, 1280)
}
func createDualStackConns(t *testing.T) (*net.UDPConn, *net.UDPConn) {
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.go b/client/iface/device/device_filter.go
index fc1c65efa..7d7493835 100644
--- a/client/iface/device/device_filter.go
+++ b/client/iface/device/device_filter.go
@@ -1,10 +1,13 @@
package device
import (
+ "fmt"
"net/netip"
+ "runtime/debug"
"sync"
"sync/atomic"
+ log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/tun"
)
@@ -41,10 +44,13 @@ type PacketCapture interface {
type FilteredDevice struct {
tun.Device
- filter PacketFilter
- capture atomic.Pointer[PacketCapture]
- mutex sync.RWMutex
- closeOnce sync.Once
+ filter PacketFilter
+ capture atomic.Pointer[PacketCapture]
+ // panicHandler is invoked after a panic in the underlying device is
+ // recovered in Read or Write.
+ panicHandler atomic.Pointer[func()]
+ mutex sync.RWMutex
+ closeOnce sync.Once
}
// newDeviceFilter constructor function
@@ -70,7 +76,7 @@ func (d *FilteredDevice) Close() error {
// Read wraps read method with filtering feature
func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, err error) {
- if n, err = d.Device.Read(bufs, sizes, offset); err != nil {
+ if n, err = d.deviceRead(bufs, sizes, offset); err != nil {
return 0, err
}
@@ -112,7 +118,7 @@ func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) {
d.mutex.RUnlock()
if filter == nil {
- return d.Device.Write(bufs, offset)
+ return d.deviceWrite(bufs, offset)
}
filteredBufs := make([][]byte, 0, len(bufs))
@@ -125,9 +131,44 @@ func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) {
}
}
- n, err := d.Device.Write(filteredBufs, offset)
- n += dropped
- return n, err
+ n, err := d.deviceWrite(filteredBufs, offset)
+ if err != nil {
+ return n, err
+ }
+ return n + dropped, nil
+}
+
+// deviceRead calls the underlying device Read, recovering from panics in the
+// wintun read path and converting them into errors.
+func (d *FilteredDevice) deviceRead(bufs [][]byte, sizes []int, offset int) (n int, err error) {
+ defer d.recoverFromPanic("read", &n, &err)
+ return d.Device.Read(bufs, sizes, offset)
+}
+
+// deviceWrite calls the underlying device Write, recovering from panics in the
+// wintun write path and converting them into errors.
+func (d *FilteredDevice) deviceWrite(bufs [][]byte, offset int) (n int, err error) {
+ defer d.recoverFromPanic("write", &n, &err)
+ return d.Device.Write(bufs, offset)
+}
+
+// recoverFromPanic converts a panic in the underlying device into a regular
+// error and invokes the registered panic handler. The wintun read path is
+// known to panic on zero-length packets that third-party filter drivers can
+// place in the ring.
+func (d *FilteredDevice) recoverFromPanic(op string, n *int, err *error) {
+ r := recover()
+ if r == nil {
+ return
+ }
+
+ log.Errorf("recovered panic in tun device %s: %v\n%s", op, r, debug.Stack())
+ *n = 0
+ *err = fmt.Errorf("tun device %s panic: %v", op, r)
+
+ if handler := d.panicHandler.Load(); handler != nil {
+ (*handler)()
+ }
}
// SetFilter sets packet filter to device
@@ -137,6 +178,17 @@ func (d *FilteredDevice) SetFilter(filter PacketFilter) {
d.mutex.Unlock()
}
+// SetPanicHandler registers a handler invoked after a recovered panic in Read
+// or Write. The device is unusable after such a panic; the handler should
+// trigger recreation of the interface. Pass nil to remove.
+func (d *FilteredDevice) SetPanicHandler(handler func()) {
+ if handler == nil {
+ d.panicHandler.Store(nil)
+ return
+ }
+ d.panicHandler.Store(&handler)
+}
+
// SetCapture sets or clears the packet capture sink. Pass nil to disable.
// Uses atomic store so the hot path (Read/Write) is a single pointer load
// with no locking overhead when capture is off.
diff --git a/client/iface/device/device_filter_test.go b/client/iface/device/device_filter_test.go
index 8fb16ca8d..0d86c9323 100644
--- a/client/iface/device/device_filter_test.go
+++ b/client/iface/device/device_filter_test.go
@@ -221,3 +221,60 @@ func TestDeviceWrapperRead(t *testing.T) {
}
})
}
+
+func TestDeviceWrapperReadPanic(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ tun := mocks.NewMockDevice(ctrl)
+ tun.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any()).
+ DoAndReturn(func(bufs [][]byte, sizes []int, offset int) (int, error) {
+ // Reproduce the wintun zero-length packet panic (index out of range).
+ packet := make([]byte, 0)
+ return int(packet[0]), nil
+ })
+
+ wrapped := newDeviceFilter(tun)
+
+ handlerCalled := false
+ wrapped.SetPanicHandler(func() { handlerCalled = true })
+
+ n, err := wrapped.Read([][]byte{{}}, []int{0}, 0)
+ if err == nil {
+ t.Errorf("expected error from recovered panic, got nil")
+ }
+ if n != 0 {
+ t.Errorf("expected n=0, got %d", n)
+ }
+ if !handlerCalled {
+ t.Errorf("expected panic handler to be called")
+ }
+}
+
+func TestDeviceWrapperWritePanic(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ tun := mocks.NewMockDevice(ctrl)
+ tun.EXPECT().Write(gomock.Any(), gomock.Any()).
+ DoAndReturn(func(bufs [][]byte, offset int) (int, error) {
+ packet := make([]byte, 0)
+ return int(packet[0]), nil
+ })
+
+ wrapped := newDeviceFilter(tun)
+
+ handlerCalled := false
+ wrapped.SetPanicHandler(func() { handlerCalled = true })
+
+ n, err := wrapped.Write([][]byte{{0x45, 0x00}}, 0)
+ if err == nil {
+ t.Errorf("expected error from recovered panic, got nil")
+ }
+ if n != 0 {
+ t.Errorf("expected n=0, got %d", n)
+ }
+ if !handlerCalled {
+ t.Errorf("expected panic handler to be called")
+ }
+}
diff --git a/client/iface/device/device_kernel_unix.go b/client/iface/device/device_kernel_unix.go
index 25c4148a6..3c429fb96 100644
--- a/client/iface/device/device_kernel_unix.go
+++ b/client/iface/device/device_kernel_unix.go
@@ -32,8 +32,6 @@ type TunKernelDevice struct {
link *wgLink
udpMuxConn net.PacketConn
udpMux *udpmux.UniversalUDPMuxDefault
-
- filterFn udpmux.FilterFn
}
func NewKernelDevice(name string, address wgaddr.Address, wgPort int, key string, mtu uint16, transportNet transport.Net) *TunKernelDevice {
@@ -104,7 +102,6 @@ func (t *TunKernelDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) {
bindParams := udpmux.UniversalUDPMuxParams{
UDPConn: nbnet.WrapPacketConn(rawSock),
Net: t.transportNet,
- FilterFn: t.filterFn,
WGAddress: t.address,
MTU: t.mtu,
}
diff --git a/client/iface/iface.go b/client/iface/iface.go
index 78c5080e7..247f421a2 100644
--- a/client/iface/iface.go
+++ b/client/iface/iface.go
@@ -63,7 +63,6 @@ type WGIFaceOpts struct {
MTU uint16
MobileArgs *device.MobileIFaceArguments
TransportNet transport.Net
- FilterFn udpmux.FilterFn
DisableDNS bool
}
diff --git a/client/iface/iface_new.go b/client/iface/iface_new.go
index 28f350e3f..96a0e670f 100644
--- a/client/iface/iface_new.go
+++ b/client/iface/iface_new.go
@@ -11,7 +11,7 @@ import (
// NewWGIFace Creates a new WireGuard interface instance
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
var tun WGTunDevice
if netstack.IsEnabled() {
diff --git a/client/iface/iface_new_android.go b/client/iface/iface_new_android.go
index e28dcc0de..ce8b4da23 100644
--- a/client/iface/iface_new_android.go
+++ b/client/iface/iface_new_android.go
@@ -9,7 +9,7 @@ import (
// NewWGIFace Creates a new WireGuard interface instance
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
if netstack.IsEnabled() {
wgIFace := &WGIface{
diff --git a/client/iface/iface_new_ios.go b/client/iface/iface_new_ios.go
index 41e0022b2..cedd55ce2 100644
--- a/client/iface/iface_new_ios.go
+++ b/client/iface/iface_new_ios.go
@@ -10,7 +10,7 @@ import (
// NewWGIFace Creates a new WireGuard interface instance
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
wgIFace := &WGIface{
tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunFd),
diff --git a/client/iface/iface_new_linux.go b/client/iface/iface_new_linux.go
index 65ce67e88..2465130e6 100644
--- a/client/iface/iface_new_linux.go
+++ b/client/iface/iface_new_linux.go
@@ -14,7 +14,7 @@ import (
// NewWGIFace Creates a new WireGuard interface instance
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
if netstack.IsEnabled() {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
return &WGIface{
tun: device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()),
userspaceBind: true,
@@ -30,7 +30,7 @@ func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
}
if device.ModuleTunIsLoaded() {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
return &WGIface{
tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind),
userspaceBind: true,
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/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/udpmux/universal.go b/client/iface/udpmux/universal.go
index 89a7eefb9..77e1b1b35 100644
--- a/client/iface/udpmux/universal.go
+++ b/client/iface/udpmux/universal.go
@@ -8,8 +8,6 @@ import (
"context"
"fmt"
"net"
- "net/netip"
- "sync"
"time"
log "github.com/sirupsen/logrus"
@@ -22,10 +20,6 @@ import (
"github.com/netbirdio/netbird/client/iface/wgaddr"
)
-// FilterFn is a function that filters out candidates based on the address.
-// If it returns true, the address is to be filtered. It also returns the prefix of matching route.
-type FilterFn func(address netip.Addr) (bool, netip.Prefix, error)
-
// UniversalUDPMuxDefault handles STUN and TURN servers packets by wrapping the original UDPConn
// It then passes packets to the UDPMux that does the actual connection muxing.
type UniversalUDPMuxDefault struct {
@@ -43,7 +37,6 @@ type UniversalUDPMuxParams struct {
UDPConn net.PacketConn
XORMappedAddrCacheTTL time.Duration
Net transport.Net
- FilterFn FilterFn
WGAddress wgaddr.Address
MTU uint16
}
@@ -68,7 +61,6 @@ func NewUniversalUDPMuxDefault(params UniversalUDPMuxParams) *UniversalUDPMuxDef
PacketConn: params.UDPConn,
mux: m,
logger: params.Logger,
- filterFn: params.FilterFn,
address: params.WGAddress,
}
@@ -115,15 +107,12 @@ func (m *UniversalUDPMuxDefault) ReadFromConn(ctx context.Context) {
}
}
-// UDPConn is a wrapper around UDPMux conn that overrides ReadFrom and handles STUN/TURN packets
+// UDPConn is a wrapper around UDPMux conn that overrides WriteTo to drop packets destined for the overlay subnet.
type UDPConn struct {
net.PacketConn
- mux *UniversalUDPMuxDefault
- logger logging.LeveledLogger
- filterFn FilterFn
- // TODO: reset cache on route changes
- addrCache sync.Map
- address wgaddr.Address
+ mux *UniversalUDPMuxDefault
+ logger logging.LeveledLogger
+ address wgaddr.Address
}
// GetPacketConn returns the underlying PacketConn
@@ -132,65 +121,16 @@ func (u *UDPConn) GetPacketConn() net.PacketConn {
}
func (u *UDPConn) WriteTo(b []byte, addr net.Addr) (int, error) {
- if u.filterFn == nil {
+ udpAddr, ok := addr.(*net.UDPAddr)
+ if !ok {
return u.PacketConn.WriteTo(b, addr)
}
-
- if isRouted, found := u.addrCache.Load(addr.String()); found {
- return u.handleCachedAddress(isRouted.(bool), b, addr)
- }
-
- return u.handleUncachedAddress(b, addr)
-}
-
-func (u *UDPConn) handleCachedAddress(isRouted bool, b []byte, addr net.Addr) (int, error) {
- if isRouted {
- return 0, fmt.Errorf("address %s is part of a routed network, refusing to write", addr)
- }
- return u.PacketConn.WriteTo(b, addr)
-}
-
-func (u *UDPConn) handleUncachedAddress(b []byte, addr net.Addr) (int, error) {
- if err := u.performFilterCheck(addr); err != nil {
- return 0, err
- }
- return u.PacketConn.WriteTo(b, addr)
-}
-
-func (u *UDPConn) performFilterCheck(addr net.Addr) error {
- host, err := getHostFromAddr(addr)
- if err != nil {
- log.Errorf("Failed to get host from address %s: %v", addr, err)
- return nil
- }
-
- a, err := netip.ParseAddr(host)
- if err != nil {
- log.Errorf("Failed to parse address %s: %v", addr, err)
- return nil
- }
-
- if u.address.Network.Contains(a) {
+ dst := udpAddr.AddrPort().Addr().Unmap()
+ if (u.address.Network.IsValid() && u.address.Network.Contains(dst)) || (u.address.IPv6Net.IsValid() && u.address.IPv6Net.Contains(dst)) {
log.Warnf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
- return fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
+ return 0, fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
}
-
- if isRouted, prefix, err := u.filterFn(a); err != nil {
- log.Errorf("Failed to check if address %s is routed: %v", addr, err)
- } else {
- u.addrCache.Store(addr.String(), isRouted)
- if isRouted {
- // Extra log, as the error only shows up with ICE logging enabled
- log.Infof("address %s is part of routed network %s, refusing to write", addr, prefix)
- return fmt.Errorf("address %s is part of routed network %s, refusing to write", addr, prefix)
- }
- }
- return nil
-}
-
-func getHostFromAddr(addr net.Addr) (string, error) {
- host, _, err := net.SplitHostPort(addr.String())
- return host, err
+ return u.PacketConn.WriteTo(b, addr)
}
// GetSharedConn returns the shared udp conn
@@ -225,6 +165,13 @@ func (m *UniversalUDPMuxDefault) HandleSTUNMessage(msg *stun.Message, addr net.A
return nil
}
+ src := udpAddr.AddrPort().Addr().Unmap()
+ wg := m.params.WGAddress
+ if (wg.Network.IsValid() && wg.Network.Contains(src)) || (wg.IPv6Net.IsValid() && wg.IPv6Net.Contains(src)) {
+ log.Debugf("dropping STUN message from overlay source %s", udpAddr)
+ return nil
+ }
+
if m.isXORMappedResponse(msg, udpAddr.String()) {
err := m.handleXORMappedResponse(udpAddr, msg)
if err != nil {
diff --git a/client/iface/wgproxy/bind/proxy.go b/client/iface/wgproxy/bind/proxy.go
index be6f3806e..be690ed4f 100644
--- a/client/iface/wgproxy/bind/proxy.go
+++ b/client/iface/wgproxy/bind/proxy.go
@@ -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/wrapper.go b/client/iface/wgproxy/ebpf/wrapper.go
index 6e80945c4..a6156a661 100644
--- a/client/iface/wgproxy/ebpf/wrapper.go
+++ b/client/iface/wgproxy/ebpf/wrapper.go
@@ -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 {
diff --git a/client/iface/wgproxy/proxy.go b/client/iface/wgproxy/proxy.go
index 3c8dfd30e..40346bc15 100644
--- a/client/iface/wgproxy/proxy.go
+++ b/client/iface/wgproxy/proxy.go
@@ -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 dd24d1cdc..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
@@ -66,7 +66,7 @@ func seedProxyForProxyCloseByRemoteConn() ([]proxyInstance, error) {
if err != nil {
return nil, err
}
- iceBind := bind.NewICEBind(nil, nil, wgAddress, 1280)
+ iceBind := bind.NewICEBind(nil, wgAddress, 1280)
endpointAddress := &net.UDPAddr{
IP: net.IPv4(10, 0, 0, 1),
Port: 1234,
diff --git a/client/iface/wgproxy/proxy_seed_test.go b/client/iface/wgproxy/proxy_seed_test.go
index ad375ccde..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
@@ -22,7 +22,7 @@ func seedProxyForProxyCloseByRemoteConn() ([]proxyInstance, error) {
if err != nil {
return nil, err
}
- iceBind := bind.NewICEBind(nil, nil, wgAddress, 1280)
+ iceBind := bind.NewICEBind(nil, wgAddress, 1280)
endpointAddress := &net.UDPAddr{
IP: net.IPv4(10, 0, 0, 1),
Port: 1234,
diff --git a/client/iface/wgproxy/redirect_test.go b/client/iface/wgproxy/redirect_test.go
index b52eead25..135970838 100644
--- a/client/iface/wgproxy/redirect_test.go
+++ b/client/iface/wgproxy/redirect_test.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && privileged
package wgproxy
@@ -26,64 +26,6 @@ func compareUDPAddr(addr1, addr2 net.Addr) bool {
return udpAddr1.IP.Equal(udpAddr2.IP) && udpAddr1.Port == udpAddr2.Port
}
-// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses
-func TestRedirectAs_eBPF_IPv4(t *testing.T) {
- wgPort := 51850
- ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
- if err := ebpfProxy.Listen(); err != nil {
- t.Fatalf("failed to initialize ebpf proxy: %v", err)
- }
- defer func() {
- if err := ebpfProxy.Free(); err != nil {
- t.Errorf("failed to free ebpf proxy: %v", err)
- }
- }()
-
- proxy := ebpf.NewProxyWrapper(ebpfProxy)
-
- // NetBird UDP address of the remote peer
- nbAddr := &net.UDPAddr{
- IP: net.ParseIP("100.108.111.177"),
- Port: 38746,
- }
-
- p2pEndpoint := &net.UDPAddr{
- IP: net.ParseIP("192.168.0.56"),
- Port: 51820,
- }
-
- testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
-}
-
-// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses
-func TestRedirectAs_eBPF_IPv6(t *testing.T) {
- wgPort := 51851
- ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
- if err := ebpfProxy.Listen(); err != nil {
- t.Fatalf("failed to initialize ebpf proxy: %v", err)
- }
- defer func() {
- if err := ebpfProxy.Free(); err != nil {
- t.Errorf("failed to free ebpf proxy: %v", err)
- }
- }()
-
- proxy := ebpf.NewProxyWrapper(ebpfProxy)
-
- // NetBird UDP address of the remote peer
- nbAddr := &net.UDPAddr{
- IP: net.ParseIP("100.108.111.177"),
- Port: 38746,
- }
-
- p2pEndpoint := &net.UDPAddr{
- IP: net.ParseIP("fe80::56"),
- Port: 51820,
- }
-
- testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
-}
-
// TestRedirectAs_UDP_IPv4 tests RedirectAs with UDP proxy using IPv4 addresses
func TestRedirectAs_UDP_IPv4(t *testing.T) {
wgPort := 51852
@@ -256,6 +198,64 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint *
}
}
+// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses
+func TestRedirectAs_eBPF_IPv4(t *testing.T) {
+ wgPort := 51850
+ ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
+ if err := ebpfProxy.Listen(); err != nil {
+ t.Fatalf("failed to initialize ebpf proxy: %v", err)
+ }
+ defer func() {
+ if err := ebpfProxy.Free(); err != nil {
+ t.Errorf("failed to free ebpf proxy: %v", err)
+ }
+ }()
+
+ proxy := ebpf.NewProxyWrapper(ebpfProxy)
+
+ // NetBird UDP address of the remote peer
+ nbAddr := &net.UDPAddr{
+ IP: net.ParseIP("100.108.111.177"),
+ Port: 38746,
+ }
+
+ p2pEndpoint := &net.UDPAddr{
+ IP: net.ParseIP("192.168.0.56"),
+ Port: 51820,
+ }
+
+ testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
+}
+
+// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses
+func TestRedirectAs_eBPF_IPv6(t *testing.T) {
+ wgPort := 51851
+ ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
+ if err := ebpfProxy.Listen(); err != nil {
+ t.Fatalf("failed to initialize ebpf proxy: %v", err)
+ }
+ defer func() {
+ if err := ebpfProxy.Free(); err != nil {
+ t.Errorf("failed to free ebpf proxy: %v", err)
+ }
+ }()
+
+ proxy := ebpf.NewProxyWrapper(ebpfProxy)
+
+ // NetBird UDP address of the remote peer
+ nbAddr := &net.UDPAddr{
+ IP: net.ParseIP("100.108.111.177"),
+ Port: 38746,
+ }
+
+ p2pEndpoint := &net.UDPAddr{
+ IP: net.ParseIP("fe80::56"),
+ Port: 51820,
+ }
+
+ testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
+}
+
// TestRedirectAs_Multiple_Switches tests switching between multiple endpoints
func TestRedirectAs_Multiple_Switches(t *testing.T) {
wgPort := 51856
diff --git a/client/iface/wgproxy/udp/proxy.go b/client/iface/wgproxy/udp/proxy.go
index 6069d1960..783843aba 100644
--- a/client/iface/wgproxy/udp/proxy.go
+++ b/client/iface/wgproxy/udp/proxy.go
@@ -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..71699071b 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"
@@ -79,8 +79,6 @@ ShowInstDetails Show
!insertmacro MUI_PAGE_DIRECTORY
-Page custom AutostartPage AutostartPageLeave
-
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
@@ -97,40 +95,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 +171,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,17 +228,12 @@ 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}
+; Autostart is owned by the UI's per-user setting (HKCU\...\Run via Wails),
+; not the installer. Drop the machine-wide entry older installers wrote so the
+; toggle is the single source of truth. HKCU is left untouched -- it may hold
+; the user's own toggle state, which must survive upgrades.
+DetailPrint "Removing installer-managed autostart registry entry if present..."
+DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
EnVar::SetHKLM
EnVar::AddValueEx "path" "$INSTDIR"
@@ -280,6 +243,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,11 +299,14 @@ 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..."
+; Remove autostart registry entries
+DetailPrint "Removing autostart registry entries if they exist..."
+; Legacy machine-wide entry written by older installers.
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
-; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
+; Per-user entry the UI toggle writes via Wails (value name is the lowercase
+; app-name slug). Uninstall removes the app, so drop it too.
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
+DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "netbird"
; Handle data deletion based on checkbox
DetailPrint "Checking if user requested data deletion..."
@@ -326,9 +329,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..d9b179457 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) {
diff --git a/client/internal/acl/manager_test.go b/client/internal/acl/manager_test.go
index 408ed992f..968654ae9 100644
--- a/client/internal/acl/manager_test.go
+++ b/client/internal/acl/manager_test.go
@@ -1,6 +1,7 @@
package acl
import (
+ "fmt"
"net/netip"
"testing"
@@ -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),
+ 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/auth/auth.go b/client/internal/auth/auth.go
index afc8ee77f..153727a6c 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 {
@@ -184,6 +204,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 +351,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 +503,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/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 2e16836d8..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 {
@@ -360,7 +375,13 @@ func isRedirectURLPortUsed(redirectURL string, excludedRanges []excludedPortRang
return true
}
- addr := fmt.Sprintf(":%s", port)
+ // FreeBSD 15 disables connecting to INADDR_ANY (0.0.0.0) as a localhost
+ // alias by default, ensure explicit ip for localhost.
+ host := parsedURL.Hostname()
+ if host == "" {
+ host = "127.0.0.1"
+ }
+ addr := net.JoinHostPort(host, port)
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return false
diff --git a/client/internal/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 ea884818f..ceb39419e 100644
--- a/client/internal/connect.go
+++ b/client/internal/connect.go
@@ -6,10 +6,12 @@ import (
"fmt"
"net"
"net/netip"
+ "path/filepath"
"runtime"
"runtime/debug"
"strings"
"sync"
+ "sync/atomic"
"time"
"github.com/cenkalti/backoff/v4"
@@ -25,12 +27,14 @@ 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"
@@ -53,6 +57,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
@@ -69,8 +77,14 @@ func NewConnectClient(
config *profilemanager.Config,
statusRecorder *peer.Status,
) *ConnectClient {
+ // Derive the run context here so Stop owns the cancel that unblocks the run
+ // loop. runCancel is set once at construction, so Stop can call it without
+ // racing the run loop's startup. Callers therefore need not cancel before Stop.
+ runCtx, runCancel := context.WithCancel(ctx)
return &ConnectClient{
- ctx: ctx,
+ ctx: runCtx,
+ runCancel: runCancel,
+ runExited: make(chan struct{}),
config: config,
statusRecorder: statusRecorder,
engineMutex: sync.Mutex{},
@@ -99,11 +113,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,
@@ -117,20 +134,31 @@ func (c *ConnectClient) RunOniOS(
networkChangeListener listener.NetworkChangeListener,
dnsManager dns.IosDnsManager,
stateFilePath string,
+ cacheDir string,
+ logFilePath string,
) error {
// 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,
}
- return c.run(mobileDependency, nil, "")
+ return c.run(mobileDependency, nil, logFilePath)
}
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
@@ -236,7 +264,10 @@ 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 {
@@ -256,6 +287,15 @@ 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)
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)
@@ -286,7 +326,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)
@@ -294,6 +334,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(),
@@ -346,6 +390,11 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
return wrapErr(err)
}
engineConfig.TempDir = mobileDependency.TempDir
+ // Leave StateDir empty when there is no state path so a disk-backed
+ // syncstore falls back to os.TempDir() instead of filepath.Dir("") == ".".
+ if path != "" {
+ engineConfig.StateDir = filepath.Dir(path)
+ }
relayManager := relayClient.NewManager(engineCtx, relayURLs, myPrivateKey.PublicKey().String(), engineConfig.MTU)
c.statusRecorder.SetRelayMgr(relayManager)
@@ -374,6 +423,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
StateManager: stateManager,
UpdateManager: c.updateManager,
ClientMetrics: c.clientMetrics,
+ MetricsCtx: c.ctx,
}, mobileDependency)
engine.SetSyncResponsePersistence(c.persistSyncResponse)
c.engine = engine
@@ -384,6 +434,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)
@@ -401,14 +455,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()
@@ -424,12 +474,16 @@ 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 {
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
}
@@ -507,11 +561,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
}
@@ -576,8 +628,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,
@@ -651,7 +704,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 ebaf71b21..0f81844f6 100644
--- a/client/internal/debug/debug.go
+++ b/client/internal/debug/debug.go
@@ -232,6 +232,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 +247,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,11 +269,16 @@ type BundleGenerator struct {
statusRecorder *peer.Status
syncResponse *mgmProto.SyncResponse
logPath string
+ uiLogPath string
+ uiLogOpener LogOpener
tempDir string
+ statePath string
cpuProfile []byte
capturePath string
refreshStatus func() // Optional callback to refresh status before bundle generation
clientMetrics MetricsExporter
+ daemonVersion string
+ cliVersion string
anonymize bool
includeSystemInfo bool
@@ -273,11 +298,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.
- CPUProfile []byte
- CapturePath string
- RefreshStatus func()
- ClientMetrics MetricsExporter
+ 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 {
@@ -287,6 +322,11 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
logFileCount = 1
}
+ uiLogOpener := deps.UILogOpener
+ if uiLogOpener == nil {
+ uiLogOpener = openLogFile
+ }
+
return &BundleGenerator{
anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()),
@@ -294,11 +334,16 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
statusRecorder: deps.StatusRecorder,
syncResponse: deps.SyncResponse,
logPath: deps.LogPath,
+ uiLogPath: deps.UILogPath,
+ uiLogOpener: uiLogOpener,
tempDir: deps.TempDir,
+ statePath: deps.StatePath,
cpuProfile: deps.CPUProfile,
capturePath: deps.CapturePath,
refreshStatus: deps.RefreshStatus,
clientMetrics: deps.ClientMetrics,
+ daemonVersion: deps.DaemonVersion,
+ cliVersion: deps.CliVersion,
anonymize: cfg.Anonymize,
includeSystemInfo: cfg.IncludeSystemInfo,
@@ -402,6 +447,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)
}
@@ -457,11 +506,12 @@ 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,
+ Anonymize: g.anonymize,
+ ProfileName: profName,
+ DaemonVersion: g.daemonVersion,
})
+ overview.CliVersion = g.cliVersion
statusOutput := overview.FullDetailSummary()
statusReader := strings.NewReader(statusOutput)
@@ -508,6 +558,14 @@ func (g *BundleGenerator) addConfig() error {
}
}
+ // Surface the set of MDM-enforced keys so a support engineer reading
+ // the bundle can tell which field values are user-set vs MDM-overridden.
+ // Same semantics as the mDMManagedFields list returned by the
+ // GetConfig RPC consumed by `netbird debug config`.
+ if managed := g.internalConfig.Policy().ManagedKeys(); len(managed) > 0 {
+ configContent.WriteString(fmt.Sprintf("MDMManagedFields: %v\n", managed))
+ }
+
configReader := strings.NewReader(configContent.String())
if err := g.addFileToZip(configReader, "config.txt"); err != nil {
return fmt.Errorf("add config file to zip: %w", err)
@@ -644,6 +702,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))
@@ -662,7 +721,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))
}
@@ -798,6 +857,8 @@ func (g *BundleGenerator) addSyncResponse() error {
AllowPartial: true,
}
+ g.maskSecrets()
+
jsonBytes, err := options.Marshal(g.syncResponse)
if err != nil {
return fmt.Errorf("generate json: %w", err)
@@ -810,9 +871,33 @@ func (g *BundleGenerator) addSyncResponse() error {
return nil
}
+func (g *BundleGenerator) maskSecrets() {
+ if g.syncResponse == nil || g.syncResponse.NetbirdConfig == nil {
+ return
+ }
+
+ if g.syncResponse.NetbirdConfig.Flow != nil {
+ g.syncResponse.NetbirdConfig.Flow.TokenPayload = maskedValue
+
+ }
+
+ if g.syncResponse.NetbirdConfig.Relay != nil {
+ g.syncResponse.NetbirdConfig.Relay.TokenPayload = maskedValue
+ }
+
+ for i := range g.syncResponse.NetbirdConfig.Turns {
+ if g.syncResponse.NetbirdConfig.Turns[i] != nil {
+ g.syncResponse.NetbirdConfig.Turns[i].Password = maskedValue
+ }
+ }
+}
+
func (g *BundleGenerator) addStateFile() error {
- sm := profilemanager.NewServiceManager("")
- path := sm.GetStatePath()
+ path := g.statePath
+ if path == "" {
+ sm := profilemanager.NewServiceManager("")
+ path = sm.GetStatePath()
+ }
if path == "" {
return nil
}
@@ -937,11 +1022,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)
@@ -950,20 +1035,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)
}
@@ -988,8 +1092,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)
}
@@ -1033,13 +1137,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
}
- pattern := filepath.Join(logDir, "client-*.log.gz")
+ // 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)
@@ -1072,7 +1179,12 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
for i := 0; i < maxFiles; i++ {
name := filepath.Base(files[i])
- if err := g.addSingleLogFileGz(files[i], name); err != nil {
+ if strings.HasSuffix(name, ".gz") {
+ err = g.addSingleLogFileGz(open, files[i], name)
+ } else {
+ err = g.addSingleLogfile(open, files[i], name)
+ }
+ if err != nil {
log.Warnf("failed to add rotated log %s: %v", name, err)
}
}
diff --git a/client/internal/debug/debug_ios.go b/client/internal/debug/debug_ios.go
new file mode 100644
index 000000000..001d64241
--- /dev/null
+++ b/client/internal/debug/debug_ios.go
@@ -0,0 +1,36 @@
+//go:build ios
+
+package debug
+
+import (
+ "path/filepath"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// swiftLogFile is the Swift app log written by the iOS app into the same log
+// directory as the Go client log, so it can be collected into the bundle.
+const swiftLogFile = "swift-log.log"
+
+// addPlatformLog collects logs for the iOS debug bundle. iOS has no logcat or
+// systemd journal, so we rely on file-based logs. addLogfile handles the Go
+// client log (logPath) with rotation, the stderr/stdout companions and
+// anonymization. The iOS app writes its own Swift log into the same directory,
+// so we add it alongside the Go log.
+func (g *BundleGenerator) addPlatformLog() error {
+ if err := g.addLogfile(); err != nil {
+ return err
+ }
+
+ if g.logPath == "" {
+ return nil
+ }
+
+ swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile)
+ 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)
+ }
+
+ return nil
+}
diff --git a/client/internal/debug/debug_logfiles_test.go b/client/internal/debug/debug_logfiles_test.go
new file mode 100644
index 000000000..31420711f
--- /dev/null
+++ b/client/internal/debug/debug_logfiles_test.go
@@ -0,0 +1,126 @@
+package debug
+
+import (
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestAddRotatedLogFiles_PicksUpAllVariants asserts that the rotated-log
+// glob picks up logs rotated by timberjack (gzipped) and by logrotate (plain
+// and gzipped), and skips unrelated files.
+func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) {
+ dir := t.TempDir()
+
+ writeFile(t, filepath.Join(dir, "client.log"), "active log\n")
+ writeFile(t, filepath.Join(dir, "other.log"), "unrelated\n")
+
+ timberjackRotated := "client-2026-05-21T10-30-45.000.log.gz"
+ writeGzFile(t, filepath.Join(dir, timberjackRotated), "timberjack rotated content\n")
+
+ logrotatePlain := "client.log.1"
+ writeFile(t, filepath.Join(dir, logrotatePlain), "logrotate plain content\n")
+
+ logrotateGz := "client.log.2.gz"
+ writeGzFile(t, filepath.Join(dir, logrotateGz), "logrotate gz content\n")
+
+ names := runAddRotatedLogFiles(t, dir, 10)
+
+ require.Contains(t, names, timberjackRotated, "timberjack rotated file should be in bundle")
+ require.Contains(t, names, logrotatePlain, "logrotate plain rotated file should be in bundle")
+ require.Contains(t, names, logrotateGz, "logrotate gzipped rotated file should be in bundle")
+ require.NotContains(t, names, "client.log", "active log should not be added by addRotatedLogFiles")
+ require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
+}
+
+// TestAddRotatedLogFiles_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) {
+ dir := t.TempDir()
+
+ oldest := filepath.Join(dir, "client.log.3")
+ middle := filepath.Join(dir, "client.log.2")
+ newest := filepath.Join(dir, "client.log.1")
+ writeFile(t, oldest, "old\n")
+ writeFile(t, middle, "mid\n")
+ writeFile(t, newest, "new\n")
+
+ now := time.Now()
+ require.NoError(t, os.Chtimes(oldest, now.Add(-2*time.Hour), now.Add(-2*time.Hour)))
+ require.NoError(t, os.Chtimes(middle, now.Add(-1*time.Hour), now.Add(-1*time.Hour)))
+ require.NoError(t, os.Chtimes(newest, now, now))
+
+ names := runAddRotatedLogFiles(t, dir, 2)
+
+ require.Contains(t, names, "client.log.1")
+ require.Contains(t, names, "client.log.2")
+ require.NotContains(t, names, "client.log.3", "oldest file should be dropped when logFileCount=2")
+}
+
+// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
+// zip writer and returns the set of entry names that ended up in the archive.
+func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} {
+ 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
+ g := &BundleGenerator{
+ archive: zip.NewWriter(&buf),
+ logFileCount: logFileCount,
+ }
+ g.addRotatedLogFiles(openLogFile, dir, prefix)
+ require.NoError(t, g.archive.Close())
+
+ zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
+ require.NoError(t, err)
+
+ names := make(map[string]struct{}, len(zr.File))
+ for _, f := range zr.File {
+ names[f.Name] = struct{}{}
+ }
+ return names
+}
+
+func writeFile(t *testing.T, path, content string) {
+ t.Helper()
+ require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
+}
+
+func writeGzFile(t *testing.T, path, content string) {
+ t.Helper()
+ var buf bytes.Buffer
+ gw := gzip.NewWriter(&buf)
+ _, err := io.WriteString(gw, content)
+ require.NoError(t, err)
+ require.NoError(t, gw.Close())
+ require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644))
+}
diff --git a/client/internal/debug/debug_nonandroid.go b/client/internal/debug/debug_nonandroid.go
index 117238dec..2dfca6ddc 100644
--- a/client/internal/debug/debug_nonandroid.go
+++ b/client/internal/debug/debug_nonandroid.go
@@ -1,4 +1,4 @@
-//go:build !android
+//go:build !android && !ios
package debug
diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go
index 39b972244..7fe93a5c1 100644
--- a/client/internal/debug/debug_test.go
+++ b/client/internal/debug/debug_test.go
@@ -843,6 +843,8 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
"PreSharedKey": "sensitive: WireGuard pre-shared key",
"SSHKey": "sensitive: SSH private key",
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
+ "Name": "non-config: profile name is not needed for debug purposes",
+ "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
}
mURL, _ := url.Parse("https://api.example.com:443")
@@ -883,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/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/dns/handler_chain.go b/client/internal/dns/handler_chain.go
index 57e7722d4..dc20146eb 100644
--- a/client/internal/dns/handler_chain.go
+++ b/client/internal/dns/handler_chain.go
@@ -339,8 +339,7 @@ func (c *HandlerChain) isHandlerMatch(qname string, entry HandlerEntry) bool {
case entry.Pattern == ".":
return true
case entry.IsWildcard:
- parts := strings.Split(strings.TrimSuffix(qname, entry.Pattern), ".")
- return len(parts) >= 2 && strings.HasSuffix(qname, entry.Pattern)
+ return strings.HasSuffix(qname, "."+entry.Pattern)
default:
// For non-wildcard patterns:
// If handler wants subdomain matching, allow suffix match
diff --git a/client/internal/dns/handler_chain_test.go b/client/internal/dns/handler_chain_test.go
index 034a760dc..b3db97ba3 100644
--- a/client/internal/dns/handler_chain_test.go
+++ b/client/internal/dns/handler_chain_test.go
@@ -164,6 +164,54 @@ func TestHandlerChain_ServeDNS_DomainMatching(t *testing.T) {
matchSubdomains: true,
shouldMatch: true,
},
+ {
+ name: "wildcard label-boundary mismatch (suffix overlap)",
+ handlerDomain: "*.b.test.",
+ queryDomain: "x.ab.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: false,
+ },
+ {
+ name: "wildcard label-boundary match",
+ handlerDomain: "*.b.test.",
+ queryDomain: "x.b.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: true,
+ },
+ {
+ name: "wildcard multi-label match",
+ handlerDomain: "*.b.test.",
+ queryDomain: "x.y.b.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: true,
+ },
+ {
+ name: "wildcard no match on multi-label apex",
+ handlerDomain: "*.b.test.",
+ queryDomain: "b.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: false,
+ },
+ {
+ name: "wildcard no match on unrelated suffix containment",
+ handlerDomain: "*.example.com.",
+ queryDomain: "notexample.com.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: false,
+ },
+ {
+ name: "wildcard accepts pattern registered without trailing dot",
+ handlerDomain: "*.b.test",
+ queryDomain: "x.b.test.",
+ isWildcard: true,
+ matchSubdomains: false,
+ shouldMatch: true,
+ },
}
for _, tt := range tests {
@@ -273,6 +321,19 @@ func TestHandlerChain_ServeDNS_OverlappingDomains(t *testing.T) {
expectedCalls: 1,
expectedHandler: 2, // highest priority matching handler should be called
},
+ {
+ name: "overlapping wildcard suffixes route to correct handler",
+ handlers: []struct {
+ pattern string
+ priority int
+ }{
+ {pattern: "*.b.test.", priority: nbdns.PriorityDNSRoute},
+ {pattern: "*.ab.test.", priority: nbdns.PriorityDNSRoute},
+ },
+ queryDomain: "app.ab.test.",
+ expectedCalls: 1,
+ expectedHandler: 1,
+ },
{
name: "root zone with specific domain",
handlers: []struct {
diff --git a/client/internal/dns/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 4a75a76b6..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"
@@ -26,6 +27,55 @@ type resolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}
+// PeerConnectivity reports whether a tunnel IP belongs to a peer the
+// client knows about and whether that peer is currently connected. The
+// local resolver uses this to suppress A/AAAA answers whose RDATA points
+// at a disconnected peer (typical case: a synthesized private-service
+// record pointing at an embedded proxy peer that just went offline).
+//
+// known=false means the IP isn't in the local peerstore at all — the
+// record is left alone (it points at something outside our mesh, e.g.
+// a non-peer upstream).
+type PeerConnectivity interface {
+ IsConnectedByIP(ip 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 {
mu sync.RWMutex
records map[dns.Question][]dns.RR
@@ -33,6 +83,17 @@ type Resolver struct {
// zones maps zone domain -> NonAuthoritative (true = non-authoritative, user-created zone)
zones map[domain.Domain]bool
resolver resolver
+ // peerConn, when non-nil, is consulted on every A/AAAA answer to
+ // drop records pointing at disconnected peers. nil disables the
+ // filter and preserves the legacy "return whatever is registered"
+ // behaviour for callers that never wire a status source.
+ peerConn PeerConnectivity
+ // 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
@@ -41,14 +102,32 @@ 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,
}
}
+// SetPeerConnectivity wires the per-IP connectivity check used to filter
+// out A/AAAA answers pointing at disconnected peers. Pass nil to disable.
+// Safe to call multiple times; the latest value wins.
+func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ d.peerConn = p
+}
+
+// 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
}
@@ -95,6 +174,10 @@ 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
replyMessage.Rcode = d.determineRcode(question, result)
@@ -436,6 +519,113 @@ func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16,
}
}
+// filterDisconnectedPeerAnswers drops A/AAAA records whose RDATA matches
+// a known but disconnected peer. The synthesized private-service zones
+// emit one A record per connected proxy peer in a cluster; when a peer
+// goes offline, the server-side refresh removes the record from the
+// next netmap, but the client may still hold the previous netmap for a
+// short window. This filter is the local belt to that braces — even on
+// the stale netmap, the resolver hides the offline target.
+//
+// Records pointing at unknown IPs (outside the local peerstore, e.g.
+// non-mesh upstreams) are never dropped. Non-A/AAAA records pass
+// through untouched.
+//
+// Escape hatch: if filtering would leave the answer empty AND at least
+// one record was filtered, the original list is returned. Better to
+// hand the client a record that may not respond than NXDOMAIN it
+// completely when every proxy peer is offline (the upstream may still
+// be reachable some other way, or the peerstore may be stale).
+func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns.Question, records []dns.RR) []dns.RR {
+ if len(records) < 2 {
+ return records
+ }
+ d.mu.RLock()
+ checker := d.peerConn
+ d.mu.RUnlock()
+ if checker == nil {
+ return records
+ }
+
+ kept := make([]dns.RR, 0, len(records))
+ var dropped int
+ for _, rr := range records {
+ ip, ok := extractRecordAddr(rr)
+ if !ok {
+ kept = append(kept, rr)
+ continue
+ }
+ known, connected := checker.IsConnectedByIP(ip)
+ if known && !connected {
+ dropped++
+ continue
+ }
+ kept = append(kept, rr)
+ }
+ if dropped == 0 {
+ return records
+ }
+ if len(kept) == 0 {
+ logger.Debugf("all %d answers for %s point at disconnected peers; returning the original list", dropped, question.Name)
+ return records
+ }
+ logger.Tracef("dropped %d disconnected-peer answer(s) for %s, returning %d", dropped, question.Name, len(kept))
+ return kept
+}
+
+// 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:
+ addr, ok := netip.AddrFromSlice(r.A)
+ return addr.Unmap(), ok
+ case *dns.AAAA:
+ addr, ok := netip.AddrFromSlice(r.AAAA)
+ return addr.Unmap(), ok
+ }
+ return netip.Addr{}, false
+}
+
// Update replaces all zones and their records
func (d *Resolver) Update(customZones []nbdns.CustomZone) {
d.mu.Lock()
diff --git a/client/internal/dns/local/local_test.go b/client/internal/dns/local/local_test.go
index 2c6b7dbc3..89e896c0a 100644
--- a/client/internal/dns/local/local_test.go
+++ b/client/internal/dns/local/local_test.go
@@ -30,6 +30,21 @@ func (m *mockResolver) LookupNetIP(ctx context.Context, network, host string) ([
return nil, nil
}
+// mockPeerConnectivity returns canned (known, connected) results per IP.
+// Used by the disconnected-peer filter tests below. IPs not in the map
+// are reported as unknown so the filter leaves them alone.
+type mockPeerConnectivity struct {
+ byIP map[string]struct{ known, connected bool }
+}
+
+func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
+ v, ok := m.byIP[ip.String()]
+ if !ok {
+ return false, false
+ }
+ return v.known, v.connected
+}
+
func TestLocalResolver_ServeDNS(t *testing.T) {
recordA := nbdns.SimpleRecord{
Name: "peera.netbird.cloud.",
@@ -2652,3 +2667,125 @@ func BenchmarkIsInManagedZone_ManyZones(b *testing.B) {
resolver.isInManagedZone(qname)
}
}
+
+// TestLocalResolver_FilterDisconnectedPeerAnswers verifies the
+// connectivity-aware filtering layered on top of lookupRecords:
+// when an A record's IP belongs to a known peer that's disconnected,
+// the record is dropped from the answer. Records for unknown IPs pass
+// through. If filtering would empty the answer entirely and at least
+// one record was dropped, the original list is restored (escape hatch
+// for the "all proxies offline" case).
+func TestLocalResolver_FilterDisconnectedPeerAnswers(t *testing.T) {
+ zone := "svc.cluster.netbird."
+ connectedRec := nbdns.SimpleRecord{
+ Name: zone,
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 5,
+ RData: "100.64.0.10",
+ }
+ disconnectedRec := nbdns.SimpleRecord{
+ Name: zone,
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 5,
+ RData: "100.64.0.11",
+ }
+ unknownRec := nbdns.SimpleRecord{
+ Name: zone,
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 5,
+ RData: "203.0.113.5",
+ }
+
+ type ipState struct{ known, connected bool }
+ tests := []struct {
+ name string
+ records []nbdns.SimpleRecord
+ connByIP map[string]ipState
+ wantInOrder []string
+ }{
+ {
+ name: "drops disconnected peer, keeps connected",
+ records: []nbdns.SimpleRecord{connectedRec, disconnectedRec},
+ connByIP: map[string]ipState{
+ "100.64.0.10": {known: true, connected: true},
+ "100.64.0.11": {known: true, connected: false},
+ },
+ wantInOrder: []string{"100.64.0.10"},
+ },
+ {
+ name: "unknown IPs pass through untouched",
+ records: []nbdns.SimpleRecord{unknownRec, disconnectedRec},
+ connByIP: map[string]ipState{
+ "100.64.0.11": {known: true, connected: false},
+ },
+ wantInOrder: []string{"203.0.113.5"},
+ },
+ {
+ name: "all disconnected falls back to original list",
+ records: []nbdns.SimpleRecord{disconnectedRec, connectedRec},
+ connByIP: map[string]ipState{
+ "100.64.0.10": {known: true, connected: false},
+ "100.64.0.11": {known: true, connected: false},
+ },
+ wantInOrder: []string{"100.64.0.11", "100.64.0.10"},
+ },
+ {
+ name: "no checker wired returns all records",
+ records: []nbdns.SimpleRecord{connectedRec, disconnectedRec},
+ connByIP: nil,
+ wantInOrder: []string{"100.64.0.10", "100.64.0.11"},
+ },
+ {
+ // A single answer is never filtered: dropping it would only
+ // trigger the empty-answer escape hatch, so the fast path
+ // returns it untouched.
+ name: "single disconnected answer passes through",
+ records: []nbdns.SimpleRecord{disconnectedRec},
+ connByIP: map[string]ipState{
+ "100.64.0.11": {known: true, connected: false},
+ },
+ wantInOrder: []string{"100.64.0.11"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ resolver := NewResolver()
+ if tc.connByIP != nil {
+ cm := mockPeerConnectivity{byIP: make(map[string]struct{ known, connected bool }, len(tc.connByIP))}
+ for ip, st := range tc.connByIP {
+ cm.byIP[ip] = struct{ known, connected bool }{st.known, st.connected}
+ }
+ resolver.SetPeerConnectivity(cm)
+ }
+ resolver.Update([]nbdns.CustomZone{{
+ Domain: strings.TrimSuffix(zone, "."),
+ Records: tc.records,
+ NonAuthoritative: true,
+ }})
+
+ var got *dns.Msg
+ writer := &test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ got = m
+ return nil
+ },
+ }
+ req := new(dns.Msg).SetQuestion(zone, dns.TypeA)
+ resolver.ServeDNS(writer, req)
+
+ require.NotNil(t, got, "resolver must produce a response")
+ require.Len(t, got.Answer, len(tc.wantInOrder),
+ "answer count must match expected: %v", tc.wantInOrder)
+ for i, want := range tc.wantInOrder {
+ a, ok := got.Answer[i].(*dns.A)
+ require.True(t, ok, "answer[%d] must be an A record", i)
+ assert.Equal(t, want, a.A.String(),
+ "answer[%d] expected %s got %s", i, want, a.A.String())
+ }
+ })
+ }
+}
diff --git a/client/internal/dns/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/resutil/resolve.go b/client/internal/dns/resutil/resolve.go
index 5a3744719..931938755 100644
--- a/client/internal/dns/resutil/resolve.go
+++ b/client/internal/dns/resutil/resolve.go
@@ -8,12 +8,17 @@ import (
"errors"
"net"
"net/netip"
+ "slices"
"strings"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
)
+// errNoSuitableAddress mirrors the unexported error string the net package
+// uses when a resolved host has no addresses of the requested family.
+const errNoSuitableAddress = "no suitable address found"
+
// GenerateRequestID creates a random 8-character hex string for request tracing.
func GenerateRequestID() string {
bytes := make([]byte, 4)
@@ -126,6 +131,14 @@ func LookupIP(ctx context.Context, r resolver, network, host string, qtype uint1
}
func getRcodeForError(ctx context.Context, r resolver, host string, qtype uint16, err error) int {
+ // The net package returns this AddrError when the host resolves but has
+ // no addresses of the requested family. The domain exists, so answer
+ // NODATA instead of SERVFAIL.
+ var addrErr *net.AddrError
+ if errors.As(err, &addrErr) && addrErr.Err == errNoSuitableAddress {
+ return dns.RcodeSuccess
+ }
+
var dnsErr *net.DNSError
if !errors.As(err, &dnsErr) {
return dns.RcodeServerFailure
@@ -155,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 {
@@ -172,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 {
@@ -195,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
new file mode 100644
index 000000000..f51092a83
--- /dev/null
+++ b/client/internal/dns/resutil/resolve_test.go
@@ -0,0 +1,320 @@
+package resutil
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/netip"
+ "strings"
+ "testing"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type mockResolver struct {
+ // results maps network ("ip4"/"ip6") to the lookup outcome.
+ results map[string]mockLookup
+}
+
+type mockLookup struct {
+ ips []netip.Addr
+ err error
+}
+
+func (m *mockResolver) LookupNetIP(_ context.Context, network, _ string) ([]netip.Addr, error) {
+ res, ok := m.results[network]
+ if !ok {
+ return nil, errors.New("unexpected network: " + network)
+ }
+ return res.ips, res.err
+}
+
+func TestLookupIP_Success(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {ips: []netip.Addr{netip.MustParseAddr("::ffff:192.0.2.1")}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ assert.Equal(t, dns.RcodeSuccess, result.Rcode, "successful lookup should return NOERROR")
+ require.Len(t, result.IPs, 1, "should return the resolved address")
+ assert.Equal(t, netip.MustParseAddr("192.0.2.1"), result.IPs[0], "v4-mapped address should be unmapped")
+}
+
+func TestLookupIP_NoSuitableAddress(t *testing.T) {
+ // The net package returns this AddrError when the host resolves but has
+ // no addresses of the requested family (e.g. AAAA query for a v4-only
+ // hosts file entry). The domain exists, so this is NODATA, not SERVFAIL.
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip6": {err: &net.AddrError{Err: "no suitable address found", Addr: "example.com."}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip6", "example.com.", dns.TypeAAAA)
+
+ assert.Equal(t, dns.RcodeSuccess, result.Rcode, "no suitable address should map to NODATA")
+ assert.Empty(t, result.IPs, "NODATA response should carry no addresses")
+}
+
+// TestErrNoSuitableAddressMatchesNetPackage pins our copy of the error string
+// to what the net package actually emits. A literal IP of the wrong family
+// takes the same filterAddrList path as a resolved hostname, without network
+// access.
+func TestErrNoSuitableAddressMatchesNetPackage(t *testing.T) {
+ _, err := (&net.Resolver{}).LookupNetIP(context.Background(), "ip6", "192.0.2.1")
+ require.Error(t, err)
+
+ var addrErr *net.AddrError
+ require.ErrorAs(t, err, &addrErr, "wrong-family lookup should return AddrError")
+ assert.Equal(t, errNoSuitableAddress, addrErr.Err, "net package error string should match our constant")
+}
+
+func TestLookupIP_OtherAddrError(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {err: &net.AddrError{Err: "some other address problem", Addr: "example.com."}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "unrecognized AddrError should map to SERVFAIL")
+}
+
+func TestLookupIP_NotFoundNXDomain(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
+ "ip6": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ assert.Equal(t, dns.RcodeNameError, result.Rcode, "not found for both families should map to NXDOMAIN")
+}
+
+func TestLookupIP_NotFoundNoData(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip6": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
+ "ip4": {ips: []netip.Addr{netip.MustParseAddr("192.0.2.1")}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip6", "example.com.", dns.TypeAAAA)
+
+ assert.Equal(t, dns.RcodeSuccess, result.Rcode, "not found with the other family present should map to NODATA")
+}
+
+func TestLookupIP_GenericError(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {err: errors.New("connection refused")},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "generic error should map to SERVFAIL")
+}
+
+func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {err: &net.DNSError{Err: "server misbehaving", Name: "example.com.", IsTemporary: true}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ 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 e689f3586..3af912792 100644
--- a/client/internal/dns/server.go
+++ b/client/internal/dns/server.go
@@ -6,6 +6,7 @@ import (
"fmt"
"net/netip"
"net/url"
+ "os"
"slices"
"strings"
"sync"
@@ -38,11 +39,15 @@ const (
// defaultWarningDelayBase is the starting grace window before a
// "Nameserver group unreachable" event fires for a group that's
// never been healthy and only has overlay upstreams with no
- // Connected peer. Per-server and overridable; see warningDelayFor.
- defaultWarningDelayBase = 30 * time.Second
+ // Connected peer. Per-server and overridable via envWarningDelay;
+ // see warningDelay.
+ defaultWarningDelayBase = 60 * time.Second
// warningDelayBonusCap caps the route-count bonus added to the
- // base grace window. See warningDelayFor.
+ // base grace window. See warningDelay.
warningDelayBonusCap = 30 * time.Second
+ // envWarningDelay overrides defaultWarningDelayBase with a Go duration
+ // string (e.g. "90s", "2m"). Invalid or non-positive values are ignored.
+ envWarningDelay = "NB_DNS_HEALTH_WARNING_DELAY"
)
// errNoUsableNameservers signals that a merged-domain group has no usable
@@ -77,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 {
@@ -135,7 +141,7 @@ type DefaultServer struct {
disableSys bool
mux sync.Mutex
service service
- dnsMuxMap registeredHandlerMap
+ dnsMuxHandlers []handlerWrapper
localResolver *local.Resolver
wgInterface WGIface
hostManager hostManager
@@ -199,8 +205,6 @@ type handlerWrapper struct {
priority int
}
-type registeredHandlerMap map[types.HandlerID]handlerWrapper
-
// DefaultServerConfig holds configuration parameters for NewDefaultServer
type DefaultServerConfig struct {
WgInterface WGIface
@@ -248,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
@@ -289,7 +293,6 @@ func newDefaultServer(
service: dnsService,
handlerChain: handlerChain,
extraDomains: make(map[domain.Domain]int),
- dnsMuxMap: make(registeredHandlerMap),
localResolver: local.NewResolver(),
wgInterface: wgInterface,
statusRecorder: statusRecorder,
@@ -298,9 +301,14 @@ func newDefaultServer(
hostManager: &noopHostConfigurator{},
mgmtCacheResolver: mgmtCacheResolver,
currentConfigHash: ^uint64(0), // Initialize to max uint64 to ensure first config is always applied
- warningDelayBase: defaultWarningDelayBase,
+ warningDelayBase: warningDelayBaseFromEnv(),
healthRefresh: make(chan struct{}, 1),
}
+ // Wire the local resolver against the peer status recorder so it can
+ // suppress A/AAAA answers that point at disconnected peers (typical
+ // case: synthesised private-service records pointing at an embedded
+ // proxy peer that just went offline).
+ defaultServer.localResolver.SetPeerConnectivity(localPeerConnectivity{statusRecorder})
// register with root zone, handler chain takes care of the routing
dnsService.RegisterMux(".", handlerChain)
@@ -323,7 +331,7 @@ func (s *DefaultServer) SetRouteSources(selected, active func() route.HAMap) {
type routeSettable interface {
setSelectedRoutes(func() route.HAMap)
}
- for _, entry := range s.dnsMuxMap {
+ for _, entry := range s.dnsMuxHandlers {
if h, ok := entry.handler.(routeSettable); ok {
h.setSelectedRoutes(selected)
}
@@ -484,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()
@@ -587,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 {
@@ -671,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)
@@ -772,13 +793,24 @@ func (s *DefaultServer) applyHostConfig() {
// context is released rather than leaked until GC.
func (s *DefaultServer) registerFallback() {
originalNameservers := s.hostManager.getOriginalNameservers()
- if len(originalNameservers) == 0 {
+
+ serverIP := s.service.RuntimeIP()
+ var servers []netip.AddrPort
+ for _, ns := range originalNameservers {
+ if ns == serverIP {
+ log.Debugf("skipping original nameserver %s as it is the same as the server IP %s", ns, serverIP)
+ continue
+ }
+ servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
+ }
+
+ if len(servers) == 0 {
log.Debugf("no fallback upstreams to register; clearing PriorityFallback handler")
s.clearFallback()
return
}
- log.Infof("registering original nameservers %v as upstream handlers with priority %d", originalNameservers, PriorityFallback)
+ log.Infof("registering original nameservers %v as upstream handlers with priority %d", servers, PriorityFallback)
handler, err := newUpstreamResolver(
s.ctx,
@@ -792,11 +824,6 @@ func (s *DefaultServer) registerFallback() {
return
}
handler.selectedRoutes = s.selectedRoutes
-
- var servers []netip.AddrPort
- for _, ns := range originalNameservers {
- servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
- }
handler.addRace(servers)
prev := s.fallbackHandler
@@ -967,19 +994,23 @@ func (s *DefaultServer) usableNameServers(nameServers []nbdns.NameServer) []neti
func (s *DefaultServer) updateMux(muxUpdates []handlerWrapper) {
// this will introduce a short period of time when the server is not able to handle DNS requests
- for _, existing := range s.dnsMuxMap {
+ for _, existing := range s.dnsMuxHandlers {
s.deregisterHandler([]string{existing.domain}, existing.priority)
- existing.handler.Stop()
+ // The local resolver is a persistent singleton shared by every custom
+ // zone and reused across config updates. Its chain registrations are
+ // per-config and must be deregistered, but Stop() cancels its lookup
+ // context (breaking external CNAME-target resolution) and clears its
+ // records, so it must not be torn down here.
+ if existing.handler != s.localResolver {
+ existing.handler.Stop()
+ }
}
- muxUpdateMap := make(registeredHandlerMap)
-
for _, update := range muxUpdates {
s.registerHandler([]string{update.domain}, update.handler, update.priority)
- muxUpdateMap[update.handler.ID()] = update
}
- s.dnsMuxMap = muxUpdateMap
+ s.dnsMuxHandlers = muxUpdates
}
// updateNSGroupStates records the new group set and pokes the refresher.
@@ -1143,6 +1174,26 @@ func (s *DefaultServer) projectUnhealthy(p *nsGroupProj, servers []netip.AddrPor
return false
}
+// warningDelayBaseFromEnv returns the base grace window, honoring
+// envWarningDelay when it holds a valid positive Go duration. Invalid or
+// non-positive values fall back to defaultWarningDelayBase.
+func warningDelayBaseFromEnv() time.Duration {
+ val := os.Getenv(envWarningDelay)
+ if val == "" {
+ return defaultWarningDelayBase
+ }
+ d, err := time.ParseDuration(val)
+ if err != nil {
+ log.Warnf("invalid %s value %q, using default %v: %v", envWarningDelay, val, defaultWarningDelayBase, err)
+ return defaultWarningDelayBase
+ }
+ if d <= 0 {
+ log.Warnf("%s must be positive, got %v, using default %v", envWarningDelay, d, defaultWarningDelayBase)
+ return defaultWarningDelayBase
+ }
+ return d
+}
+
// warningDelay returns the grace window for the given selected-route
// count. Scales gently: +1s per 100 routes, capped by
// warningDelayBonusCap. Parallel handshakes mean handshake time grows
@@ -1193,7 +1244,7 @@ func (s *DefaultServer) groupHasImmediateUpstream(servers []netip.AddrPort, snap
// in more than one handler.
func (s *DefaultServer) collectUpstreamHealth() map[netip.AddrPort]UpstreamHealth {
merged := make(map[netip.AddrPort]UpstreamHealth)
- for _, entry := range s.dnsMuxMap {
+ for _, entry := range s.dnsMuxHandlers {
reporter, ok := entry.handler.(upstreamHealthReporter)
if !ok {
continue
@@ -1386,3 +1437,25 @@ func (s *DefaultServer) PopulateManagementDomain(mgmtURL *url.URL) error {
}
return nil
}
+
+// localPeerConnectivity adapts *peer.Status to local.PeerConnectivity so
+// the local resolver can ask "is this IP a known peer and is it
+// connected?" without taking on the peer package as a dependency.
+// A nil status recorder always reports known=false so the resolver
+// short-circuits to the legacy "return everything" path.
+type localPeerConnectivity struct {
+ status *peer.Status
+}
+
+// IsConnectedByIP looks the IP up in the peerstore and surfaces both
+// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
+func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
+ if l.status == nil {
+ return false, false
+ }
+ state, ok := l.status.PeerStateByIP(ip.String())
+ if !ok {
+ return false, false
+ }
+ return true, state.ConnStatus == peer.StatusConnected
+}
diff --git a/client/internal/dns/server_privileged_test.go b/client/internal/dns/server_privileged_test.go
new file mode 100644
index 000000000..a03aea169
--- /dev/null
+++ b/client/internal/dns/server_privileged_test.go
@@ -0,0 +1,485 @@
+//go:build privileged
+
+package dns
+
+import (
+ "context"
+ "fmt"
+ "net/netip"
+ "os"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+
+ "github.com/netbirdio/netbird/client/iface"
+ pfmock "github.com/netbirdio/netbird/client/iface/mocks"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+ "github.com/netbirdio/netbird/client/internal/dns/local"
+ "github.com/netbirdio/netbird/client/internal/dns/test"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/stdnet"
+ nbdns "github.com/netbirdio/netbird/dns"
+)
+
+func TestUpdateDNSServer(t *testing.T) {
+
+ nameServers := []nbdns.NameServer{
+ {
+ IP: netip.MustParseAddr("8.8.8.8"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ {
+ IP: netip.MustParseAddr("8.8.4.4"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ }
+
+ testCases := []struct {
+ name string
+ initUpstreamMap []handlerWrapper
+ initLocalZones []nbdns.CustomZone
+ initSerial uint64
+ inputSerial uint64
+ inputUpdate nbdns.Config
+ shouldFail bool
+ expectedUpstreamMap []handlerWrapper
+ expectedLocalQs []dns.Question
+ }{
+ {
+ name: "Initial Config Should Succeed",
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.io",
+ priority: PriorityUpstream,
+ },
+ {
+ domain: "netbird.cloud",
+ priority: PriorityLocal,
+ },
+ {
+ domain: nbdns.RootZone,
+ priority: PriorityDefault,
+ },
+ },
+ expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}},
+ },
+ {
+ name: "New Config Should Succeed",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.cloud",
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.io",
+ priority: PriorityUpstream,
+ },
+ {
+ domain: "netbird.cloud",
+ priority: PriorityLocal,
+ },
+ },
+ expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}},
+ },
+ {
+ name: "Smaller Config Serial Should Be Skipped",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 2,
+ inputSerial: 1,
+ shouldFail: true,
+ },
+ {
+ name: "Empty NS Group Domain Or Not Primary Element Should Fail",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ },
+ },
+ },
+ shouldFail: true,
+ },
+ {
+ name: "Invalid NS Group Nameservers list Should Fail",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ },
+ },
+ },
+ shouldFail: true,
+ },
+ {
+ name: "Invalid Custom Zone Records list Should Skip",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{{
+ domain: ".",
+ priority: PriorityDefault,
+ }},
+ },
+ {
+ name: "Empty Config Should Succeed and Clean Maps",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{ServiceEnable: true},
+ expectedUpstreamMap: nil,
+ expectedLocalQs: []dns.Question{},
+ },
+ {
+ name: "Disabled Service Should clean map",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{ServiceEnable: false},
+ expectedUpstreamMap: nil,
+ expectedLocalQs: []dns.Question{},
+ },
+ }
+
+ for n, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ privKey, _ := wgtypes.GenerateKey()
+ newNet, err := stdnet.NewNet(context.Background(), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ opts := iface.WGIFaceOpts{
+ IFaceName: fmt.Sprintf("utun230%d", n),
+ Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)),
+ WGPort: 33100,
+ WGPrivKey: privKey.String(),
+ MTU: iface.DefaultMTU,
+ TransportNet: newNet,
+ }
+
+ wgIface, err := iface.NewWGIFace(opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = wgIface.Create()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ err = wgIface.Close()
+ if err != nil {
+ t.Log(err)
+ }
+ }()
+ dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
+ WgInterface: wgIface,
+ CustomAddress: "",
+ StatusRecorder: peer.NewRecorder("mgm"),
+ StateManager: nil,
+ DisableSys: false,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = dnsServer.Initialize()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ err = dnsServer.hostManager.restoreHostDNS()
+ if err != nil {
+ t.Log(err)
+ }
+ }()
+
+ dnsServer.dnsMuxHandlers = testCase.initUpstreamMap
+ dnsServer.localResolver.Update(testCase.initLocalZones)
+ dnsServer.updateSerial = testCase.initSerial
+
+ err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate)
+ if err != nil {
+ if testCase.shouldFail {
+ return
+ }
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ }
+
+ if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) {
+ t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers))
+ }
+
+ for _, expected := range testCase.expectedUpstreamMap {
+ found := false
+ for _, got := range dnsServer.dnsMuxHandlers {
+ if got.domain == expected.domain && got.priority == expected.priority {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers)
+ }
+ }
+
+ var responseMSG *dns.Msg
+ responseWriter := &test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ responseMSG = m
+ return nil
+ },
+ }
+ for _, q := range testCase.expectedLocalQs {
+ dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{
+ Question: []dns.Question{q},
+ })
+ }
+
+ if len(testCase.expectedLocalQs) > 0 {
+ assert.NotNil(t, responseMSG, "response message should not be nil")
+ assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success")
+ assert.NotEmpty(t, responseMSG.Answer, "response message should have answers")
+ }
+ })
+ }
+}
+
+func TestDNSFakeResolverHandleUpdates(t *testing.T) {
+ ov := os.Getenv("NB_WG_KERNEL_DISABLED")
+ defer t.Setenv("NB_WG_KERNEL_DISABLED", ov)
+
+ t.Setenv("NB_WG_KERNEL_DISABLED", "true")
+ newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"})
+ if err != nil {
+ t.Errorf("create stdnet: %v", err)
+ return
+ }
+
+ privKey, _ := wgtypes.GeneratePrivateKey()
+ opts := iface.WGIFaceOpts{
+ IFaceName: "utun2301",
+ Address: wgaddr.MustParseWGAddress("100.66.100.1/32"),
+ WGPort: 33100,
+ WGPrivKey: privKey.String(),
+ MTU: iface.DefaultMTU,
+ TransportNet: newNet,
+ }
+ wgIface, err := iface.NewWGIFace(opts)
+ if err != nil {
+ t.Errorf("build interface wireguard: %v", err)
+ return
+ }
+
+ err = wgIface.Create()
+ if err != nil {
+ t.Errorf("create and init wireguard interface: %v", err)
+ return
+ }
+ defer func() {
+ if err = wgIface.Close(); err != nil {
+ t.Logf("close wireguard interface: %v", err)
+ }
+ }()
+
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ packetfilter := pfmock.NewMockPacketFilter(ctrl)
+ packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes()
+ packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+ packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+
+ if err := wgIface.SetFilter(packetfilter); err != nil {
+ t.Errorf("set packet filter: %v", err)
+ return
+ }
+
+ dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
+ WgInterface: wgIface,
+ CustomAddress: "",
+ StatusRecorder: peer.NewRecorder("mgm"),
+ StateManager: nil,
+ DisableSys: false,
+ })
+ if err != nil {
+ t.Errorf("create DNS server: %v", err)
+ return
+ }
+
+ err = dnsServer.Initialize()
+ if err != nil {
+ t.Errorf("run DNS server: %v", err)
+ return
+ }
+ defer func() {
+ if err = dnsServer.hostManager.restoreHostDNS(); err != nil {
+ t.Logf("restore DNS settings on the host: %v", err)
+ return
+ }
+ }()
+
+ dnsServer.dnsMuxHandlers = []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &local.Resolver{},
+ priority: PriorityUpstream,
+ },
+ }
+ dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}})
+ dnsServer.updateSerial = 0
+
+ nameServers := []nbdns.NameServer{
+ {
+ IP: netip.MustParseAddr("8.8.8.8"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ {
+ IP: netip.MustParseAddr("8.8.4.4"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ }
+
+ update := nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ }
+
+ // Start the server with regular configuration
+ if err := dnsServer.UpdateDNSServer(1, update); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+
+ update2 := update
+ update2.ServiceEnable = false
+ // Disable the server, stop the listener
+ if err := dnsServer.UpdateDNSServer(2, update2); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+
+ update3 := update2
+ update3.NameServerGroups = update3.NameServerGroups[:1]
+ // But service still get updates and we checking that we handle
+ // internal state in the right way
+ if err := dnsServer.UpdateDNSServer(3, update3); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+}
diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go
index 722c2abd7..96e55a354 100644
--- a/client/internal/dns/server_test.go
+++ b/client/internal/dns/server_test.go
@@ -10,7 +10,6 @@ import (
"testing"
"time"
- "github.com/golang/mock/gomock"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
@@ -23,7 +22,6 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/configurer"
"github.com/netbirdio/netbird/client/iface/device"
- pfmock "github.com/netbirdio/netbird/client/iface/mocks"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/dns/local"
"github.com/netbirdio/netbird/client/internal/dns/test"
@@ -104,481 +102,6 @@ func init() {
formatter.SetTextFormatter(log.StandardLogger())
}
-func generateDummyHandler(d string, servers []nbdns.NameServer) *upstreamResolverBase {
- var srvs []netip.AddrPort
- for _, srv := range servers {
- srvs = append(srvs, srv.AddrPort())
- }
- u := &upstreamResolverBase{
- domain: domain.Domain(d),
- cancel: func() {},
- }
- u.addRace(srvs)
- return u
-}
-
-func TestUpdateDNSServer(t *testing.T) {
-
- nameServers := []nbdns.NameServer{
- {
- IP: netip.MustParseAddr("8.8.8.8"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- {
- IP: netip.MustParseAddr("8.8.4.4"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- }
-
- dummyHandler := local.NewResolver()
-
- testCases := []struct {
- name string
- initUpstreamMap registeredHandlerMap
- initLocalZones []nbdns.CustomZone
- initSerial uint64
- inputSerial uint64
- inputUpdate nbdns.Config
- shouldFail bool
- expectedUpstreamMap registeredHandlerMap
- expectedLocalQs []dns.Question
- }{
- {
- name: "Initial Config Should Succeed",
- initUpstreamMap: make(registeredHandlerMap),
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- Domains: []string{"netbird.io"},
- NameServers: nameServers,
- },
- {
- NameServers: nameServers,
- Primary: true,
- },
- },
- },
- expectedUpstreamMap: registeredHandlerMap{
- generateDummyHandler("netbird.io", nameServers).ID(): handlerWrapper{
- domain: "netbird.io",
- handler: dummyHandler,
- priority: PriorityUpstream,
- },
- dummyHandler.ID(): handlerWrapper{
- domain: "netbird.cloud",
- handler: dummyHandler,
- priority: PriorityLocal,
- },
- generateDummyHandler(".", nameServers).ID(): handlerWrapper{
- domain: nbdns.RootZone,
- handler: dummyHandler,
- priority: PriorityDefault,
- },
- },
- expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}},
- },
- {
- name: "New Config Should Succeed",
- initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
- initUpstreamMap: registeredHandlerMap{
- generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
- domain: "netbird.cloud",
- handler: dummyHandler,
- priority: PriorityUpstream,
- },
- },
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- Domains: []string{"netbird.io"},
- NameServers: nameServers,
- },
- },
- },
- expectedUpstreamMap: registeredHandlerMap{
- generateDummyHandler("netbird.io", nameServers).ID(): handlerWrapper{
- domain: "netbird.io",
- handler: dummyHandler,
- priority: PriorityUpstream,
- },
- "local-resolver": handlerWrapper{
- domain: "netbird.cloud",
- handler: dummyHandler,
- priority: PriorityLocal,
- },
- },
- expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}},
- },
- {
- name: "Smaller Config Serial Should Be Skipped",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: make(registeredHandlerMap),
- initSerial: 2,
- inputSerial: 1,
- shouldFail: true,
- },
- {
- name: "Empty NS Group Domain Or Not Primary Element Should Fail",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: make(registeredHandlerMap),
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- NameServers: nameServers,
- },
- },
- },
- shouldFail: true,
- },
- {
- name: "Invalid NS Group Nameservers list Should Fail",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: make(registeredHandlerMap),
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- NameServers: nameServers,
- },
- },
- },
- shouldFail: true,
- },
- {
- name: "Invalid Custom Zone Records list Should Skip",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: make(registeredHandlerMap),
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- NameServers: nameServers,
- Primary: true,
- },
- },
- },
- expectedUpstreamMap: registeredHandlerMap{generateDummyHandler(".", nameServers).ID(): handlerWrapper{
- domain: ".",
- handler: dummyHandler,
- priority: PriorityDefault,
- }},
- },
- {
- name: "Empty Config Should Succeed and Clean Maps",
- initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
- initUpstreamMap: registeredHandlerMap{
- generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
- domain: zoneRecords[0].Name,
- handler: dummyHandler,
- priority: PriorityUpstream,
- },
- },
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{ServiceEnable: true},
- expectedUpstreamMap: make(registeredHandlerMap),
- expectedLocalQs: []dns.Question{},
- },
- {
- name: "Disabled Service Should clean map",
- initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
- initUpstreamMap: registeredHandlerMap{
- generateDummyHandler(zoneRecords[0].Name, nameServers).ID(): handlerWrapper{
- domain: zoneRecords[0].Name,
- handler: dummyHandler,
- priority: PriorityUpstream,
- },
- },
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{ServiceEnable: false},
- expectedUpstreamMap: make(registeredHandlerMap),
- expectedLocalQs: []dns.Question{},
- },
- }
-
- for n, testCase := range testCases {
- t.Run(testCase.name, func(t *testing.T) {
- privKey, _ := wgtypes.GenerateKey()
- newNet, err := stdnet.NewNet(context.Background(), nil)
- if err != nil {
- t.Fatal(err)
- }
-
- opts := iface.WGIFaceOpts{
- IFaceName: fmt.Sprintf("utun230%d", n),
- Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)),
- WGPort: 33100,
- WGPrivKey: privKey.String(),
- MTU: iface.DefaultMTU,
- TransportNet: newNet,
- }
-
- wgIface, err := iface.NewWGIFace(opts)
- if err != nil {
- t.Fatal(err)
- }
- err = wgIface.Create()
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- err = wgIface.Close()
- if err != nil {
- t.Log(err)
- }
- }()
- dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
- WgInterface: wgIface,
- CustomAddress: "",
- StatusRecorder: peer.NewRecorder("mgm"),
- StateManager: nil,
- DisableSys: false,
- })
- if err != nil {
- t.Fatal(err)
- }
- err = dnsServer.Initialize()
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- err = dnsServer.hostManager.restoreHostDNS()
- if err != nil {
- t.Log(err)
- }
- }()
-
- dnsServer.dnsMuxMap = testCase.initUpstreamMap
- dnsServer.localResolver.Update(testCase.initLocalZones)
- dnsServer.updateSerial = testCase.initSerial
-
- err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate)
- if err != nil {
- if testCase.shouldFail {
- return
- }
- t.Fatalf("update dns server should not fail, got error: %v", err)
- }
-
- if len(dnsServer.dnsMuxMap) != len(testCase.expectedUpstreamMap) {
- t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxMap))
- }
-
- for key := range testCase.expectedUpstreamMap {
- _, found := dnsServer.dnsMuxMap[key]
- if !found {
- t.Fatalf("update upstream failed, key %s was not found in the dnsMuxMap: %#v", key, dnsServer.dnsMuxMap)
- }
- }
-
- var responseMSG *dns.Msg
- responseWriter := &test.MockResponseWriter{
- WriteMsgFunc: func(m *dns.Msg) error {
- responseMSG = m
- return nil
- },
- }
- for _, q := range testCase.expectedLocalQs {
- dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{
- Question: []dns.Question{q},
- })
- }
-
- if len(testCase.expectedLocalQs) > 0 {
- assert.NotNil(t, responseMSG, "response message should not be nil")
- assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success")
- assert.NotEmpty(t, responseMSG.Answer, "response message should have answers")
- }
- })
- }
-}
-
-func TestDNSFakeResolverHandleUpdates(t *testing.T) {
- ov := os.Getenv("NB_WG_KERNEL_DISABLED")
- defer t.Setenv("NB_WG_KERNEL_DISABLED", ov)
-
- t.Setenv("NB_WG_KERNEL_DISABLED", "true")
- newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"})
- if err != nil {
- t.Errorf("create stdnet: %v", err)
- return
- }
-
- privKey, _ := wgtypes.GeneratePrivateKey()
- opts := iface.WGIFaceOpts{
- IFaceName: "utun2301",
- Address: wgaddr.MustParseWGAddress("100.66.100.1/32"),
- WGPort: 33100,
- WGPrivKey: privKey.String(),
- MTU: iface.DefaultMTU,
- TransportNet: newNet,
- }
- wgIface, err := iface.NewWGIFace(opts)
- if err != nil {
- t.Errorf("build interface wireguard: %v", err)
- return
- }
-
- err = wgIface.Create()
- if err != nil {
- t.Errorf("create and init wireguard interface: %v", err)
- return
- }
- defer func() {
- if err = wgIface.Close(); err != nil {
- t.Logf("close wireguard interface: %v", err)
- }
- }()
-
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
-
- packetfilter := pfmock.NewMockPacketFilter(ctrl)
- packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes()
- packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
- packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
-
- if err := wgIface.SetFilter(packetfilter); err != nil {
- t.Errorf("set packet filter: %v", err)
- return
- }
-
- dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
- WgInterface: wgIface,
- CustomAddress: "",
- StatusRecorder: peer.NewRecorder("mgm"),
- StateManager: nil,
- DisableSys: false,
- })
- if err != nil {
- t.Errorf("create DNS server: %v", err)
- return
- }
-
- err = dnsServer.Initialize()
- if err != nil {
- t.Errorf("run DNS server: %v", err)
- return
- }
- defer func() {
- if err = dnsServer.hostManager.restoreHostDNS(); err != nil {
- t.Logf("restore DNS settings on the host: %v", err)
- return
- }
- }()
-
- dnsServer.dnsMuxMap = registeredHandlerMap{
- "id1": handlerWrapper{
- domain: zoneRecords[0].Name,
- handler: &local.Resolver{},
- priority: PriorityUpstream,
- },
- }
- dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}})
- dnsServer.updateSerial = 0
-
- nameServers := []nbdns.NameServer{
- {
- IP: netip.MustParseAddr("8.8.8.8"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- {
- IP: netip.MustParseAddr("8.8.4.4"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- }
-
- update := nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- Domains: []string{"netbird.io"},
- NameServers: nameServers,
- },
- {
- NameServers: nameServers,
- Primary: true,
- },
- },
- }
-
- // Start the server with regular configuration
- if err := dnsServer.UpdateDNSServer(1, update); err != nil {
- t.Fatalf("update dns server should not fail, got error: %v", err)
- return
- }
-
- update2 := update
- update2.ServiceEnable = false
- // Disable the server, stop the listener
- if err := dnsServer.UpdateDNSServer(2, update2); err != nil {
- t.Fatalf("update dns server should not fail, got error: %v", err)
- return
- }
-
- update3 := update2
- update3.NameServerGroups = update3.NameServerGroups[:1]
- // But service still get updates and we checking that we handle
- // internal state in the right way
- if err := dnsServer.UpdateDNSServer(3, update3); err != nil {
- t.Fatalf("update dns server should not fail, got error: %v", err)
- return
- }
-}
-
func TestDNSServerStartStop(t *testing.T) {
testCases := []struct {
name string
@@ -1029,15 +552,15 @@ func (m *mockService) RegisterMux(string, dns.Handler) {}
func (m *mockService) DeregisterMux(string) {}
func TestDefaultServer_UpdateMux(t *testing.T) {
- baseMatchHandlers := registeredHandlerMap{
- "upstream-group1": {
+ baseMatchHandlers := []handlerWrapper{
+ {
domain: "example.com",
handler: &mockHandler{
Id: "upstream-group1",
},
priority: PriorityUpstream,
},
- "upstream-group2": {
+ {
domain: "example.com",
handler: &mockHandler{
Id: "upstream-group2",
@@ -1046,15 +569,15 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
},
}
- baseRootHandlers := registeredHandlerMap{
- "upstream-root1": {
+ baseRootHandlers := []handlerWrapper{
+ {
domain: ".",
handler: &mockHandler{
Id: "upstream-root1",
},
priority: PriorityDefault,
},
- "upstream-root2": {
+ {
domain: ".",
handler: &mockHandler{
Id: "upstream-root2",
@@ -1063,22 +586,22 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
},
}
- baseMixedHandlers := registeredHandlerMap{
- "upstream-group1": {
+ baseMixedHandlers := []handlerWrapper{
+ {
domain: "example.com",
handler: &mockHandler{
Id: "upstream-group1",
},
priority: PriorityUpstream,
},
- "upstream-group2": {
+ {
domain: "example.com",
handler: &mockHandler{
Id: "upstream-group2",
},
priority: PriorityUpstream - 1,
},
- "upstream-other": {
+ {
domain: "other.com",
handler: &mockHandler{
Id: "upstream-other",
@@ -1089,7 +612,7 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
tests := []struct {
name string
- initialHandlers registeredHandlerMap
+ initialHandlers []handlerWrapper
updates []handlerWrapper
expectedHandlers map[string]string // map[HandlerID]domain
description string
@@ -1373,32 +896,38 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := &DefaultServer{
- dnsMuxMap: tt.initialHandlers,
- handlerChain: NewHandlerChain(),
- service: &mockService{},
+ dnsMuxHandlers: tt.initialHandlers,
+ handlerChain: NewHandlerChain(),
+ service: &mockService{},
}
// Perform the update
server.updateMux(tt.updates)
// Verify the results
- assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxMap),
+ assert.Equal(t, len(tt.expectedHandlers), len(server.dnsMuxHandlers),
"Number of handlers after update doesn't match expected")
// Check each expected handler
for id, expectedDomain := range tt.expectedHandlers {
- handler, exists := server.dnsMuxMap[types.HandlerID(id)]
- assert.True(t, exists, "Expected handler %s not found", id)
- if exists {
- assert.Equal(t, expectedDomain, handler.domain,
+ var found *handlerWrapper
+ for i := range server.dnsMuxHandlers {
+ if server.dnsMuxHandlers[i].handler.ID() == types.HandlerID(id) {
+ found = &server.dnsMuxHandlers[i]
+ break
+ }
+ }
+ assert.NotNil(t, found, "Expected handler %s not found", id)
+ if found != nil {
+ assert.Equal(t, expectedDomain, found.domain,
"Domain mismatch for handler %s", id)
}
}
// Verify no unexpected handlers exist
- for HandlerID := range server.dnsMuxMap {
- _, expected := tt.expectedHandlers[string(HandlerID)]
- assert.True(t, expected, "Unexpected handler found: %s", HandlerID)
+ for _, entry := range server.dnsMuxHandlers {
+ _, expected := tt.expectedHandlers[string(entry.handler.ID())]
+ assert.True(t, expected, "Unexpected handler found: %s", entry.handler.ID())
}
// Verify the handlerChain state and order
@@ -1413,7 +942,7 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
// Verify handler exists in mux
foundInMux := false
- for _, muxEntry := range server.dnsMuxMap {
+ for _, muxEntry := range server.dnsMuxHandlers {
if chainEntry.Handler == muxEntry.handler &&
chainEntry.Priority == muxEntry.priority &&
chainEntry.Pattern == dns.Fqdn(muxEntry.domain) {
@@ -1422,12 +951,108 @@ func TestDefaultServer_UpdateMux(t *testing.T) {
}
}
assert.True(t, foundInMux,
- "Handler in chain not found in dnsMuxMap")
+ "Handler in chain not found in dnsMuxHandlers")
}
})
}
}
+// chainHasPattern reports whether the handler chain holds an entry registered
+// for the given fqdn pattern at the given priority.
+func chainHasPattern(s *DefaultServer, pattern string, priority int) bool {
+ for _, h := range s.handlerChain.handlers {
+ if h.OrigPattern == pattern && h.Priority == priority {
+ return true
+ }
+ }
+ return false
+}
+
+// TestDefaultServer_UpdateMux_SharedHandlerZoneRemoval verifies that updateMux
+// tracks each (handler, domain) registration independently when one handler
+// serves multiple zones. Every custom zone is served by the same handler
+// instance (the local resolver, whose ID is the constant "local-resolver"), so
+// removing one zone must deregister exactly that zone's chain entry and leave
+// the others in place. Tracking registrations by handler ID alone collapses all
+// zones onto one entry, leaving removed zones in the chain to answer
+// authoritatively with no records.
+func TestDefaultServer_UpdateMux_SharedHandlerZoneRemoval(t *testing.T) {
+ // One handler serves every custom zone, mirroring s.localResolver.
+ shared := &mockHandler{Id: "local-resolver"}
+
+ server := &DefaultServer{
+ handlerChain: NewHandlerChain(),
+ service: &mockService{},
+ }
+
+ // Two custom zones under the same handler. The surviving zone is registered
+ // last, mirroring the management emission order.
+ server.updateMux([]handlerWrapper{
+ {domain: "userzone.test", handler: shared, priority: PriorityLocal},
+ {domain: "peerzone.test", handler: shared, priority: PriorityLocal},
+ })
+
+ require.True(t, chainHasPattern(server, "userzone.test.", PriorityLocal),
+ "userzone.test should be registered after the first update")
+ require.True(t, chainHasPattern(server, "peerzone.test.", PriorityLocal),
+ "peerzone.test should be registered after the first update")
+
+ // Remove one zone, keep the other.
+ server.updateMux([]handlerWrapper{
+ {domain: "peerzone.test", handler: shared, priority: PriorityLocal},
+ })
+
+ assert.True(t, chainHasPattern(server, "peerzone.test.", PriorityLocal),
+ "peerzone.test should remain after removing userzone.test")
+ assert.False(t, chainHasPattern(server, "userzone.test.", PriorityLocal),
+ "userzone.test handler must be deregistered, not leaked in the chain")
+}
+
+// TestDefaultServer_UpdateMux_PreservesLocalResolver verifies that updateMux
+// does not tear down the shared local resolver during reconfiguration. The
+// resolver is a process-lifetime singleton reused across config updates;
+// Stop() cancels its lookup context (breaking external CNAME-target
+// resolution) and clears its records. updateMux must deregister its chain
+// entries without stopping it. Records surviving a teardown update is the
+// observable proxy: Stop() would have cleared them.
+func TestDefaultServer_UpdateMux_PreservesLocalResolver(t *testing.T) {
+ resolver := local.NewResolver()
+ require.NoError(t, resolver.RegisterRecord(nbdns.SimpleRecord{
+ Name: "peer.netbird.cloud.",
+ Type: int(dns.TypeA),
+ Class: nbdns.DefaultClass,
+ TTL: 300,
+ RData: "10.0.0.1",
+ }))
+
+ server := &DefaultServer{
+ handlerChain: NewHandlerChain(),
+ service: &mockService{},
+ localResolver: resolver,
+ }
+
+ server.updateMux([]handlerWrapper{
+ {domain: "netbird.cloud", handler: resolver, priority: PriorityLocal},
+ })
+
+ // Remove the zone. The resolver must survive so its records and lookup
+ // context stay intact for the next registration.
+ server.updateMux(nil)
+
+ var response *dns.Msg
+ resolver.ServeDNS(&test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ response = m
+ return nil
+ },
+ }, &dns.Msg{Question: []dns.Question{{Name: "peer.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}}})
+
+ require.NotNil(t, response, "local resolver should answer after teardown")
+ assert.Equal(t, dns.RcodeSuccess, response.Rcode,
+ "local resolver records must survive teardown; updateMux must not Stop() the shared resolver")
+ assert.NotEmpty(t, response.Answer, "answer should contain the surviving record")
+}
+
func TestExtraDomains(t *testing.T) {
tests := []struct {
name string
@@ -2049,7 +1674,6 @@ func TestBuildUpstreamHandler_MergesGroupsPerDomain(t *testing.T) {
localResolver: local.NewResolver(),
handlerChain: NewHandlerChain(),
hostManager: &noopHostConfigurator{},
- dnsMuxMap: make(registeredHandlerMap),
}
groups := []*nbdns.NameServerGroup{
@@ -2207,7 +1831,7 @@ func TestEvaluateNSGroupHealth(t *testing.T) {
}
}
-// healthStubHandler is a minimal dnsMuxMap entry that exposes a fixed
+// healthStubHandler is a minimal dnsMuxHandlers entry that exposes a fixed
// UpstreamHealth snapshot, letting tests drive recomputeNSGroupStates
// without spinning up real handlers.
type healthStubHandler struct {
@@ -2283,12 +1907,11 @@ func newProjTestFixture(t *testing.T) *projTestFixture {
ctx: context.Background(),
wgInterface: &mocWGIface{},
statusRecorder: recorder,
- dnsMuxMap: make(registeredHandlerMap),
selectedRoutes: func() route.HAMap { return fx.selected },
activeRoutes: func() route.HAMap { return fx.active },
warningDelayBase: defaultWarningDelayBase,
}
- fx.server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: fx.stub, priority: PriorityUpstream}
+ fx.server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: fx.stub, priority: PriorityUpstream}}
fx.server.mux.Lock()
fx.server.updateNSGroupStates([]*nbdns.NameServerGroup{fx.group})
@@ -2395,7 +2018,6 @@ func TestProjection_OverlayAddrNoRouteDelaysWarning(t *testing.T) {
ctx: context.Background(),
wgInterface: &mocWGIface{},
statusRecorder: recorder,
- dnsMuxMap: make(registeredHandlerMap),
selectedRoutes: func() route.HAMap { return nil },
activeRoutes: func() route.HAMap { return nil },
warningDelayBase: 50 * time.Millisecond,
@@ -2407,7 +2029,7 @@ func TestProjection_OverlayAddrNoRouteDelaysWarning(t *testing.T) {
stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{
overlayPeer: {LastFail: time.Now(), LastErr: "timeout"},
}}
- server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
+ server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}}
server.mux.Lock()
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
@@ -2444,7 +2066,6 @@ func TestProjection_StopClearsHealthState(t *testing.T) {
service: NewServiceViaMemory(wgIface),
hostManager: &noopHostConfigurator{},
extraDomains: map[domain.Domain]int{},
- dnsMuxMap: make(registeredHandlerMap),
statusRecorder: peer.NewRecorder("mgm"),
selectedRoutes: func() route.HAMap { return nil },
activeRoutes: func() route.HAMap { return nil },
@@ -2459,7 +2080,7 @@ func TestProjection_StopClearsHealthState(t *testing.T) {
NameServers: []nbdns.NameServer{{IP: srv.Addr(), NSType: nbdns.UDPNameServerType, Port: int(srv.Port())}},
}
stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{srv: {LastOk: time.Now()}}}
- server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
+ server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}}
server.mux.Lock()
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
@@ -2484,6 +2105,32 @@ func TestProjection_StopClearsHealthState(t *testing.T) {
// rule 3: startup failures while the peer is handshaking, then the peer
// comes up and a query succeeds before the grace window elapses. No
// warning should ever have fired, and no recovery either.
+func TestWarningDelayBaseFromEnv(t *testing.T) {
+ tests := []struct {
+ name string
+ set bool
+ val string
+ want time.Duration
+ }{
+ {name: "unset uses default", set: false, want: defaultWarningDelayBase},
+ {name: "valid override", set: true, val: "90s", want: 90 * time.Second},
+ {name: "valid minutes", set: true, val: "2m", want: 2 * time.Minute},
+ {name: "invalid falls back", set: true, val: "notaduration", want: defaultWarningDelayBase},
+ {name: "zero falls back", set: true, val: "0s", want: defaultWarningDelayBase},
+ {name: "negative falls back", set: true, val: "-30s", want: defaultWarningDelayBase},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(envWarningDelay, tc.val)
+ if !tc.set {
+ os.Unsetenv(envWarningDelay)
+ }
+ assert.Equal(t, tc.want, warningDelayBaseFromEnv(), "grace window base")
+ })
+ }
+}
+
func TestProjection_OverlayRecoversDuringGrace(t *testing.T) {
fx := newProjTestFixture(t)
fx.server.warningDelayBase = 200 * time.Millisecond
@@ -2595,7 +2242,6 @@ func TestProjection_MixedGroupEmitsImmediately(t *testing.T) {
server := &DefaultServer{
ctx: context.Background(),
statusRecorder: recorder,
- dnsMuxMap: make(registeredHandlerMap),
selectedRoutes: func() route.HAMap { return overlayMap },
activeRoutes: func() route.HAMap { return nil },
warningDelayBase: time.Hour,
@@ -2613,7 +2259,7 @@ func TestProjection_MixedGroupEmitsImmediately(t *testing.T) {
overlay: {LastFail: time.Now(), LastErr: "timeout"},
},
}
- server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
+ server.dnsMuxHandlers = []handlerWrapper{{domain: "example.com", handler: stub, priority: PriorityUpstream}}
server.mux.Lock()
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
@@ -2640,7 +2286,6 @@ func TestDNSLoopPrevention(t *testing.T) {
localResolver: local.NewResolver(),
handlerChain: NewHandlerChain(),
hostManager: &noopHostConfigurator{},
- dnsMuxMap: make(registeredHandlerMap),
}
tests := []struct {
diff --git a/client/internal/dns/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/upstream.go b/client/internal/dns/upstream.go
index a4f713d68..72fc0450c 100644
--- a/client/internal/dns/upstream.go
+++ b/client/internal/dns/upstream.go
@@ -443,29 +443,32 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.M
return raceResult{}, &upstreamFailure{upstream: upstream, reason: "no response"}
}
+ // A valid response means the upstream is reachable, whatever the Rcode.
+ u.markUpstreamOk(upstream)
+
proto := ""
if upstreamProto != nil {
proto = upstreamProto.protocol
}
if rm.Rcode == dns.RcodeServerFailure || rm.Rcode == dns.RcodeRefused {
+ // SERVFAIL and REFUSED are per-question outcomes (DNSSEC-bogus names,
+ // refused zones, transient recursion errors), not reachability
+ // problems: fail over for a better answer but keep the upstream healthy.
if code, ok := nonRetryableEDE(rm); ok {
if !hadEdns {
- stripOPT(rm)
+ resutil.StripOPT(rm)
}
- u.markUpstreamOk(upstream)
return raceResult{msg: rm, upstream: upstream, protocol: proto, ede: edeName(code)}, nil
}
reason := dns.RcodeToString[rm.Rcode]
- u.markUpstreamFail(upstream, reason)
return raceResult{}, &upstreamFailure{upstream: upstream, reason: reason}
}
if !hadEdns {
- stripOPT(rm)
+ resutil.StripOPT(rm)
}
- u.markUpstreamOk(upstream)
return raceResult{msg: rm, upstream: upstream, protocol: proto}, nil
}
@@ -520,22 +523,6 @@ func upstreamUDPSize() uint16 {
return dns.MinMsgSize
}
-// stripOPT removes any OPT pseudo-RRs from the response's Extra section so
-// the response complies with RFC 6891 when the client did not advertise EDNS0.
-func stripOPT(rm *dns.Msg) {
- if len(rm.Extra) == 0 {
- return
- }
- out := rm.Extra[:0]
- for _, rr := range rm.Extra {
- if _, ok := rr.(*dns.OPT); ok {
- continue
- }
- out = append(out, rr)
- }
- rm.Extra = out
-}
-
func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.AddrPort, startTime time.Time) *upstreamFailure {
if !errors.Is(err, context.DeadlineExceeded) && !isTimeout(err) {
return &upstreamFailure{upstream: upstream, reason: err.Error()}
diff --git a/client/internal/dns/upstream_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 8b3c589f1..4c2784545 100644
--- a/client/internal/dns/upstream_test.go
+++ b/client/internal/dns/upstream_test.go
@@ -517,6 +517,78 @@ func TestUpstreamResolver_HealthTracking(t *testing.T) {
assert.NotContains(t, health, bad, "sibling upstream should not be queried when primary answers")
}
+// TestUpstreamResolver_HealthTracking_ResponseMeansReachable verifies that an
+// upstream which answers with SERVFAIL or REFUSED is recorded as healthy:
+// those are per-question outcomes from a reachable server and must not mark
+// the upstream unhealthy. Only transport failures (timeouts) do.
+func TestUpstreamResolver_HealthTracking_ResponseMeansReachable(t *testing.T) {
+ a := netip.MustParseAddrPort("192.0.2.10:53")
+ b := netip.MustParseAddrPort("192.0.2.11:53")
+ timeoutErr := &net.OpError{Op: "read", Err: fmt.Errorf("i/o timeout")}
+
+ tests := []struct {
+ name string
+ respA mockUpstreamResponse
+ respB mockUpstreamResponse
+ wantHealthy bool
+ }{
+ {
+ name: "both SERVFAIL are reachable",
+ respA: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")},
+ respB: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")},
+ wantHealthy: true,
+ },
+ {
+ name: "both REFUSED are reachable",
+ respA: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")},
+ respB: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")},
+ wantHealthy: true,
+ },
+ {
+ name: "timeout marks unhealthy",
+ respA: mockUpstreamResponse{err: timeoutErr},
+ respB: mockUpstreamResponse{err: timeoutErr},
+ wantHealthy: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ mockClient := &mockUpstreamResolverPerServer{
+ responses: map[string]mockUpstreamResponse{
+ a.String(): tc.respA,
+ b.String(): tc.respB,
+ },
+ rtt: time.Millisecond,
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ resolver := &upstreamResolverBase{
+ ctx: ctx,
+ upstreamClient: mockClient,
+ upstreamTimeout: UpstreamTimeout,
+ }
+ resolver.addRace([]netip.AddrPort{a, b})
+
+ responseWriter := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { return nil }}
+ resolver.ServeDNS(responseWriter, new(dns.Msg).SetQuestion("example.com.", dns.TypeA))
+
+ health := resolver.UpstreamHealth()
+ require.Contains(t, health, a, "primary upstream should have a health record")
+ if tc.wantHealthy {
+ assert.False(t, health[a].LastOk.IsZero(), "responding upstream should have LastOk set")
+ assert.True(t, health[a].LastFail.IsZero(), "responding upstream should not be marked failed")
+ assert.Empty(t, health[a].LastErr, "responding upstream should have no error")
+ } else {
+ assert.False(t, health[a].LastFail.IsZero(), "timed-out upstream should be marked failed")
+ assert.NotEmpty(t, health[a].LastErr, "timed-out upstream should record an error")
+ }
+ })
+ }
+}
+
func TestFormatFailures(t *testing.T) {
testCases := []struct {
name string
@@ -913,19 +985,6 @@ func TestEDEName(t *testing.T) {
assert.Equal(t, "EDE 9999", edeName(9999), "unknown code falls back to numeric")
}
-func TestStripOPT(t *testing.T) {
- rm := &dns.Msg{
- Extra: []dns.RR{
- &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}},
- &dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)},
- },
- }
- stripOPT(rm)
- assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept")
- _, isOPT := rm.Extra[0].(*dns.OPT)
- assert.False(t, isOPT, "remaining record must not be OPT")
-}
-
func TestUpstreamResolver_NonRetryableEDEShortCircuits(t *testing.T) {
upstream1 := netip.MustParseAddrPort("192.0.2.1:53")
upstream2 := netip.MustParseAddrPort("192.0.2.2:53")
diff --git a/client/internal/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/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/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 3bd0d4621..f4f47992f 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -22,7 +22,6 @@ import (
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
- "google.golang.org/protobuf/proto"
nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/client/firewall"
@@ -41,6 +40,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"
@@ -54,8 +54,8 @@ import (
"github.com/netbirdio/netbird/client/internal/relay"
"github.com/netbirdio/netbird/client/internal/rosenpass"
"github.com/netbirdio/netbird/client/internal/routemanager"
- "github.com/netbirdio/netbird/client/internal/routemanager/systemops"
"github.com/netbirdio/netbird/client/internal/statemanager"
+ "github.com/netbirdio/netbird/client/internal/syncstore"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/jobexec"
cProto "github.com/netbirdio/netbird/client/proto"
@@ -64,7 +64,10 @@ import (
"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"
@@ -72,6 +75,7 @@ import (
sProto "github.com/netbirdio/netbird/shared/signal/proto"
"github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/util/capture"
+ "github.com/netbirdio/netbird/version"
)
// PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer.
@@ -82,10 +86,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 +150,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
@@ -148,6 +163,10 @@ type EngineConfig struct {
LogPath string
TempDir string
+
+ // StateDir is the directory holding the state file. The sync response
+ // (network map) is serialized here on platforms that persist it to disk.
+ StateDir string
}
// EngineServices holds the external service dependencies required by the Engine.
@@ -160,6 +179,7 @@ type EngineServices struct {
StateManager *statemanager.Manager
UpdateManager *updater.Manager
ClientMetrics *metrics.ClientMetrics
+ MetricsCtx context.Context
}
// Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers.
@@ -195,6 +215,8 @@ type Engine struct {
ctx context.Context
cancel context.CancelFunc
+ started bool
+
wgInterface WGIface
udpMux *udpmux.UniversalUDPMuxDefault
@@ -202,6 +224,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
@@ -226,11 +255,16 @@ type Engine struct {
afpacketCapture *capture.AFPacketCapture
- // Sync response persistence (protected by syncRespMux)
- syncRespMux sync.RWMutex
- persistSyncResponse bool
- latestSyncResponse *mgmProto.SyncResponse
- flowManager nftypes.FlowManager
+ // Sync response persistence (protected by syncRespMux).
+ // syncStore is nil unless persistence has been enabled; its presence is
+ // what marks persistence as active. The backend (disk or memory) is
+ // selected per-platform; see the syncstore package. syncStoreDir is where
+ // a disk-backed store serializes to.
+ syncRespMux sync.RWMutex
+ syncStore syncstore.Store
+ syncStoreDir string
+
+ flowManager nftypes.FlowManager
// auto-update
updateManager *updater.Manager
@@ -245,11 +279,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
@@ -270,9 +319,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,
@@ -291,8 +346,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
@@ -304,8 +372,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()
}
@@ -333,6 +427,10 @@ func (e *Engine) Stop() error {
e.srWatcher.Close()
}
+ if e.sessionWatcher != nil {
+ e.sessionWatcher.Close()
+ }
+
if e.updateManager != nil {
e.updateManager.SetDownloadOnly()
}
@@ -356,10 +454,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()
@@ -378,21 +472,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.
@@ -430,18 +509,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()
@@ -463,7 +562,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)
}
@@ -473,15 +572,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
@@ -498,10 +590,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,
@@ -516,12 +606,14 @@ 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)
}
+ if filteredDevice := e.wgInterface.GetDevice(); filteredDevice != nil {
+ filteredDevice.SetPanicHandler(e.triggerClientRestart)
+ }
+
if err := e.createFirewall(); err != nil {
- e.close()
return err
}
@@ -533,7 +625,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)
}
@@ -558,21 +649,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()
@@ -624,7 +731,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)
}
@@ -850,6 +956,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() {
@@ -865,68 +981,87 @@ 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 update.GetNetbirdConfig() != nil {
- wCfg := update.GetNetbirdConfig()
- err := e.updateTURNs(wCfg.GetTurns())
- if err != nil {
- return fmt.Errorf("update TURNs: %w", err)
- }
-
- err = e.updateSTUNs(wCfg.GetStuns())
- if err != nil {
- return fmt.Errorf("update STUNs: %w", err)
- }
-
- var stunTurn []*stun.URI
- stunTurn = append(stunTurn, e.STUNs...)
- stunTurn = append(stunTurn, e.TURNs...)
- e.stunTurn.Store(stunTurn)
-
- err = e.handleRelayUpdate(wCfg.GetRelay())
- if err != nil {
- return err
- }
-
- err = e.handleFlowUpdate(wCfg.GetFlow())
- if err != nil {
- return fmt.Errorf("handle the flow configuration: %w", err)
- }
-
- if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil {
- log.Warnf("Failed to update DNS server config: %v", err)
- }
-
- // todo update signal
- }
-
- if err := e.updateChecksIfNew(update.Checks); err != nil {
+ done := e.phase("netbird_config")
+ err := e.updateNetbirdConfig(update.GetNetbirdConfig())
+ done()
+ if err != nil {
return err
}
- nm := update.GetNetworkMap()
+ // 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
if nm == nil {
return nil
}
- // Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
- // Read the storage-enabled flag under the syncRespMux too.
- e.syncRespMux.RLock()
- enabled := e.persistSyncResponse
- e.syncRespMux.RUnlock()
-
- // Store sync response if persistence is enabled
- if enabled {
- e.syncRespMux.Lock()
- e.latestSyncResponse = update
- e.syncRespMux.Unlock()
-
- log.Debugf("sync response persisted with serial %d", nm.GetSerial())
+ 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 {
return err
@@ -937,6 +1072,79 @@ 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.
+func (e *Engine) updateNetbirdConfig(wCfg *mgmProto.NetbirdConfig) error {
+ if wCfg == nil {
+ return nil
+ }
+
+ if err := e.updateTURNs(wCfg.GetTurns()); err != nil {
+ return fmt.Errorf("update TURNs: %w", err)
+ }
+
+ if err := e.updateSTUNs(wCfg.GetStuns()); err != nil {
+ return fmt.Errorf("update STUNs: %w", err)
+ }
+
+ var stunTurn []*stun.URI
+ stunTurn = append(stunTurn, e.STUNs...)
+ stunTurn = append(stunTurn, e.TURNs...)
+ e.stunTurn.Store(stunTurn)
+
+ if err := e.handleRelayUpdate(wCfg.GetRelay()); err != nil {
+ return err
+ }
+
+ if err := e.handleFlowUpdate(wCfg.GetFlow()); err != nil {
+ 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)
+ }
+
+ // todo update signal
+
+ return nil
+}
+
+// persistSyncResponse stores the full sync response so it can be restored on the next
+// startup. Persistence is enabled only when syncStore is set. The dedicated syncRespMux
+// (not syncMsgMux) is held for the whole Set so the store cannot be cleared (disabled /
+// engine close) mid-call and have this write resurrect a file that was just removed.
+func (e *Engine) persistSyncResponse(update *mgmProto.SyncResponse) {
+ e.syncRespMux.RLock()
+ defer e.syncRespMux.RUnlock()
+
+ if e.syncStore == nil {
+ return
+ }
+
+ if err := e.syncStore.Set(update); err != nil {
+ log.Errorf("failed to persist sync response: %v", err)
+ return
+ }
+
+ log.Debugf("sync response persisted with serial %d", update.GetNetworkMap().GetSerial())
+}
+
func (e *Engine) handleRelayUpdate(update *mgmProto.RelayConfig) error {
if update != nil {
// when we receive token we expect valid address list too
@@ -977,6 +1185,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")
@@ -1001,11 +1217,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,
@@ -1017,19 +1244,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 {
@@ -1063,6 +1298,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
state.PubKey = e.config.WgPrivateKey.PublicKey().String()
state.KernelInterface = !e.wgInterface.IsUserspaceBind()
state.FQDN = conf.GetFqdn()
+ state.WgPort = e.config.WgPort
e.statusRecorder.UpdateLocalPeerState(state)
@@ -1141,8 +1377,9 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
LogPath: e.config.LogPath,
TempDir: e.config.TempDir,
ClientMetrics: e.clientMetrics,
+ DaemonVersion: version.NetbirdVersion(),
RefreshStatus: func() {
- e.RunHealthProbes(true)
+ e.RunHealthProbes(e.ctx, true)
},
}
@@ -1173,31 +1410,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
@@ -1290,13 +1511,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)
@@ -1305,29 +1529,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()))
@@ -1342,42 +1597,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 {
@@ -1662,7 +1918,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()
@@ -1678,6 +1934,13 @@ func (e *Engine) receiveSignalEvents() {
return e.ctx.Err()
}
+ // Self-addressed heartbeat: the signal client's receive watchdog
+ // round-trips this through the server to confirm the receive stream
+ // is delivering. Liveness is already recorded before this handler.
+ if msg.GetBody().GetType() == sProto.Body_HEARTBEAT {
+ return nil
+ }
+
conn, ok := e.peerStore.PeerConn(msg.Key)
if !ok {
return fmt.Errorf("wrongly addressed message %s", msg.Key)
@@ -1726,7 +1989,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 {
@@ -1813,42 +2081,18 @@ func (e *Engine) close() {
if err := e.portForwardManager.GracefullyStop(ctx); err != nil {
log.Warnf("failed to gracefully stop port forwarding manager: %s", err)
}
-}
-func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) {
- if runtime.GOOS != "android" {
- // nolint:nilnil
- return nil, nil, false, nil
+ // Drop any persisted sync response so its network map does not linger on
+ // disk after the engine stops (and cannot leak into a later run).
+ e.syncRespMux.Lock()
+ store := e.syncStore
+ e.syncStore = nil
+ e.syncRespMux.Unlock()
+ if store != nil {
+ if err := store.Clear(); err != nil {
+ log.Warnf("failed to clear persisted sync response on close: %v", err)
+ }
}
-
- 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) {
@@ -1864,7 +2108,6 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
WGPrivKey: e.config.WgPrivateKey.String(),
MTU: e.config.MTU,
TransportNet: transportNet,
- FilterFn: e.addrViaRoutes,
DisableDNS: e.config.DisableDNS,
}
@@ -1886,7 +2129,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() {
@@ -1899,7 +2142,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
@@ -1911,7 +2154,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,
@@ -1967,6 +2210,29 @@ func (e *Engine) GetClientMetrics() *metrics.ClientMetrics {
return e.clientMetrics
}
+// Performance bundles runtime-adjustable tunnel pool knobs.
+// See Engine.SetPerformance. Nil fields are ignored.
+type Performance struct {
+ PreallocatedBuffersPerPool *uint32
+}
+
+// SetPerformance applies the given tuning to this engine's live Device.
+func (e *Engine) SetPerformance(t Performance) error {
+ e.syncMsgMux.Lock()
+ defer e.syncMsgMux.Unlock()
+ if e.wgInterface == nil {
+ return fmt.Errorf("wg interface not initialized")
+ }
+ dev := e.wgInterface.GetWGDevice()
+ if dev == nil {
+ return fmt.Errorf("wg device not initialized")
+ }
+ if t.PreallocatedBuffersPerPool != nil {
+ dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool)
+ }
+ return nil
+}
+
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
iface, err := net.InterfaceByName(ifaceName)
if err != nil {
@@ -2004,7 +2270,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()
@@ -2027,9 +2306,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)
@@ -2089,21 +2368,6 @@ func (e *Engine) startNetworkMonitor() {
}()
}
-func (e *Engine) addrViaRoutes(addr netip.Addr) (bool, netip.Prefix, error) {
- var vpnRoutes []netip.Prefix
- for _, routes := range e.routeManager.GetClientRoutes() {
- if len(routes) > 0 && routes[0] != nil {
- vpnRoutes = append(vpnRoutes, routes[0].Network)
- }
- }
-
- if isVpn, prefix := systemops.IsAddrRouted(addr, vpnRoutes); isVpn {
- return true, prefix, nil
- }
-
- return false, netip.Prefix{}, nil
-}
-
func (e *Engine) stopDNSServer() {
if e.dnsServer == nil {
return
@@ -2119,45 +2383,42 @@ func (e *Engine) stopDNSServer() {
e.statusRecorder.UpdateDNSStates(nsGroupStates)
}
-// SetSyncResponsePersistence enables or disables sync response persistence
+// SetSyncResponsePersistence enables or disables sync response persistence.
+// The store is only instantiated while persistence is enabled; construction
+// itself drops any stale data left over from an earlier run (see syncstore).
func (e *Engine) SetSyncResponsePersistence(enabled bool) {
e.syncRespMux.Lock()
defer e.syncRespMux.Unlock()
- if enabled == e.persistSyncResponse {
+ if enabled == (e.syncStore != nil) {
return
}
- e.persistSyncResponse = enabled
log.Debugf("Sync response persistence is set to %t", enabled)
if !enabled {
- e.latestSyncResponse = nil
+ if err := e.syncStore.Clear(); err != nil {
+ log.Warnf("failed to clear persisted sync response: %v", err)
+ }
+ e.syncStore = nil
+ return
}
+
+ e.syncStore = syncstore.New(e.syncStoreDir)
}
// GetLatestSyncResponse returns the stored sync response if persistence is enabled
func (e *Engine) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) {
+ // Hold the lock for the whole Get so the store cannot be cleared
+ // (disabled / engine close) mid-call.
e.syncRespMux.RLock()
- enabled := e.persistSyncResponse
- latest := e.latestSyncResponse
- e.syncRespMux.RUnlock()
+ defer e.syncRespMux.RUnlock()
- if !enabled {
+ if e.syncStore == nil {
return nil, errors.New("sync response persistence is disabled")
}
- if latest == nil {
- //nolint:nilnil
- return nil, nil
- }
-
- log.Debugf("Retrieving latest sync response with size %d bytes", proto.Size(latest))
- sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
- if !ok {
- return nil, fmt.Errorf("failed to clone sync response")
- }
-
- return sr, nil
+ //nolint:nilnil
+ return e.syncStore.Get()
}
// GetWgAddr returns the wireguard address
@@ -2193,7 +2454,7 @@ func (e *Engine) updateDNSForwarder(
enabled bool,
fwdEntries []*dnsfwd.ForwarderEntry,
) {
- if e.config.DisableServerRoutes {
+ if e.config.DisableServerRoutes || e.config.BlockInbound {
return
}
@@ -2390,13 +2651,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
}
@@ -2406,6 +2668,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..f787f741f
--- /dev/null
+++ b/client/internal/engine_privileged_test.go
@@ -0,0 +1,565 @@
+//go:build privileged
+
+package internal
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/google/uuid"
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/keepalive"
+
+ "github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/client/iface/device"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+ "github.com/netbirdio/netbird/client/internal/dns"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ nbssh "github.com/netbirdio/netbird/client/ssh"
+ "github.com/netbirdio/netbird/client/system"
+ nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ "github.com/netbirdio/netbird/management/internals/server/config"
+ nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+ "github.com/netbirdio/netbird/management/server"
+ "github.com/netbirdio/netbird/management/server/activity"
+ nbcache "github.com/netbirdio/netbird/management/server/cache"
+ "github.com/netbirdio/netbird/management/server/groups"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
+ "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/job"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/settings"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/telemetry"
+ "github.com/netbirdio/netbird/management/server/types"
+ mgmt "github.com/netbirdio/netbird/shared/management/client"
+ mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
+ relayClient "github.com/netbirdio/netbird/shared/relay/client"
+ signal "github.com/netbirdio/netbird/shared/signal/client"
+ "github.com/netbirdio/netbird/shared/signal/proto"
+ signalServer "github.com/netbirdio/netbird/signal/server"
+ "github.com/netbirdio/netbird/util"
+)
+
+func TestEngine_SSH(t *testing.T) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ engine := NewEngine(
+ ctx, cancel,
+ &EngineConfig{
+ WgIfaceName: "utun101",
+ WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
+ WgPrivateKey: key,
+ WgPort: 33100,
+ ServerSSHAllowed: true,
+ MTU: iface.DefaultMTU,
+ SSHKey: sshKey,
+ },
+ EngineServices{
+ SignalClient: &signal.MockClient{},
+ MgmClient: &mgmt.MockClient{},
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ },
+ MobileDependency{},
+ )
+
+ engine.dnsServer = &dns.MockServer{
+ UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
+ }
+
+ err = engine.Start(nil, nil)
+ require.NoError(t, err)
+
+ defer func() {
+ err := engine.Stop()
+ if err != nil {
+ return
+ }
+ }()
+
+ peerWithSSH := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
+ AllowedIps: []string{"100.64.0.21/24"},
+ SshConfig: &mgmtProto.SSHConfig{
+ SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"),
+ },
+ }
+
+ // SSH server is not enabled so SSH config of a remote peer should be ignored
+ networkMap := &mgmtProto.NetworkMap{
+ Serial: 6,
+ PeerConfig: nil,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ assert.Nil(t, engine.sshServer)
+
+ // SSH server is enabled, therefore SSH config should be applied
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 7,
+ PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
+ SshConfig: &mgmtProto.SSHConfig{
+ SshEnabled: true,
+ JwtConfig: &mgmtProto.JWTConfig{
+ Issuer: "test-issuer",
+ Audience: "test-audience",
+ KeysLocation: "test-keys",
+ MaxTokenAge: 3600,
+ },
+ }},
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ time.Sleep(250 * time.Millisecond)
+ assert.NotNil(t, engine.sshServer)
+
+ // now remove peer
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 8,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ // time.Sleep(250 * time.Millisecond)
+ assert.NotNil(t, engine.sshServer)
+
+ // now disable SSH server
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 9,
+ PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
+ SshConfig: &mgmtProto.SSHConfig{SshEnabled: false}},
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ assert.Nil(t, engine.sshServer)
+}
+
+func TestEngine_Sync(t *testing.T) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ // feed updates to Engine via mocked Management client
+ updates := make(chan *mgmtProto.SyncResponse)
+ defer close(updates)
+ syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
+ for msg := range updates {
+ err := msgHandler(msg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ return nil
+ }
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ engine := NewEngine(ctx, cancel, &EngineConfig{
+ WgIfaceName: "utun103",
+ WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
+ WgPrivateKey: key,
+ WgPort: 33100,
+ MTU: iface.DefaultMTU,
+ }, EngineServices{
+ SignalClient: &signal.MockClient{},
+ MgmClient: &mgmt.MockClient{SyncFunc: syncFunc},
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ }, MobileDependency{})
+ engine.ctx = ctx
+
+ engine.dnsServer = &dns.MockServer{
+ UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
+ }
+
+ defer func() {
+ err := engine.Stop()
+ if err != nil {
+ return
+ }
+ }()
+
+ err = engine.Start(nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ peer1 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
+ AllowedIps: []string{"100.64.0.10/24"},
+ }
+ peer2 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
+ AllowedIps: []string{"100.64.0.11/24"},
+ }
+ peer3 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "GGHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
+ AllowedIps: []string{"100.64.0.12/24"},
+ }
+ // 1st update with just 1 peer and serial larger than the current serial of the engine => apply update
+ updates <- &mgmtProto.SyncResponse{
+ NetworkMap: &mgmtProto.NetworkMap{
+ Serial: 10,
+ PeerConfig: nil,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3},
+ RemotePeersIsEmpty: false,
+ },
+ }
+
+ timeout := time.After(time.Second * 2)
+ for {
+ select {
+ case <-timeout:
+ t.Fatalf("timeout while waiting for test to finish")
+ return
+ default:
+ }
+
+ if getPeers(engine) == 3 && engine.networkSerial == 10 {
+ break
+ }
+ }
+}
+
+func TestEngine_MultiplePeers(t *testing.T) {
+ // log.SetLevel(log.DebugLevel)
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ sigServer, signalAddr, err := startSignal(t)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+ defer sigServer.Stop()
+ mgmtServer, mgmtAddr, err := startManagement(t, t.TempDir(), "../testdata/store.sql")
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+ defer mgmtServer.GracefulStop()
+
+ setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB"
+
+ mu := sync.Mutex{}
+ engines := []*Engine{}
+ numPeers := 10
+ wg := sync.WaitGroup{}
+ wg.Add(numPeers)
+ // create and start peers
+ for i := 0; i < numPeers; i++ {
+ j := i
+ go func() {
+ engine, err := createEngine(ctx, cancel, setupKey, j, mgmtAddr, signalAddr)
+ if err != nil {
+ wg.Done()
+ t.Errorf("unable to create the engine for peer %d with error %v", j, err)
+ return
+ }
+ engine.dnsServer = &dns.MockServer{}
+ mu.Lock()
+ defer mu.Unlock()
+ guid := fmt.Sprintf("{%s}", uuid.New().String())
+ device.CustomWindowsGUIDString = strings.ToLower(guid)
+ err = engine.Start(nil, nil)
+ if err != nil {
+ t.Errorf("unable to start engine for peer %d with error %v", j, err)
+ wg.Done()
+ return
+ }
+ engines = append(engines, engine)
+ wg.Done()
+ }()
+ }
+
+ // wait until all have been created and started
+ wg.Wait()
+ if len(engines) != numPeers {
+ t.Fatal("not all peers were started")
+ }
+ // check whether all the peer have expected peers connected
+
+ expectedConnected := numPeers * (numPeers - 1)
+
+ // adjust according to timeouts
+ timeout := 50 * time.Second
+ timeoutChan := time.After(timeout)
+ ticker := time.NewTicker(time.Second)
+ defer ticker.Stop()
+loop:
+ for {
+ select {
+ case <-timeoutChan:
+ t.Fatalf("waiting for expected connections timeout after %s", timeout.String())
+ break loop
+ case <-ticker.C:
+ totalConnected := 0
+ for _, engine := range engines {
+ totalConnected += getConnectedPeers(engine)
+ }
+ if totalConnected == expectedConnected {
+ log.Infof("total connected=%d", totalConnected)
+ break loop
+ }
+ log.Infof("total connected=%d", totalConnected)
+ }
+ }
+ // cleanup test
+ for n, peerEngine := range engines {
+ t.Logf("stopping peer with interface %s from multipeer test, loopIndex %d", peerEngine.wgInterface.Name(), n)
+ errStop := peerEngine.mgmClient.Close()
+ if errStop != nil {
+ log.Infoln("got error trying to close management clients from engine: ", errStop)
+ }
+ errStop = peerEngine.Stop()
+ if errStop != nil {
+ log.Infoln("got error trying to close testing peers engine: ", errStop)
+ }
+ }
+}
+
+var (
+ kaep = keepalive.EnforcementPolicy{
+ MinTime: 15 * time.Second,
+ PermitWithoutStream: true,
+ }
+
+ kasp = keepalive.ServerParameters{
+ MaxConnectionIdle: 15 * time.Second,
+ MaxConnectionAgeGrace: 5 * time.Second,
+ Time: 5 * time.Second,
+ Timeout: 2 * time.Second,
+ }
+)
+
+func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey string, i int, mgmtAddr string, signalAddr string) (*Engine, error) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ return nil, err
+ }
+ mgmtClient, err := mgmt.NewClient(ctx, mgmtAddr, key, false)
+ if err != nil {
+ return nil, err
+ }
+ signalClient, err := signal.NewClient(ctx, signalAddr, key, false)
+ if err != nil {
+ return nil, err
+ }
+
+ info := system.GetInfo(ctx)
+ resp, err := mgmtClient.Register(setupKey, "", info, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var ifaceName string
+ if runtime.GOOS == "darwin" {
+ ifaceName = fmt.Sprintf("utun1%d", i)
+ } else {
+ ifaceName = fmt.Sprintf("wt%d", i)
+ }
+
+ wgPort := 33100 + i
+ conf := &EngineConfig{
+ WgIfaceName: ifaceName,
+ WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address),
+ WgPrivateKey: key,
+ WgPort: wgPort,
+ MTU: iface.DefaultMTU,
+ }
+
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ e, err := NewEngine(ctx, cancel, conf, EngineServices{
+ SignalClient: signalClient,
+ MgmClient: mgmtClient,
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ }, MobileDependency{}), nil
+ e.ctx = ctx
+ return e, err
+}
+
+func startSignal(t *testing.T) (*grpc.Server, string, error) {
+ t.Helper()
+
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ log.Fatalf("failed to listen: %v", err)
+ }
+
+ srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
+ require.NoError(t, err)
+ proto.RegisterSignalExchangeServer(s, srv)
+
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
+
+func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, string, error) {
+ t.Helper()
+
+ config := &config.Config{
+ Stuns: []*config.Host{},
+ TURNConfig: &config.TURNConfig{},
+ Relay: &config.Relay{
+ Addresses: []string{"127.0.0.1:1234"},
+ CredentialsTTL: util.Duration{Duration: time.Hour},
+ Secret: "222222222222222222",
+ },
+ Signal: &config.Host{
+ Proto: "http",
+ URI: "localhost:10000",
+ },
+ Datadir: dataDir,
+ HttpConfig: nil,
+ }
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return nil, "", err
+ }
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+
+ store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), testFile, config.Datadir)
+ if err != nil {
+ return nil, "", err
+ }
+ t.Cleanup(cleanUp)
+
+ eventStore := &activity.InMemoryEventStore{}
+ if err != nil {
+ return nil, "", err
+ }
+
+ permissionsManager := permissions.NewManager(store)
+ peersManager := peers.NewManager(store, permissionsManager)
+ jobManager := job.NewJobManager(nil, store, peersManager)
+
+ cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
+ if err != nil {
+ return nil, "", err
+ }
+
+ ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
+
+ metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
+ require.NoError(t, err)
+
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+ settingsMockManager := settings.NewMockManager(ctrl)
+ settingsMockManager.EXPECT().
+ GetSettings(gomock.Any(), gomock.Any(), gomock.Any()).
+ Return(&types.Settings{}, nil).
+ AnyTimes()
+ settingsMockManager.EXPECT().
+ GetExtraSettings(gomock.Any(), gomock.Any()).
+ Return(&types.ExtraSettings{}, nil).
+ AnyTimes()
+
+ groupsManager := groups.NewManagerMock()
+
+ updateManager := update_channel.NewPeersUpdateManager(metrics)
+ requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
+ networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
+ accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
+ if err != nil {
+ return nil, "", err
+ }
+
+ secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
+ if err != nil {
+ return nil, "", err
+ }
+ mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ mgmtProto.RegisterManagementServiceServer(s, mgmtServer)
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
+
+// getConnectedPeers returns a connection Status or nil if peer connection wasn't found
+func getConnectedPeers(e *Engine) int {
+ e.syncMsgMux.Lock()
+ defer e.syncMsgMux.Unlock()
+ i := 0
+ for _, id := range e.peerStore.PeersPubKey() {
+ conn, _ := e.peerStore.PeerConn(id)
+ if conn.IsConnected() {
+ i++
+ }
+ }
+ return i
+}
+
+func getPeers(e *Engine) int {
+ e.syncMsgMux.Lock()
+ defer e.syncMsgMux.Unlock()
+
+ return len(e.peerStore.PeersPubKey())
+}
diff --git a/client/internal/engine_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 834a49a09..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/management-integrations/integrations"
-
- "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,44 +31,17 @@ 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"
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
- relayClient "github.com/netbirdio/netbird/shared/relay/client"
"github.com/netbirdio/netbird/shared/netiputil"
+ relayClient "github.com/netbirdio/netbird/shared/relay/client"
signal "github.com/netbirdio/netbird/shared/signal/client"
- "github.com/netbirdio/netbird/shared/signal/proto"
- signalServer "github.com/netbirdio/netbird/signal/server"
"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, _ := integrations.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/support.go b/client/internal/lazyconn/support.go
index 5e765c2d6..cc0e95e53 100644
--- a/client/internal/lazyconn/support.go
+++ b/client/internal/lazyconn/support.go
@@ -4,6 +4,8 @@ import (
"strings"
"github.com/hashicorp/go-version"
+
+ nbversion "github.com/netbirdio/netbird/version"
)
var (
@@ -11,7 +13,7 @@ var (
)
func IsSupported(agentVersion string) bool {
- if agentVersion == "development" {
+ if nbversion.IsDevelopmentVersion(agentVersion) {
return true
}
diff --git a/client/internal/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/networkmonitor/check_change_common.go b/client/internal/networkmonitor/check_change_common.go
index a4a4f76ac..f693081a6 100644
--- a/client/internal/networkmonitor/check_change_common.go
+++ b/client/internal/networkmonitor/check_change_common.go
@@ -50,7 +50,7 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
switch msg.Type {
// handle route changes
case unix.RTM_ADD, syscall.RTM_DELETE:
- route, err := parseRouteMessage(buf[:n])
+ route, flags, err := parseRouteMessage(buf[:n])
if err != nil {
log.Debugf("Network monitor: error parsing routing message: %v", err)
continue
@@ -66,6 +66,10 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
}
switch msg.Type {
case unix.RTM_ADD:
+ if systemops.IgnoreAddedDefaultRoute(flags) {
+ log.Debugf("Network monitor: ignoring added default route via %s, interface %s, flags %#x", route.Gw, intf, flags)
+ continue
+ }
log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf)
return nil
case unix.RTM_DELETE:
@@ -78,22 +82,26 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
}
}
-func parseRouteMessage(buf []byte) (*systemops.Route, error) {
+func parseRouteMessage(buf []byte) (*systemops.Route, int, error) {
msgs, err := route.ParseRIB(route.RIBTypeRoute, buf)
if err != nil {
- return nil, fmt.Errorf("parse RIB: %v", err)
+ return nil, 0, fmt.Errorf("parse RIB: %v", err)
}
if len(msgs) != 1 {
- return nil, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
+ return nil, 0, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
}
msg, ok := msgs[0].(*route.RouteMessage)
if !ok {
- return nil, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
+ return nil, 0, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
}
- return systemops.MsgToRoute(msg)
+ r, err := systemops.MsgToRoute(msg)
+ if err != nil {
+ return nil, 0, err
+ }
+ return r, msg.Flags, nil
}
// waitReadable blocks until fd has data to read, or ctx is cancelled.
diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go
index 1e416bfe7..f3235ec7f 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"
@@ -23,11 +24,17 @@ import (
"github.com/netbirdio/netbird/client/internal/peer/id"
"github.com/netbirdio/netbird/client/internal/peer/worker"
"github.com/netbirdio/netbird/client/internal/portforward"
+ "github.com/netbirdio/netbird/client/internal/rosenpass"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/route"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
)
+// 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(
@@ -116,6 +123,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
@@ -135,6 +145,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.
@@ -160,7 +203,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,
}
@@ -171,6 +213,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()
@@ -226,6 +278,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
}
@@ -252,6 +307,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 {
@@ -422,6 +479,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)
@@ -545,6 +604,8 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
wgConfigWorkaround()
+ conn.injectPendingFirstPacket(wgProxy, nil)
+
conn.rosenpassRemoteKey = rci.rosenpassPubKey
conn.currentConnPriority = conntype.Relay
conn.statusRelay.SetConnected()
@@ -611,11 +672,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
}
@@ -631,6 +693,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) {
@@ -750,23 +835,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) {
@@ -789,7 +890,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)
}
@@ -838,6 +941,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 {
@@ -849,12 +961,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
@@ -899,7 +1008,7 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
}
// Fallback to deterministic key if no NetBird PSK is configured
- determKey, err := conn.rosenpassDetermKey()
+ determKey, err := rosenpass.DeterministicSeedKey(conn.config.LocalKey, conn.config.Key)
if err != nil {
conn.Log.Errorf("failed to generate Rosenpass initial key: %v", err)
return nil
@@ -908,26 +1017,6 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
return determKey
}
-// todo: move this logic into Rosenpass package
-func (conn *Conn) rosenpassDetermKey() (*wgtypes.Key, error) {
- lk := []byte(conn.config.LocalKey)
- rk := []byte(conn.config.Key) // remote key
- var keyInput []byte
- if string(lk) > string(rk) {
- //nolint:gocritic
- keyInput = append(lk[:16], rk[:16]...)
- } else {
- //nolint:gocritic
- keyInput = append(rk[:16], lk[:16]...)
- }
-
- key, err := wgtypes.NewKey(keyInput)
- if err != nil {
- return nil, err
- }
- return &key, nil
-}
-
func isController(config ConnConfig) bool {
return config.LocalKey > config.Key
}
@@ -975,3 +1064,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_status.go b/client/internal/peer/conn_status.go
index b43e245f3..d6ad37b70 100644
--- a/client/internal/peer/conn_status.go
+++ b/client/internal/peer/conn_status.go
@@ -26,7 +26,6 @@ type connStatusInputs struct {
iceInProgress bool // a negotiation is currently in flight
}
-
// ConnStatus describe the status of a peer's connection
type ConnStatus int32
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..6c2e846a9 100644
--- a/client/internal/peer/guard/guard.go
+++ b/client/internal/peer/guard/guard.go
@@ -85,7 +85,11 @@ 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
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..ded3e4aea
--- /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)
+}
+
+// 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/handshaker.go b/client/internal/peer/handshaker.go
index 1d44096b6..56e82e6e3 100644
--- a/client/internal/peer/handshaker.go
+++ b/client/internal/peer/handshaker.go
@@ -195,14 +195,14 @@ func (h *Handshaker) sendOffer() error {
}
offer := h.buildOfferAnswer()
- h.log.Infof("sending offer with serial: %s", offer.SessionIDString())
+ h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
return h.signaler.SignalOffer(offer, h.config.Key)
}
func (h *Handshaker) sendAnswer() error {
answer := h.buildOfferAnswer()
- h.log.Infof("sending answer with serial: %s", answer.SessionIDString())
+ h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
return h.signaler.SignalAnswer(answer, h.config.Key)
}
diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go
index df746fa13..423ce9b23 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"
@@ -111,6 +112,7 @@ type LocalPeerState struct {
PubKey string
KernelInterface bool
FQDN string
+ WgPort int
Routes map[string]struct{}
}
@@ -185,23 +187,35 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
return s.eventsChan
}
-// Status holds a state of peers, signal, management connections and relays
+// Status holds a state of peers, signal, management connections and relays.
+// mux is an RWMutex so hot read paths (notably PeerStateByIP, called for
+// every private-service request) don't contend against each other.
+// Pure read methods take RLock; anything that mutates state takes Lock.
type Status struct {
- mux sync.Mutex
- peers map[string]State
- 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
@@ -217,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
@@ -227,9 +256,11 @@ type Status struct {
func NewRecorder(mgmAddress string) *Status {
return &Status{
peers: make(map[string]State),
+ ipToKey: make(map[string]string),
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,
@@ -238,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
}
@@ -278,13 +309,19 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string)
Mux: new(sync.RWMutex),
}
d.peerListChangedForNotification = true
+ if ipv6 != "" {
+ d.ipToKey[ipv6] = peerPubKey
+ }
+ if ip != "" {
+ d.ipToKey[ip] = peerPubKey
+ }
return nil
}
// GetPeer adds peer to Daemon status map
func (d *Status) GetPeer(peerPubKey string) (State, error) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
state, ok := d.peers[peerPubKey]
if !ok {
@@ -294,8 +331,8 @@ func (d *Status) GetPeer(peerPubKey string) (State, error) {
}
func (d *Status) PeerByIP(ip string) (string, bool) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
for _, state := range d.peers {
if state.IP == ip {
@@ -305,17 +342,45 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
return "", false
}
+// PeerStateByIP returns the full peer State for the given tunnel IP.
+// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
+// address so dual-stack peers are reachable on either family. Only
+// active peers are matched; peers moved into the offline slice by
+// ReplaceOfflinePeers are intentionally treated as unknown.
+func (d *Status) PeerStateByIP(ip string) (State, bool) {
+ if ip == "" {
+ return State{}, false
+ }
+ d.mux.RLock()
+ defer d.mux.RUnlock()
+ key, ok := d.ipToKey[ip]
+ if !ok {
+ return State{}, false
+ }
+ state, ok := d.peers[key]
+ if ok {
+ return state, true
+ }
+ return State{}, false
+}
+
// RemovePeer removes peer from Daemon status map
func (d *Status) RemovePeer(peerPubKey string) error {
d.mux.Lock()
defer d.mux.Unlock()
- _, ok := d.peers[peerPubKey]
+ p, ok := d.peers[peerPubKey]
if !ok {
return errors.New("no peer with to remove")
}
delete(d.peers, peerPubKey)
+ if mappedKey, exists := d.ipToKey[p.IP]; exists && mappedKey == peerPubKey {
+ delete(d.ipToKey, p.IP)
+ }
+ if mappedKey, exists := d.ipToKey[p.IPv6]; exists && mappedKey == peerPubKey {
+ delete(d.ipToKey, p.IPv6)
+ }
d.peerListChangedForNotification = true
return nil
}
@@ -360,6 +425,7 @@ func (d *Status) UpdatePeerState(receivedState State) error {
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -385,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
}
@@ -410,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
}
@@ -459,6 +527,7 @@ func (d *Status) UpdatePeerICEState(receivedState State) error {
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -495,6 +564,7 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error {
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -530,6 +600,7 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -568,6 +639,7 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error {
if notifyRouter {
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
}
+ d.notifyStateChange()
return nil
}
@@ -661,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 {
@@ -702,8 +775,8 @@ func (d *Status) UnsubscribePeerStateChanges(subscription *StatusChangeSubscript
// GetLocalPeerState returns the local peer state
func (d *Status) GetLocalPeerState() LocalPeerState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return d.localPeer.Clone()
}
@@ -719,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
@@ -787,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
@@ -799,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
@@ -811,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
@@ -844,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
@@ -851,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
@@ -863,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
}
@@ -909,8 +1036,8 @@ func (d *Status) DeleteResolvedDomainsStates(domain domain.Domain) {
}
func (d *Status) GetRosenpassState() RosenpassState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return RosenpassState{
d.rosenpassEnabled,
d.rosenpassPermissive,
@@ -918,14 +1045,14 @@ func (d *Status) GetRosenpassState() RosenpassState {
}
func (d *Status) GetLazyConnection() bool {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return d.lazyConnectionEnabled
}
func (d *Status) GetManagementState() ManagementState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return ManagementState{
d.mgmAddress,
d.managementState,
@@ -951,8 +1078,8 @@ func (d *Status) UpdateLatency(pubKey string, latency time.Duration) error {
// IsLoginRequired determines if a peer's login has expired.
func (d *Status) IsLoginRequired() bool {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
// if peer is connected to the management then login is not expired
if d.managementState {
@@ -967,8 +1094,8 @@ func (d *Status) IsLoginRequired() bool {
}
func (d *Status) GetSignalState() SignalState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return SignalState{
d.signalAddress,
d.signalState,
@@ -978,21 +1105,26 @@ func (d *Status) GetSignalState() SignalState {
// GetRelayStates returns the stun/turn/permanent relay states
func (d *Status) GetRelayStates() []relay.ProbeResult {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.muxRelays.RLock()
if d.relayMgr == nil {
- return d.relayStates
+ defer d.muxRelays.RUnlock()
+ return slices.Clone(d.relayStates)
}
- // extend the list of stun, turn servers with relay address
+ relayMgr := d.relayMgr
+ // extend the list of stun, turn servers with the relay server connections
relayStates := slices.Clone(d.relayStates)
+ d.muxRelays.RUnlock()
- // if the server connection is not established then we will use the general address
- // in case of connection we will use the instance specific address
- instanceAddr, _, err := d.relayMgr.RelayInstanceAddress()
- if err != nil {
- // TODO add their status
- for _, r := range d.relayMgr.ServerURLs() {
+ 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 := relayMgr.RelayConnectError(); connErr != nil {
+ err = connErr
+ }
+ for _, r := range relayMgr.ServerURLs() {
relayStates = append(relayStates, relay.ProbeResult{
URI: r,
Err: err,
@@ -1001,15 +1133,19 @@ func (d *Status) GetRelayStates() []relay.ProbeResult {
return relayStates
}
- relayState := relay.ProbeResult{
- URI: instanceAddr,
+ for _, rs := range states {
+ relayStates = append(relayStates, relay.ProbeResult{
+ URI: rs.URL,
+ Err: rs.Err,
+ Transport: rs.Transport,
+ })
}
- return append(relayStates, relayState)
+ return relayStates
}
func (d *Status) ForwardingRules() []firewall.ForwardRule {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
if d.ingressGwMgr == nil {
return nil
}
@@ -1018,16 +1154,16 @@ func (d *Status) ForwardingRules() []firewall.ForwardRule {
}
func (d *Status) GetDNSStates() []NSGroupState {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
// shallow copy is good enough, as slices fields are currently not updated
return slices.Clone(d.nsGroupStates)
}
func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
return maps.Clone(d.resolvedDomainsStates)
}
@@ -1043,8 +1179,8 @@ func (d *Status) GetFullStatus() FullStatus {
LazyConnectionEnabled: d.GetLazyConnection(),
}
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
fullStatus.LocalPeerState = d.localPeer
@@ -1060,16 +1196,19 @@ 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()
}
// SetConnectionListener set a listener to the notifier
@@ -1211,6 +1350,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()
@@ -1219,8 +1431,8 @@ func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
}
func (d *Status) PeersStatus() (*configurer.Stats, error) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.mux.RLock()
+ defer d.mux.RUnlock()
if d.wgIface == nil {
return nil, fmt.Errorf("wgInterface is nil, cannot retrieve peers status")
}
@@ -1326,6 +1538,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
+ pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort)
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)
@@ -1364,6 +1577,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
pbRelayState := &proto.RelayState{
URI: relayState.URI,
Available: relayState.Err == nil,
+ Transport: relayState.Transport,
}
if err := relayState.Err; err != nil {
pbRelayState.Error = err.Error()
diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go
index 9bafca55a..29404d413 100644
--- a/client/internal/peer/status_test.go
+++ b/client/internal/peer/status_test.go
@@ -63,6 +63,72 @@ func TestUpdatePeerState(t *testing.T) {
assert.Equal(t, ip, state.IP, "ip should be equal")
}
+func TestStatus_PeerStateByIP(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ req := require.New(t)
+
+ req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", ""))
+ req.NoError(status.AddPeer("pk-2", "peer-2.netbird", "100.64.0.11", ""))
+
+ state, ok := status.PeerStateByIP("100.64.0.10")
+ req.True(ok, "known tunnel IP should resolve to a peer state")
+ req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
+ req.Equal("peer-1.netbird", state.FQDN, "matching state must carry the right FQDN")
+
+ _, ok = status.PeerStateByIP("100.64.0.99")
+ req.False(ok, "unknown IP must report ok=false")
+}
+
+func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ req := require.New(t)
+
+ req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1"))
+
+ state, ok := status.PeerStateByIP("fd00::1")
+ req.True(ok, "IPv6-only match must resolve to the peer state")
+ req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
+}
+
+// TestStatus_PeerStateByIP_IgnoresOfflinePeers documents that peers
+// moved into the offline slice via ReplaceOfflinePeers are intentionally
+// not resolvable by IP: only active peers can carry traffic, so callers
+// (DNS filter, embed.Client.IdentityForIP) treat them as unknown.
+func TestStatus_PeerStateByIP_IgnoresOfflinePeers(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ req := require.New(t)
+
+ status.ReplaceOfflinePeers([]State{
+ {PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"},
+ })
+
+ _, ok := status.PeerStateByIP("100.64.0.20")
+ req.False(ok, "offline peer must not resolve by IPv4 tunnel address")
+
+ _, ok = status.PeerStateByIP("fd00::20")
+ req.False(ok, "offline peer must not resolve by IPv6 tunnel address")
+}
+
+// TestStatus_PeerStateByIP_RemovedPeer verifies RemovePeer drops the
+// IP index entries for both address families.
+func TestStatus_PeerStateByIP_RemovedPeer(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ req := require.New(t)
+
+ req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1"))
+
+ _, ok := status.PeerStateByIP("100.64.0.10")
+ req.True(ok, "active peer must resolve before removal")
+
+ req.NoError(status.RemovePeer("pk-1"))
+
+ _, ok = status.PeerStateByIP("100.64.0.10")
+ req.False(ok, "removed peer must not resolve by IPv4 tunnel address")
+
+ _, ok = status.PeerStateByIP("fd00::1")
+ req.False(ok, "removed peer must not resolve by IPv6 tunnel address")
+}
+
func TestStatus_UpdatePeerFQDN(t *testing.T) {
key := "abc"
fqdn := "peer-a.netbird.local"
@@ -248,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 29bf5aaaa..b1aa3e0f9 100644
--- a/client/internal/peer/worker_ice.go
+++ b/client/internal/peer/worker_ice.go
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"net"
- "net/netip"
"strconv"
"sync"
"time"
@@ -165,10 +164,6 @@ func (w *WorkerICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HA
return
}
- if candidateViaRoutes(candidate, haRoutes) {
- return
- }
-
if err := w.agent.AddRemoteCandidate(candidate); err != nil {
w.log.Errorf("error while handling remote candidate")
return
@@ -589,34 +584,6 @@ func extraSrflxCandidate(candidate ice.Candidate) (*ice.CandidateServerReflexive
return ec, nil
}
-func candidateViaRoutes(candidate ice.Candidate, clientRoutes route.HAMap) bool {
- addr, err := netip.ParseAddr(candidate.Address())
- if err != nil {
- log.Errorf("Failed to parse IP address %s: %v", candidate.Address(), err)
- return false
- }
-
- var routePrefixes []netip.Prefix
- for _, routes := range clientRoutes {
- if len(routes) > 0 && routes[0] != nil {
- routePrefixes = append(routePrefixes, routes[0].Network)
- }
- }
-
- for _, prefix := range routePrefixes {
- // default route is handled by route exclusion / ip rules
- if prefix.Bits() == 0 {
- continue
- }
-
- if prefix.Contains(addr) {
- log.Debugf("Ignoring candidate [%s], its address is part of routed network %s", candidate.String(), prefix)
- return true
- }
- }
- return false
-}
-
func isRelayCandidate(candidate ice.Candidate) bool {
return candidate.Type() == ice.CandidateTypeRelay
}
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/pcp/nat.go b/client/internal/portforward/pcp/nat.go
index 6491e7367..0e635b6c8 100644
--- a/client/internal/portforward/pcp/nat.go
+++ b/client/internal/portforward/pcp/nat.go
@@ -179,8 +179,10 @@ func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) {
}
dst := net.IPv4zero
- if runtime.GOOS == "linux" {
- // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux.
+ if runtime.GOOS == "linux" || runtime.GOOS == "android" {
+ // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android.
+ // TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties /
+ // NWPathMonitor) when netlink-based lookup is restricted or unavailable.
dst = net.IPv4(0, 0, 0, 1)
}
_, gateway, localIP, err = router.Route(dst)
@@ -203,7 +205,7 @@ func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) {
}
dst := net.IPv6zero
- if runtime.GOOS == "linux" {
+ if runtime.GOOS == "linux" || runtime.GOOS == "android" {
// ::2
dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}
}
diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go
index cd5bc0680..e1668238e 100644
--- a/client/internal/profilemanager/config.go
+++ b/client/internal/profilemanager/config.go
@@ -22,6 +22,7 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
+ "github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/ssh"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -57,6 +58,10 @@ var DefaultInterfaceBlacklist = []string{
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
}
+// loadMDMPolicy is the package-level indirection used by apply() to read the
+// active MDM policy. Tests override this to inject a fake policy.
+var loadMDMPolicy = mdm.LoadPolicy
+
// ConfigInput carries configuration changes to the client
type ConfigInput struct {
ManagementURL string
@@ -91,18 +96,21 @@ type ConfigInput struct {
BlockLANAccess *bool
BlockInbound *bool
DisableIPv6 *bool
+ SyncMessageVersion *int
DisableNotifications *bool
DNSLabels domain.List
- LazyConnectionEnabled *bool
-
MTU *uint16
}
// Config Configuration type
type Config struct {
+ // Name is the human-readable profile name shown in CLI/UI listings.
+ // It is independent of the profile's on-disk filename (which is the ID).
+ Name string
+
// Wireguard private key of local peer
PrivateKey string
PreSharedKey string
@@ -130,6 +138,7 @@ type Config struct {
BlockLANAccess bool
BlockInbound bool
DisableIPv6 bool
+ SyncMessageVersion *int
DisableNotifications *bool
@@ -171,9 +180,28 @@ 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
+
+ // policy is the MDM policy that produced the currently-set values for
+ // any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
+ // and reset on every apply() invocation. Never persisted to disk.
+ // Callers query enforcement state via Policy() and the mdm.Policy API
+ // (HasKey, ManagedKeys, IsEmpty).
+ policy *mdm.Policy `json:"-"`
+}
+
+// Policy returns the MDM policy applied to this Config. Returns a non-nil
+// empty Policy when MDM enforcement is inactive; callers can always invoke
+// HasKey / ManagedKeys / IsEmpty without a nil check.
+func (config *Config) Policy() *mdm.Policy {
+ if config == nil || config.policy == nil {
+ return mdm.NewPolicy(nil)
+ }
+ return config.policy
}
var ConfigDirOverride string
@@ -248,6 +276,16 @@ func createNewConfig(input ConfigInput) (*Config, error) {
}
func (config *Config) apply(input ConfigInput) (updated bool, err error) {
+ if config.Name != "" {
+ sanitized, err := sanitizeDisplayName(config.Name)
+ if err != nil {
+ return false, fmt.Errorf("invalid profile name: %w", err)
+ }
+ if sanitized != config.Name {
+ config.Name = sanitized
+ updated = true
+ }
+ }
if config.ManagementURL == nil {
log.Infof("using default Management URL %s", DefaultManagementURL)
config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL)
@@ -350,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
@@ -397,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 {
@@ -418,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 {
@@ -428,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 {
@@ -438,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 {
@@ -448,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 {
@@ -458,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 {
@@ -468,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
@@ -551,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 {
@@ -596,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
@@ -612,10 +650,109 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
+ // MDM is the last override layer: any key present in the policy
+ // supersedes defaults, on-disk config, env vars and CLI input.
+ config.applyMDMPolicy(loadMDMPolicy())
+
return updated, nil
}
-// parseURL parses and validates a service URL
+// applyMDMPolicy overlays MDM-supplied values on top of the resolved Config.
+// The provided Policy is also stored on the Config so callers can later query
+// which fields are enforced. Invalid values (e.g. malformed URLs) are logged
+// and skipped to avoid bricking the client; the field keeps its previous
+// resolved value but is still marked as managed (Policy.HasKey returns true
+// for the key, so per-field rejection of user writes still applies).
+func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
+ config.policy = policy
+ if policy.IsEmpty() {
+ return
+ }
+
+ // Helper: log the application of a single MDM-managed key. Values for
+ // keys in mdm.SecretKeys are redacted.
+ logApplied := func(key string, displayValue any) {
+ if _, secret := mdm.SecretKeys[key]; secret {
+ log.Infof("MDM override %s = ********** (secret)", key)
+ return
+ }
+ log.Infof("MDM override %s = %v", key, displayValue)
+ }
+
+ if v, ok := policy.GetString(mdm.KeyManagementURL); ok {
+ if u, err := parseURL("Management URL", v); err != nil {
+ log.Warnf("MDM management URL %q invalid: %v; keeping previous value", v, err)
+ } else {
+ config.ManagementURL = u
+ logApplied(mdm.KeyManagementURL, u.String())
+ }
+ }
+
+ if v, ok := policy.GetString(mdm.KeyPreSharedKey); ok {
+ // Defensive: refuse the redaction mask in case it round-tripped
+ // through a manifest by mistake.
+ if !isPreSharedKeyHidden(&v) {
+ config.PreSharedKey = v
+ logApplied(mdm.KeyPreSharedKey, "")
+ }
+ }
+
+ // applyBool collapses the per-key "read + set + log" boilerplate
+ // for every plain bool MDM key into a single helper. Keeps the
+ // outer function's cognitive complexity below SonarCube's
+ // threshold; functional behaviour is identical to the inlined
+ // branches it replaces.
+ applyBool := func(key string, setter func(bool)) {
+ v, ok := policy.GetBool(key)
+ if !ok {
+ return
+ }
+ setter(v)
+ logApplied(key, v)
+ }
+
+ applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv })
+ applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v })
+ applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v })
+ applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v })
+ applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v })
+ applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v })
+ applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v })
+
+ if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok {
+ // REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the
+ // upper bound and reject obviously-invalid values to avoid the
+ // engine binding to an unusable port if the admin pushes garbage.
+ if v >= 1 && v <= 65535 {
+ config.WgPort = int(v)
+ logApplied(mdm.KeyWireguardPort, v)
+ } else {
+ 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
+// must use the http or https scheme; if no port is present, ":443" is
+// 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
new file mode 100644
index 000000000..c6a688ab2
--- /dev/null
+++ b/client/internal/profilemanager/config_mdm_test.go
@@ -0,0 +1,183 @@
+package profilemanager
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/mdm"
+)
+
+// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so
+// apply() observes the supplied Policy. The original loader is restored at
+// test cleanup.
+func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
+ t.Helper()
+ prev := loadMDMPolicy
+ loadMDMPolicy = func() *mdm.Policy { return policy }
+ t.Cleanup(func() { loadMDMPolicy = prev })
+}
+
+func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(nil))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
+ assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+ assert.Empty(t, cfg.Policy().ManagedKeys())
+
+ // Default management URL still resolves.
+ assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
+}
+
+func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
+ const mdmURL = "https://corp.mdm.example.com:443"
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: mdmURL,
+ mdm.KeyDisableClientRoutes: true,
+ mdm.KeyBlockInbound: true,
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ assert.Equal(t, mdmURL, cfg.ManagementURL.String())
+ assert.True(t, cfg.DisableClientRoutes)
+ assert.True(t, cfg.BlockInbound)
+
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyBlockInbound))
+ assert.False(t, cfg.Policy().HasKey(mdm.KeyAllowServerSSH))
+}
+
+func TestApply_MDMBeatsCLIInput(t *testing.T) {
+ const mdmURL = "https://mdm.example.com:443"
+ const cliURL = "https://cli.example.com:443"
+
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: mdmURL,
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ ManagementURL: cliURL,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ // MDM wins over CLI-supplied management URL.
+ assert.Equal(t, mdmURL, cfg.ManagementURL.String())
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+}
+
+func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "not-a-url",
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ // Invalid MDM URL is logged and skipped: default URL stays in place
+ // to keep the client functional.
+ assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
+
+ // But the key is still considered MDM-managed (admin intent is to
+ // enforce, daemon rejects user writes to this field — phase-1 scaffolding
+ // reflects this by keeping Policy.HasKey true even on parse failure).
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+}
+
+func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
+ tmp := filepath.Join(t.TempDir(), "config.json")
+
+ // Seed without MDM.
+ withMDMPolicy(t, mdm.NewPolicy(nil))
+ _, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: tmp,
+ DisableClientRoutes: boolPtr(false),
+ RosenpassEnabled: boolPtr(false),
+ })
+ require.NoError(t, err)
+
+ // Now enable MDM enforcement for these keys.
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyDisableClientRoutes: true,
+ mdm.KeyRosenpassEnabled: true,
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true")
+ assert.True(t, cfg.RosenpassEnabled)
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
+ 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 = "**********"
+
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyPreSharedKey: maskSentinel,
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ // Mask sentinel must not be persisted as the actual PSK.
+ assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
+ // Key still marked managed so user writes are still rejected.
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey))
+}
+
+func boolPtr(b bool) *bool { return &b }
diff --git a/client/internal/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go
index 5216f2423..736ff3412 100644
--- a/client/internal/profilemanager/config_test.go
+++ b/client/internal/profilemanager/config_test.go
@@ -242,6 +242,35 @@ func TestWireguardPortDefaultVsExplicit(t *testing.T) {
}
}
+func TestUpdateConfigServerSSHAllowedNotSet(t *testing.T) {
+ // Configs written before ServerSSHAllowed was introduced lack the field and
+ // unmarshal to nil. Supplying the SSH server flag on top of such a config must
+ // apply the value instead of panicking on a nil pointer dereference.
+ tests := []struct {
+ name string
+ input *bool
+ want bool
+ }{
+ {"enable", util.True(), true},
+ {"disable", util.False(), false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600))
+
+ config, err := UpdateConfig(ConfigInput{
+ ConfigPath: configPath,
+ ServerSSHAllowed: tt.input,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, config.ServerSSHAllowed, "ServerSSHAllowed should be set from input")
+ assert.Equal(t, tt.want, *config.ServerSSHAllowed)
+ })
+ }
+}
+
func TestUpdateOldManagementURL(t *testing.T) {
origProber := newMgmProber
newMgmProber = func(_ context.Context, _ string, _ wgtypes.Key, _ bool) (mgmProber, error) {
diff --git a/client/internal/profilemanager/id.go b/client/internal/profilemanager/id.go
new file mode 100644
index 000000000..3b82c8779
--- /dev/null
+++ b/client/internal/profilemanager/id.go
@@ -0,0 +1,118 @@
+package profilemanager
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+const (
+ // profileIDByteLen is the number of random bytes generated for a new
+ // profile ID. The resulting hex string is twice this length.
+ profileIDByteLen = 16
+
+ // shortIDLen is the number of leading characters of an ID we render in
+ // list output. Profiles per device are few, so 8 chars is collision-safe
+ // in practice and easy to type as a prefix.
+ shortIDLen = 8
+
+ // maxProfileNameLen caps the human-readable profile name to keep table
+ // output legible and prevent denial-of-service via huge JSON fields.
+ maxProfileNameLen = 128
+
+ // maxProfileIDLen bounds the on-disk filename we'll accept. New
+ // IDs are 32 hex chars, legacy stems are sanitized profile names. The
+ // cap is generous enough to cover both without permitting absurdly
+ // long filenames.
+ maxProfileIDLen = 64
+)
+
+type ID string
+
+// generateProfileID returns a new random hex ID for a profile file.
+func generateProfileID() (ID, error) {
+ buf := make([]byte, profileIDByteLen)
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("read random bytes: %w", err)
+ }
+ return ID(hex.EncodeToString(buf)), nil
+}
+
+// IsValidProfileFilenameStem reports whether id is safe to use as the stem
+// of a profile JSON filename.
+func IsValidProfileFilenameStem(id ID) bool {
+ s := id.String()
+ if s == "" || len(s) > maxProfileIDLen {
+ return false
+ }
+ if s == defaultProfileName {
+ return true
+ }
+ if strings.ContainsAny(s, `/\`) || strings.Contains(s, "..") {
+ return false
+ }
+ // filepath.Base catches any leftover separators on platforms with
+ // exotic path conventions.
+ if filepath.Base(s) != s {
+ return false
+ }
+ for _, r := range s {
+ if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-') {
+ return false
+ }
+ }
+ return true
+}
+
+// sanitizeDisplayName normalizes a user-supplied profile display name for
+// storage. It strips ASCII control characters, rejects invalid UTF-8, and
+// caps the length. Emojis, spaces, punctuation, and non-ASCII letters are
+// preserved. Returns an error if nothing usable remains.
+func sanitizeDisplayName(name string) (string, error) {
+ if !utf8.ValidString(name) {
+ return "", fmt.Errorf("name is not valid UTF-8")
+ }
+ name = StripCtrlChars(name)
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return "", fmt.Errorf("name is empty after sanitization")
+ }
+ if utf8.RuneCountInString(name) > maxProfileNameLen {
+ return "", fmt.Errorf("name exceeds %d characters", maxProfileNameLen)
+ }
+ return name, nil
+}
+
+// StripCtrlChars control characters from a name before printing it.
+func StripCtrlChars(name string) string {
+ var b strings.Builder
+ b.Grow(len(name))
+ for _, r := range name {
+ // Skip C0 controls and DEL, plus C1 controls (0x80–0x9F).
+ if r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F) {
+ continue
+ }
+ b.WriteRune(r)
+ }
+ return b.String()
+}
+
+// ShortID truncates an ID for display.
+func (id ID) ShortID() string {
+ if id == DefaultProfileName {
+ return DefaultProfileName
+ }
+ runes := []rune(id)
+ if len(runes) <= shortIDLen {
+ return id.String()
+ }
+ return string(runes[:shortIDLen])
+}
+
+func (id ID) String() string {
+ return string(id)
+}
diff --git a/client/internal/profilemanager/profilemanager.go b/client/internal/profilemanager/profilemanager.go
index c87f521cb..e25d493d5 100644
--- a/client/internal/profilemanager/profilemanager.go
+++ b/client/internal/profilemanager/profilemanager.go
@@ -19,19 +19,41 @@ const (
)
type Profile struct {
- Name string
+ // ID is the on-disk filename stem (without .json). For new profiles
+ // it is a 32-char hex string; legacy profiles created before the
+ // ID-keyed layout keep their original name as their ID. The reserved
+ // value "default" identifies the special default profile.
+ ID ID
+ // Name is the human-readable display name. Falls back to ID when the
+ // underlying JSON has no "name" field set.
+ Name string
+ // Path is the absolute path to the profile JSON. Populated by the
+ // loader so callers do not have to reconstruct it from ID + dir.
+ Path string
IsActive bool
}
func (p *Profile) FilePath() (string, error) {
- if p.Name == "" {
- return "", fmt.Errorf("active profile name is empty")
+ if p.Path != "" {
+ return p.Path, nil
}
- if p.Name == defaultProfileName {
+ id := p.ID
+ if id == "" {
+ id = ID(p.Name)
+ }
+ if id == "" {
+ return "", fmt.Errorf("profile ID is empty")
+ }
+
+ if id == defaultProfileName {
return DefaultConfigPath, nil
}
+ if !IsValidProfileFilenameStem(id) {
+ return "", fmt.Errorf("invalid profile ID: %q", id)
+ }
+
username, err := user.Current()
if err != nil {
return "", fmt.Errorf("failed to get current user: %w", err)
@@ -42,10 +64,13 @@ func (p *Profile) FilePath() (string, error) {
return "", fmt.Errorf("failed to get config directory for user %s: %w", username.Username, err)
}
- return filepath.Join(configDir, p.Name+".json"), nil
+ return filepath.Join(configDir, id.String()+".json"), nil
}
func (p *Profile) IsDefault() bool {
+ if p.ID != "" {
+ return p.ID == defaultProfileName
+ }
return p.Name == defaultProfileName
}
@@ -57,18 +82,24 @@ func NewProfileManager() *ProfileManager {
return &ProfileManager{}
}
+// GetActiveProfile returns the active profile as recorded in the local
+// user state file. Only ID is populated.
func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
pm.mu.Lock()
defer pm.mu.Unlock()
- prof := pm.getActiveProfileState()
- return &Profile{Name: prof}, nil
+ id := pm.getActiveProfileState()
+ return &Profile{ID: id}, nil
}
-func (pm *ProfileManager) SwitchProfile(profileName string) error {
- profileName = sanitizeProfileName(profileName)
+// SwitchProfile records the given profile ID as active in the local user
+// state file.
+func (pm *ProfileManager) SwitchProfile(id ID) error {
+ if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
+ return fmt.Errorf("invalid profile ID: %q", id)
+ }
- if err := pm.setActiveProfileState(profileName); err != nil {
+ if err := pm.setActiveProfileState(id); err != nil {
return fmt.Errorf("failed to switch profile: %w", err)
}
return nil
@@ -85,7 +116,7 @@ func sanitizeProfileName(name string) string {
}, name)
}
-func (pm *ProfileManager) getActiveProfileState() string {
+func (pm *ProfileManager) getActiveProfileState() ID {
configDir, err := getConfigDir()
if err != nil {
@@ -113,10 +144,10 @@ func (pm *ProfileManager) getActiveProfileState() string {
return defaultProfileName
}
- return profileName
+ return ID(profileName)
}
-func (pm *ProfileManager) setActiveProfileState(profileName string) error {
+func (pm *ProfileManager) setActiveProfileState(id ID) error {
configDir, err := getConfigDir()
if err != nil {
@@ -125,7 +156,7 @@ func (pm *ProfileManager) setActiveProfileState(profileName string) error {
statePath := filepath.Join(configDir, activeProfileStateFilename)
- err = os.WriteFile(statePath, []byte(profileName), 0600)
+ err = os.WriteFile(statePath, []byte(id), 0600)
if err != nil {
return fmt.Errorf("failed to write active profile state: %w", err)
}
@@ -142,7 +173,7 @@ func GetLoginHint() string {
return ""
}
- profileState, err := pm.GetProfileState(activeProf.Name)
+ profileState, err := pm.GetProfileState(activeProf.ID)
if err != nil {
log.Debugf("failed to get profile state for login hint: %v", err)
return ""
diff --git a/client/internal/profilemanager/profilemanager_test.go b/client/internal/profilemanager/profilemanager_test.go
index 79a7ae650..882a71d0a 100644
--- a/client/internal/profilemanager/profilemanager_test.go
+++ b/client/internal/profilemanager/profilemanager_test.go
@@ -50,14 +50,14 @@ func TestServiceManager_CreateAndGetDefaultProfile(t *testing.T) {
state, err := sm.GetActiveProfileState()
assert.NoError(t, err)
- assert.Equal(t, state.Name, defaultProfileName) // No active profile state yet
+ assert.Equal(t, defaultProfileName, state.ID.String()) // No active profile state yet
err = sm.SetActiveProfileStateToDefault()
assert.NoError(t, err)
active, err := sm.GetActiveProfileState()
assert.NoError(t, err)
- assert.Equal(t, "default", active.Name)
+ assert.Equal(t, "default", active.ID.String())
})
})
}
@@ -92,14 +92,14 @@ func TestServiceManager_SetActiveProfileState(t *testing.T) {
currUser, err := user.Current()
assert.NoError(t, err)
sm := &ServiceManager{}
- state := &ActiveProfileState{Name: "foo", Username: currUser.Username}
+ state := &ActiveProfileState{ID: "foo", Username: currUser.Username}
err = sm.SetActiveProfileState(state)
assert.NoError(t, err)
// Should error on nil or incomplete state
err = sm.SetActiveProfileState(nil)
assert.Error(t, err)
- err = sm.SetActiveProfileState(&ActiveProfileState{Name: "", Username: ""})
+ err = sm.SetActiveProfileState(&ActiveProfileState{ID: "", Username: ""})
assert.Error(t, err)
})
})
diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go
index ef3eb1114..696a60310 100644
--- a/client/internal/profilemanager/service.go
+++ b/client/internal/profilemanager/service.go
@@ -2,6 +2,7 @@ package profilemanager
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"io"
@@ -10,6 +11,7 @@ import (
"runtime"
"sort"
"strings"
+ "syscall"
log "github.com/sirupsen/logrus"
@@ -23,12 +25,43 @@ var (
DefaultConfigPathDir = ""
DefaultConfigPath = ""
ActiveProfileStatePath = ""
-)
-var (
ErrorOldDefaultConfigNotFound = errors.New("old default config not found")
)
+// ErrAmbiguousHandle is returned when a profile handle (ID prefix or name)
+// matches more than one profile. Callers can render Candidates to help the
+// user disambiguate.
+type ErrAmbiguousHandle struct {
+ Handle string
+ Candidates []Profile
+ Kind AmbiguityKind
+}
+
+// AmbiguityKind describes which matcher produced the ambiguity, so callers
+// can tailor the error message.
+type AmbiguityKind int
+
+const (
+ AmbiguityKindIDPrefix AmbiguityKind = iota
+ AmbiguityKindName
+)
+
+// profileMeta is the minimal slice of a profile JSON we need, so we avoid
+// reading all fields
+type profileMeta struct {
+ Name string
+}
+
+func (e *ErrAmbiguousHandle) Error() string {
+ switch e.Kind {
+ case AmbiguityKindIDPrefix:
+ return fmt.Sprintf("ID prefix %q is ambiguous (matches %d profiles)", e.Handle, len(e.Candidates))
+ default:
+ return fmt.Sprintf("name %q is ambiguous (%d profiles share this name)", e.Handle, len(e.Candidates))
+ }
+}
+
func init() {
DefaultConfigPathDir = "/var/lib/netbird/"
@@ -54,25 +87,34 @@ func init() {
}
type ActiveProfileState struct {
- Name string `json:"name"`
+ // ID is the on-disk filename stem of the active profile. The JSON tag stays
+ // as "name" for backwards compatibility with active state files written
+ // before the ID-based config files. Legacy values were profile names, which
+ // were also the legacy filename stems, so they still resolve to the correct
+ // file on disk.
+ ID ID `json:"name"`
Username string `json:"username"`
}
func (a *ActiveProfileState) FilePath() (string, error) {
- if a.Name == "" {
- return "", fmt.Errorf("active profile name is empty")
+ if a.ID == "" {
+ return "", fmt.Errorf("active profile ID is empty")
}
- if a.Name == defaultProfileName {
+ if a.ID == defaultProfileName {
return DefaultConfigPath, nil
}
+ if !IsValidProfileFilenameStem(a.ID) {
+ return "", fmt.Errorf("invalid profile ID: %q", a.ID)
+ }
+
configDir, err := getConfigDirForUser(a.Username)
if err != nil {
return "", fmt.Errorf("failed to get config directory for user %s: %w", a.Username, err)
}
- return filepath.Join(configDir, a.Name+".json"), nil
+ return filepath.Join(configDir, a.ID.String()+".json"), nil
}
type ServiceManager struct {
@@ -178,7 +220,7 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) {
return nil, fmt.Errorf("failed to set active profile to default: %w", err)
}
return &ActiveProfileState{
- Name: "default",
+ ID: defaultProfileName,
Username: "",
}, nil
} else {
@@ -186,12 +228,12 @@ func (s *ServiceManager) GetActiveProfileState() (*ActiveProfileState, error) {
}
}
- if activeProfile.Name == "" {
+ if activeProfile.ID == "" {
if err := s.SetActiveProfileStateToDefault(); err != nil {
return nil, fmt.Errorf("failed to set active profile to default: %w", err)
}
return &ActiveProfileState{
- Name: "default",
+ ID: defaultProfileName,
Username: "",
}, nil
}
@@ -216,25 +258,29 @@ func (s *ServiceManager) setDefaultActiveState() error {
}
func (s *ServiceManager) SetActiveProfileState(a *ActiveProfileState) error {
- if a == nil || a.Name == "" {
+ if a == nil || a.ID == "" {
return errors.New("invalid active profile state")
}
- if a.Name != defaultProfileName && a.Username == "" {
- return fmt.Errorf("username must be set for non-default profiles, got: %s", a.Name)
+ if a.ID != defaultProfileName && a.Username == "" {
+ return fmt.Errorf("username must be set for non-default profiles, got: %s", a.ID)
+ }
+
+ if a.ID != defaultProfileName && !IsValidProfileFilenameStem(a.ID) {
+ return fmt.Errorf("invalid profile ID: %q", a.ID)
}
if err := util.WriteJsonWithRestrictedPermission(context.Background(), ActiveProfileStatePath, a); err != nil {
return fmt.Errorf("failed to write active profile state: %w", err)
}
- log.Infof("active profile set to %s for %s", a.Name, a.Username)
+ log.Infof("active profile set to %s for %s", a.ID, a.Username)
return nil
}
func (s *ServiceManager) SetActiveProfileStateToDefault() error {
return s.SetActiveProfileState(&ActiveProfileState{
- Name: "default",
+ ID: defaultProfileName,
Username: "",
})
}
@@ -243,57 +289,117 @@ func (s *ServiceManager) DefaultProfilePath() string {
return DefaultConfigPath
}
-func (s *ServiceManager) AddProfile(profileName, username string) error {
+// AddProfile creates a new profile with a generated ID. The user-supplied
+// displayName is stored inside the JSON's name field, the on-disk filename
+// uses the generated ID.
+//
+// The returned Profile carries the freshly-generated ID so callers can
+// show it to the user (and so the gRPC AddProfileResponse can include
+// it).
+func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) {
configDir, err := s.getConfigDir(username)
if err != nil {
- return fmt.Errorf("failed to get config directory: %w", err)
+ return nil, fmt.Errorf("failed to get config directory: %w", err)
}
- profileName = sanitizeProfileName(profileName)
-
- if profileName == defaultProfileName {
- return fmt.Errorf("cannot create profile with reserved name: %s", defaultProfileName)
- }
-
- profPath := filepath.Join(configDir, profileName+".json")
- profileExists, err := fileExists(profPath)
+ displayName, err = sanitizeDisplayName(displayName)
if err != nil {
- return fmt.Errorf("failed to check if profile exists: %w", err)
- }
- if profileExists {
- return ErrProfileAlreadyExists
+ return nil, fmt.Errorf("invalid profile name: %w", err)
}
+ id, err := generateProfileID()
+ if err != nil {
+ return nil, fmt.Errorf("generate profile id: %w", err)
+ }
+
+ profPath := filepath.Join(configDir, id.String()+".json")
cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath})
if err != nil {
- return fmt.Errorf("failed to create new config: %w", err)
+ return nil, fmt.Errorf("failed to create new config: %w", err)
+ }
+ cfg.Name = displayName
+
+ if err := util.WriteJson(context.Background(), profPath, cfg); err != nil {
+ return nil, fmt.Errorf("failed to write profile config: %w", err)
}
- err = util.WriteJson(context.Background(), profPath, cfg)
+ return &Profile{
+ ID: id,
+ Name: displayName,
+ Path: profPath,
+ }, nil
+}
+
+func (s *ServiceManager) RenameProfile(id ID, username string, newName string) error {
+ displayName, err := sanitizeDisplayName(newName)
if err != nil {
- return fmt.Errorf("failed to write profile config: %w", err)
+ return fmt.Errorf("invalid profile name: %w", err)
}
+ if !IsValidProfileFilenameStem(id) {
+ return fmt.Errorf("invalid profile ID: %q", id)
+ }
+
+ profiles, err := s.loadAllProfiles(username)
+ if err != nil {
+ return fmt.Errorf("load profiles: %w", err)
+ }
+
+ var target *Profile
+ for i := range profiles {
+ if profiles[i].ID == id {
+ target = &profiles[i]
+ break
+ }
+ }
+ if target == nil {
+ return ErrProfileNotFound
+ }
+
+ data, err := os.ReadFile(target.Path)
+ if err != nil {
+ return err
+ }
+ var cfg Config
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return err
+ }
+ cfg.Name = displayName
+
+ if err := util.WriteJson(context.Background(), target.Path, cfg); err != nil {
+ return fmt.Errorf("failed to write profile name: %w", err)
+ }
return nil
}
-func (s *ServiceManager) RemoveProfile(profileName, username string) error {
- configDir, err := s.getConfigDir(username)
- if err != nil {
- return fmt.Errorf("failed to get config directory: %w", err)
+// RemoveProfile deletes the profile identified by id. Callers must have
+// already resolved any user-supplied handle to a concrete ID via
+// ResolveProfile.
+func (s *ServiceManager) RemoveProfile(id ID, username string) error {
+ if id == defaultProfileName {
+ defaultName := readProfileName(DefaultConfigPath)
+ if defaultName == "" {
+ defaultName = defaultProfileName
+ }
+ return fmt.Errorf("cannot remove default profile with name: %s", defaultName)
+ }
+ if !IsValidProfileFilenameStem(id) {
+ return fmt.Errorf("invalid profile ID: %q", id)
}
- profileName = sanitizeProfileName(profileName)
-
- if profileName == defaultProfileName {
- return fmt.Errorf("cannot remove profile with reserved name: %s", defaultProfileName)
- }
- profPath := filepath.Join(configDir, profileName+".json")
- profileExists, err := fileExists(profPath)
+ profiles, err := s.loadAllProfiles(username)
if err != nil {
- return fmt.Errorf("failed to check if profile exists: %w", err)
+ return fmt.Errorf("load profiles: %w", err)
}
- if !profileExists {
+
+ var target *Profile
+ for i := range profiles {
+ if profiles[i].ID == id {
+ target = &profiles[i]
+ break
+ }
+ }
+ if target == nil {
return ErrProfileNotFound
}
@@ -301,57 +407,26 @@ func (s *ServiceManager) RemoveProfile(profileName, username string) error {
if err != nil && !errors.Is(err, ErrNoActiveProfile) {
return fmt.Errorf("failed to get active profile: %w", err)
}
-
- if activeProf != nil && activeProf.Name == profileName {
- return fmt.Errorf("cannot remove active profile: %s", profileName)
+ if activeProf != nil && activeProf.ID == id {
+ return fmt.Errorf("cannot remove active profile: %s", id)
}
- err = util.RemoveJson(profPath)
- if err != nil {
+ if err := util.RemoveJson(target.Path); err != nil {
return fmt.Errorf("failed to remove profile config: %w", err)
}
+
+ stateFile := filepath.Join(filepath.Dir(target.Path), id.String()+".state.json")
+ if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
+ log.Warnf("failed to remove profile state file %s: %v", stateFile, err)
+ }
+
return nil
}
+// ListProfiles returns every profile for the given user, including the
+// default profile, with IsActive flags set.
func (s *ServiceManager) ListProfiles(username string) ([]Profile, error) {
- configDir, err := s.getConfigDir(username)
- if err != nil {
- return nil, fmt.Errorf("failed to get config directory: %w", err)
- }
-
- files, err := util.ListFiles(configDir, "*.json")
- if err != nil {
- return nil, fmt.Errorf("failed to list profile files: %w", err)
- }
-
- var filtered []string
- for _, file := range files {
- if strings.HasSuffix(file, "state.json") {
- continue // skip state files
- }
- filtered = append(filtered, file)
- }
- sort.Strings(filtered)
-
- var activeProfName string
- activeProf, err := s.GetActiveProfileState()
- if err == nil {
- activeProfName = activeProf.Name
- }
-
- var profiles []Profile
- // add default profile always
- profiles = append(profiles, Profile{Name: defaultProfileName, IsActive: activeProfName == "" || activeProfName == defaultProfileName})
- for _, file := range filtered {
- profileName := strings.TrimSuffix(filepath.Base(file), ".json")
- var isActive bool
- if activeProfName != "" && activeProfName == profileName {
- isActive = true
- }
- profiles = append(profiles, Profile{Name: profileName, IsActive: isActive})
- }
-
- return profiles, nil
+ return s.loadAllProfiles(username)
}
// GetStatePath returns the path to the state file based on the operating system
@@ -365,11 +440,20 @@ 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
}
- if activeProf.Name == defaultProfileName {
+ if activeProf.ID == defaultProfileName {
+ return defaultStatePath
+ }
+
+ if !IsValidProfileFilenameStem(activeProf.ID) {
+ log.Warnf("invalid active profile ID %q, using default state path", activeProf.ID)
return defaultStatePath
}
@@ -379,7 +463,7 @@ func (s *ServiceManager) GetStatePath() string {
return defaultStatePath
}
- return filepath.Join(configDir, activeProf.Name+".state.json")
+ return filepath.Join(configDir, activeProf.ID.String()+".state.json")
}
// getConfigDir returns the profiles directory, using profilesDir if set, otherwise getConfigDirForUser
@@ -390,3 +474,169 @@ func (s *ServiceManager) getConfigDir(username string) (string, error) {
return getConfigDirForUser(username)
}
+
+// loadAllProfiles returns every profile visible to the daemon for the
+// given user, including the default profile. The returned slice is sorted
+// by ID for a stable display order.
+//
+// Each Profile is fully populated: ID is the filename stem, Name comes
+// from the JSON's "name" field (falling back to the filename stem when absent)
+// and Path is built from a basename read off disk.
+func (s *ServiceManager) loadAllProfiles(username string) ([]Profile, error) {
+ activeID, activeIsDefault := s.activeProfileID()
+ defaultName := readProfileName(DefaultConfigPath)
+ if defaultName == "" {
+ defaultName = defaultProfileName
+ }
+
+ profiles := []Profile{{
+ ID: defaultProfileName,
+ Name: defaultName,
+ Path: DefaultConfigPath,
+ IsActive: activeIsDefault,
+ }}
+
+ configDir, err := s.getConfigDir(username)
+ if err != nil {
+ return nil, fmt.Errorf("get config directory: %w", err)
+ }
+
+ entries, err := os.ReadDir(configDir)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return profiles, nil
+ }
+ return nil, fmt.Errorf("read profile directory: %w", err)
+ }
+
+ var fileProfiles []Profile
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ base := entry.Name()
+ if !strings.HasSuffix(base, ".json") {
+ continue
+ }
+ if strings.HasSuffix(base, ".state.json") {
+ continue
+ }
+ stem := ID(strings.TrimSuffix(base, ".json"))
+ if stem == defaultProfileName {
+ // default lives at the top-level config dir, not under /
+ continue
+ }
+ if !IsValidProfileFilenameStem(ID(stem)) {
+ continue
+ }
+ path := filepath.Join(configDir, base)
+ name := readProfileName(path)
+ if name == "" {
+ name = stem.String()
+ }
+ fileProfiles = append(fileProfiles, Profile{
+ ID: stem,
+ Name: name,
+ Path: path,
+ IsActive: stem == ID(activeID),
+ })
+ }
+
+ sort.Slice(fileProfiles, func(i, j int) bool {
+ if fileProfiles[i].Name != fileProfiles[j].Name {
+ return fileProfiles[i].Name < fileProfiles[j].Name
+ }
+ // Sort tie-break on ID so duplicate names always render in the same order.
+ return fileProfiles[i].ID < fileProfiles[j].ID
+ })
+ profiles = append(profiles, fileProfiles...)
+ return profiles, nil
+}
+
+// readProfileName parses just the "name" field from the profile Json.
+func readProfileName(path string) string {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ var meta profileMeta
+ if err := json.Unmarshal(data, &meta); err != nil {
+ return ""
+ }
+ return meta.Name
+}
+
+// activeProfileID returns the currently-active profile's ID. The second
+// return value is true when the active profile is the default one.
+func (s *ServiceManager) activeProfileID() (ID, bool) {
+ state, err := s.GetActiveProfileState()
+ if err != nil || state == nil {
+ return defaultProfileName, true
+ }
+ if state.ID == "" || state.ID == defaultProfileName {
+ return defaultProfileName, true
+ }
+ return state.ID, false
+}
+
+// ResolveProfile turns a user-supplied handle into a Profile. Resolution
+// precedence is: exact ID match, then unique exact name, then unique ID
+// prefix. Ambiguous matches return *ErrAmbiguousHandle so callers can
+// surface the candidates.
+func (s *ServiceManager) ResolveProfile(handle, username string) (*Profile, error) {
+ if handle == "" {
+ return nil, fmt.Errorf("profile handle is empty")
+ }
+
+ profiles, err := s.loadAllProfiles(username)
+ if err != nil {
+ return nil, err
+ }
+
+ for i := range profiles {
+ if profiles[i].ID == ID(handle) {
+ return &profiles[i], nil
+ }
+ }
+
+ var nameMatches []Profile
+ for i := range profiles {
+ if profiles[i].Name == handle {
+ nameMatches = append(nameMatches, profiles[i])
+ }
+ }
+ if len(nameMatches) == 1 {
+ return &nameMatches[0], nil
+ }
+ if len(nameMatches) > 1 {
+ return nil, &ErrAmbiguousHandle{
+ Handle: handle,
+ Candidates: nameMatches,
+ Kind: AmbiguityKindName,
+ }
+ }
+
+ // ID prefix match. Skip the default profile so `select d` does not
+ // accidentally pick it via prefix.
+ var prefixMatches []Profile
+ for i := range profiles {
+ if profiles[i].ID == defaultProfileName {
+ continue
+ }
+ if strings.HasPrefix(profiles[i].ID.String(), handle) {
+ prefixMatches = append(prefixMatches, profiles[i])
+ }
+ }
+ if len(prefixMatches) == 1 {
+ return &prefixMatches[0], nil
+ }
+ if len(prefixMatches) > 1 {
+ return nil, &ErrAmbiguousHandle{
+ Handle: handle,
+ Candidates: prefixMatches,
+ Kind: AmbiguityKindIDPrefix,
+ }
+ }
+
+ return nil, ErrProfileNotFound
+}
diff --git a/client/internal/profilemanager/service_test.go b/client/internal/profilemanager/service_test.go
new file mode 100644
index 000000000..5e051b15d
--- /dev/null
+++ b/client/internal/profilemanager/service_test.go
@@ -0,0 +1,230 @@
+package profilemanager
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/util"
+)
+
+// withTestSM wires up patched globals + a clean config dir and returns a
+// fully initialized ServiceManager plus the username we are scoped to.
+func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) {
+ t.Helper()
+ withTempConfigDir(t, func(configDir string) {
+ withPatchedGlobals(t, configDir, func() {
+ u, err := user.Current()
+ require.NoError(t, err)
+ sm := &ServiceManager{}
+ require.NoError(t, sm.CreateDefaultProfile())
+ fn(sm, u.Username)
+ })
+ })
+}
+
+func TestServiceProfile_ExactID(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ created, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ got, err := sm.ResolveProfile(created.ID.String(), username)
+ require.NoError(t, err)
+ assert.Equal(t, created.ID, got.ID)
+ assert.Equal(t, "work", got.Name)
+ })
+}
+
+func TestServiceProfile_IDPrefix(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ created, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ prefix := created.ID[:4]
+ got, err := sm.ResolveProfile(prefix.String(), username)
+ require.NoError(t, err)
+ assert.Equal(t, created.ID, got.ID)
+ })
+}
+
+func TestServiceProfile_AmbiguousPrefix(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ // Plant two profiles whose IDs share a known prefix by writing
+ // the files directly, since generated IDs are random.
+ configDir, err := sm.getConfigDir(username)
+ require.NoError(t, err)
+ for _, id := range []string{"abcd1111aaaa", "abcd2222bbbb"} {
+ path := filepath.Join(configDir, id+".json")
+ require.NoError(t, util.WriteJson(context.Background(), path, &Config{Name: id}))
+ }
+
+ _, err = sm.ResolveProfile("abcd", username)
+ var amb *ErrAmbiguousHandle
+ require.ErrorAs(t, err, &amb)
+ assert.Equal(t, AmbiguityKindIDPrefix, amb.Kind)
+ assert.Len(t, amb.Candidates, 2)
+ })
+}
+
+func TestServiceProfile_ExactNameUnique(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ _, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ got, err := sm.ResolveProfile("work", username)
+ require.NoError(t, err)
+ assert.Equal(t, "work", got.Name)
+ })
+}
+
+func TestServiceProfile_AmbiguousName(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ _, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+ _, err = sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ _, err = sm.ResolveProfile("work", username)
+ var amb *ErrAmbiguousHandle
+ require.ErrorAs(t, err, &amb)
+ assert.Equal(t, AmbiguityKindName, amb.Kind)
+ assert.Len(t, amb.Candidates, 2)
+ })
+}
+
+func TestServiceProfile_NotFound(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ _, err := sm.ResolveProfile("nope", username)
+ assert.ErrorIs(t, err, ErrProfileNotFound)
+ })
+}
+
+func TestServiceProfile_DefaultByExactID(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ got, err := sm.ResolveProfile(defaultProfileName, username)
+ require.NoError(t, err)
+ assert.Equal(t, defaultProfileName, got.ID.String())
+ })
+}
+
+func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) {
+ // Legacy profiles stored as .json with no "name" JSON field
+ // should still be discoverable by name and removable by name.
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ configDir, err := sm.getConfigDir(username)
+ require.NoError(t, err)
+ path := filepath.Join(configDir, "legacy.json")
+ require.NoError(t, util.WriteJson(context.Background(), path, &Config{}))
+
+ got, err := sm.ResolveProfile("legacy", username)
+ require.NoError(t, err)
+ assert.Equal(t, "legacy", got.ID.String())
+ // Name falls back to the filename stem when JSON omits it.
+ assert.Equal(t, "legacy", got.Name)
+ })
+}
+
+func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ first, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ second, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+ assert.NotEqual(t, first.ID, second.ID)
+ assert.Equal(t, "work", second.Name)
+ })
+}
+
+func TestAddProfile_RejectsInvalidNames(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ cases := []string{
+ "", // empty
+ "\x00\x01", // only control chars (becomes empty)
+ strings.Repeat("a", maxProfileNameLen+1), // too long
+ }
+ for _, name := range cases {
+ _, err := sm.AddProfile(name, username)
+ assert.Error(t, err, "expected error for %q", name)
+ }
+ })
+}
+
+func TestRemoveProfile_RejectsInvalidID(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ err := sm.RemoveProfile("../escape", username)
+ assert.Error(t, err)
+ })
+}
+
+func TestSanitizeDisplayName(t *testing.T) {
+ cases := []struct {
+ in string
+ want string
+ wantErr bool
+ }{
+ {"work", "work", false},
+ {"My Work Account", "My Work Account", false},
+ {"emoji 🚀 ok", "emoji 🚀 ok", false},
+ {"漢字テスト", "漢字テスト", false},
+ {"with\x00null", "withnull", false},
+ {"\x01\x02\x03", "", true},
+ {"", "", true},
+ }
+ for _, tc := range cases {
+ got, err := sanitizeDisplayName(tc.in)
+ if tc.wantErr {
+ assert.Error(t, err, "case %q", tc.in)
+ continue
+ }
+ assert.NoError(t, err, "case %q", tc.in)
+ assert.Equal(t, tc.want, got, "case %q", tc.in)
+ }
+}
+
+func TestIsValidProfileFilenameStem(t *testing.T) {
+ cases := []struct {
+ in string
+ want bool
+ }{
+ {"default", true},
+ {"abc123def456", true},
+ {"legacy-name", true},
+ {"legacy_name", true},
+ {"", false},
+ {"..", false},
+ {"../etc", false},
+ {"foo/bar", false},
+ {`foo\bar`, false},
+ {"with space", false},
+ {"with.dot", false},
+ {strings.Repeat("a", maxProfileIDLen+1), false},
+ }
+ for _, tc := range cases {
+ got := IsValidProfileFilenameStem(ID(tc.in))
+ assert.Equal(t, tc.want, got, "case %q", tc.in)
+ }
+}
+
+func TestRemoveProfile_DeletesStateFile(t *testing.T) {
+ withTestSM(t, func(sm *ServiceManager, username string) {
+ created, err := sm.AddProfile("work", username)
+ require.NoError(t, err)
+
+ configDir, err := sm.getConfigDir(username)
+ require.NoError(t, err)
+ statePath := filepath.Join(configDir, created.ID.String()+".state.json")
+ require.NoError(t, os.WriteFile(statePath, []byte(`{"email":"a@b"}`), 0600))
+
+ require.NoError(t, sm.RemoveProfile(created.ID, username))
+ _, err = os.Stat(statePath)
+ assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed")
+ })
+}
diff --git a/client/internal/profilemanager/state.go b/client/internal/profilemanager/state.go
index f09391ede..fcd1c384c 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"
@@ -13,13 +14,20 @@ type ProfileState struct {
Email string `json:"email"`
}
-func (pm *ProfileManager) GetProfileState(profileName string) (*ProfileState, error) {
+// GetProfileState reads the per-profile state file keyed by profile ID.
+// The state file lives in the user's config directory. Legacy state files
+// keyed by the old profile name remain readable.
+func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
configDir, err := getConfigDir()
if err != nil {
return nil, fmt.Errorf("get config directory: %w", err)
}
- stateFile := filepath.Join(configDir, profileName+".state.json")
+ if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
+ return nil, fmt.Errorf("invalid profile ID: %q", id)
+ }
+
+ stateFile := filepath.Join(configDir, id.String()+".state.json")
stateFileExists, err := fileExists(stateFile)
if err != nil {
return nil, fmt.Errorf("failed to check if profile state file exists: %w", err)
@@ -37,12 +45,35 @@ func (pm *ProfileManager) GetProfileState(profileName string) (*ProfileState, er
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) {
@@ -51,10 +82,23 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
return fmt.Errorf("get active profile: %w", err)
}
- stateFile := filepath.Join(configDir, activeProf.Name+".state.json")
- err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
+ 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
+// a successful logout so a logged-out profile no longer shows a stale account
+// email. 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("write profile state: %w", err)
+ return fmt.Errorf("get config directory: %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/relay/relay.go b/client/internal/relay/relay.go
index f00a8d93a..051717608 100644
--- a/client/internal/relay/relay.go
+++ b/client/internal/relay/relay.go
@@ -32,6 +32,9 @@ type ProbeResult struct {
URI string
Err error
Addr string
+ // Transport is the negotiated relay transport, empty
+ // for stun/turn probes or when not connected.
+ Transport string
}
type StunTurnProbe struct {
diff --git a/client/internal/rosenpass/manager.go b/client/internal/rosenpass/manager.go
index 11cda8dbc..21dd751df 100644
--- a/client/internal/rosenpass/manager.go
+++ b/client/internal/rosenpass/manager.go
@@ -28,22 +28,33 @@ func hashRosenpassKey(key []byte) string {
return hex.EncodeToString(hasher.Sum(nil))
}
+// rpServer is the subset of rp.Server used by Manager. Defined as an interface
+// so tests can substitute a mock without spinning up a real UDP server.
+type rpServer interface {
+ AddPeer(rp.PeerConfig) (rp.PeerID, error)
+ RemovePeer(rp.PeerID) error
+ Run() error
+ Close() error
+}
+
type Manager struct {
ifaceName string
+ localWgKey wgtypes.Key
spk []byte
ssk []byte
rpKeyHash string
preSharedKey *[32]byte
rpPeerIDs map[string]*rp.PeerID
rpWgHandler *NetbirdHandler
- server *rp.Server
+ server rpServer
lock sync.Mutex
port int
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
@@ -51,7 +62,23 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
rpKeyHash := hashRosenpassKey(public)
log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash)
- return &Manager{ifaceName: wgIfaceName, rpKeyHash: rpKeyHash, spk: public, ssk: secret, preSharedKey: (*[32]byte)(preSharedKey), rpPeerIDs: make(map[string]*rp.PeerID), lock: sync.Mutex{}}, nil
+ return &Manager{
+ ifaceName: wgIfaceName,
+ localWgKey: localWgKey,
+ rpKeyHash: rpKeyHash,
+ spk: public,
+ ssk: secret,
+ preSharedKey: (*[32]byte)(preSharedKey),
+ rpPeerIDs: make(map[string]*rp.PeerID),
+ // rpWgHandler is created here (instead of only in generateConfig) so it
+ // is never nil between NewManager and Run(). Otherwise an early
+ // OnConnected call (race observed on Android, issue #4341) panics on
+ // nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will
+ // replace it with a fresh handler on each Run() to clear stale peer
+ // state from previous engine sessions.
+ rpWgHandler: NewNetbirdHandler((*[32]byte)(preSharedKey), localWgKey),
+ lock: sync.Mutex{},
+ }, nil
}
func (m *Manager) GetPubKey() []byte {
@@ -65,6 +92,16 @@ func (m *Manager) GetAddress() *net.UDPAddr {
// addPeer adds a new peer to the Rosenpass server
func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuardIP string, wireGuardPubKey string) error {
+ // Defense in depth against issue #4341 (Android crash): if Run() has not
+ // completed yet, m.server / m.rpWgHandler may be nil. Return an explicit
+ // error instead of panicking on nil-receiver dereference.
+ if m.server == nil {
+ return fmt.Errorf("rosenpass server not initialized")
+ }
+ if m.rpWgHandler == nil {
+ return fmt.Errorf("rosenpass wg handler not initialized")
+ }
+
var err error
pcfg := rp.PeerConfig{PublicKey: rosenpassPubKey}
if m.preSharedKey != nil {
@@ -79,6 +116,16 @@ func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuar
if pcfg.Endpoint, err = net.ResolveUDPAddr("udp", peerAddr); err != nil {
return fmt.Errorf("failed to resolve peer endpoint address: %w", err)
}
+ // Our local Rosenpass UDP server binds on the IPv6 wildcard ([::]) — see
+ // GetAddress(). The remote peer's endpoint (pcfg.Endpoint) is the destination
+ // our server will sendto when initiating handshakes. ResolveUDPAddr returns a
+ // 4-byte IPv4 for IPv4 hosts, which the kernel rejects (EDESTADDRREQ) when
+ // sent from an AF_INET6 socket. Normalize the remote endpoint to IPv4-mapped
+ // IPv6 so its address family matches our listening socket.
+ // TODO: maybe bind the Rosenpass UDP server to the peer wg IP addr
+ if v4 := pcfg.Endpoint.IP.To4(); v4 != nil {
+ pcfg.Endpoint.IP = v4.To16()
+ }
}
peerID, err := m.server.AddPeer(pcfg)
if err != nil {
@@ -117,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)
}
@@ -182,24 +229,31 @@ func (m *Manager) Run() error {
return err
}
- m.server, err = rp.NewUDPServer(conf)
+ server, err := rp.NewUDPServer(conf)
if err != nil {
return err
}
+ m.lock.Lock()
+ m.server = server
+ m.lock.Unlock()
+
log.Infof("starting rosenpass server on port %d", m.port)
- return m.server.Run()
+ return server.Run()
}
// Close closes the Rosenpass server
func (m *Manager) Close() error {
- if m.server != nil {
- err := m.server.Close()
- if err != nil {
- log.Errorf("failed closing local rosenpass server")
- }
- m.server = nil
+ m.lock.Lock()
+ server := m.server
+ m.server = nil
+ m.lock.Unlock()
+ if server == nil {
+ return nil
+ }
+ if err := server.Close(); err != nil {
+ log.Errorf("failed closing local rosenpass server: %v", err)
}
return nil
}
diff --git a/client/internal/rosenpass/manager_test.go b/client/internal/rosenpass/manager_test.go
index 90bbdda59..69e18ac88 100644
--- a/client/internal/rosenpass/manager_test.go
+++ b/client/internal/rosenpass/manager_test.go
@@ -1,14 +1,412 @@
package rosenpass
import (
+ "errors"
+ "os"
+ "sync"
"testing"
+ rp "cunicu.li/go-rosenpass"
"github.com/stretchr/testify/require"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
+// --- test doubles -----------------------------------------------------------
+
+type addPeerCall struct {
+ cfg rp.PeerConfig
+}
+
+type removePeerCall struct {
+ id rp.PeerID
+}
+
+type mockServer struct {
+ mu sync.Mutex
+ addCalls []addPeerCall
+ removed []removePeerCall
+ nextID rp.PeerID
+ addErr error
+ removeErr error
+ closed bool
+ ran bool
+}
+
+func (m *mockServer) AddPeer(cfg rp.PeerConfig) (rp.PeerID, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.addCalls = append(m.addCalls, addPeerCall{cfg: cfg})
+ if m.addErr != nil {
+ return rp.PeerID{}, m.addErr
+ }
+ // Increment a byte in nextID so distinct peers get distinct IDs.
+ m.nextID[0]++
+ return m.nextID, nil
+}
+
+func (m *mockServer) RemovePeer(id rp.PeerID) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.removed = append(m.removed, removePeerCall{id: id})
+ return m.removeErr
+}
+
+func (m *mockServer) Run() error { m.ran = true; return nil }
+func (m *mockServer) Close() error { m.closed = true; return nil }
+
+type setPSKCall struct {
+ peerKey string
+ psk wgtypes.Key
+ updateOnly bool
+}
+
+type mockIface struct {
+ mu sync.Mutex
+ calls []setPSKCall
+ err error
+}
+
+func (m *mockIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.calls = append(m.calls, setPSKCall{peerKey: peerKey, psk: psk, updateOnly: updateOnly})
+ return m.err
+}
+
+// newTestManager builds a Manager with deterministic spk so tie-break
+// against a peer pubkey is controllable from tests. The provided spk byte
+// becomes the first byte; remaining bytes are zero.
+func newTestManager(spkFirstByte byte, mock *mockServer) *Manager {
+ spk := make([]byte, 32)
+ spk[0] = spkFirstByte
+ return &Manager{
+ ifaceName: "wt0",
+ spk: spk,
+ ssk: make([]byte, 32),
+ rpKeyHash: "test-hash",
+ rpPeerIDs: make(map[string]*rp.PeerID),
+ rpWgHandler: NewNetbirdHandler(nil, wgtypes.Key{0x01}),
+ server: mock,
+ }
+}
+
+// validWGKey returns a deterministic 32-byte wireguard public key (base64).
+func validWGKey(t *testing.T, lastByte byte) string {
+ t.Helper()
+ var k wgtypes.Key
+ k[31] = lastByte
+ return k.String()
+}
+
+// --- pure helpers ----------------------------------------------------------
+
+func TestHashRosenpassKey_Deterministic(t *testing.T) {
+ key := []byte("hello-rosenpass")
+ require.Equal(t, hashRosenpassKey(key), hashRosenpassKey(key))
+ require.Len(t, hashRosenpassKey(key), 64) // sha256 hex
+}
+
+func TestHashRosenpassKey_DifferentInputsDifferOutputs(t *testing.T) {
+ require.NotEqual(t, hashRosenpassKey([]byte("a")), hashRosenpassKey([]byte("b")))
+}
+
+func TestGetLogLevel_DefaultWhenUnset(t *testing.T) {
+ // Snapshot + unset to exercise the LookupEnv ok=false branch. t.Setenv
+ // can only set, not delete, so do it manually with restore via t.Cleanup.
+ prev, hadPrev := os.LookupEnv(defaultLogLevelVar)
+ require.NoError(t, os.Unsetenv(defaultLogLevelVar))
+ t.Cleanup(func() {
+ if hadPrev {
+ _ = os.Setenv(defaultLogLevelVar, prev)
+ } else {
+ _ = os.Unsetenv(defaultLogLevelVar)
+ }
+ })
+ require.Equal(t, defaultLog.String(), getLogLevel().String())
+}
+
+func TestGetLogLevel_Cases(t *testing.T) {
+ cases := map[string]string{
+ "debug": "DEBUG",
+ "info": "INFO",
+ "warn": "WARN",
+ "error": "ERROR",
+ "unknown": "INFO", // default fallback
+ }
+ for input, wantStr := range cases {
+ input, wantStr := input, wantStr
+ t.Run(input, func(t *testing.T) {
+ t.Setenv(defaultLogLevelVar, input)
+ require.Equal(t, wantStr, getLogLevel().String())
+ })
+ }
+}
+
func TestFindRandomAvailableUDPPort(t *testing.T) {
port, err := findRandomAvailableUDPPort()
require.NoError(t, err)
require.Greater(t, port, 0)
require.LessOrEqual(t, port, 65535)
}
+
+// --- addPeer ---------------------------------------------------------------
+
+func TestAddPeer_HigherLocalPubkey_SetsEndpoint(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv) // local spk lexicographically larger
+
+ remotePubKey := make([]byte, 32) // remote spk = all zeros (smaller)
+ err := m.addPeer(remotePubKey, "rosenpass-host:7000", "100.1.1.1", validWGKey(t, 1))
+ require.NoError(t, err)
+ require.Len(t, srv.addCalls, 1)
+
+ ep := srv.addCalls[0].cfg.Endpoint
+ require.NotNil(t, ep, "initiator side must set Endpoint")
+ require.Equal(t, 7000, ep.Port)
+ require.Equal(t, "100.1.1.1", ep.IP.String())
+}
+
+func TestAddPeer_HigherLocalPubkey_EndpointIPIsIPv4Mapped(t *testing.T) {
+ // Regression guard for the EDESTADDRREQ fix: Endpoint.IP must be 16-byte
+ // (IPv4-mapped IPv6) so it matches the AF_INET6 listening socket family.
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
+ require.NoError(t, err)
+
+ ep := srv.addCalls[0].cfg.Endpoint
+ require.NotNil(t, ep)
+ require.Len(t, ep.IP, 16, "IPv4 endpoint must be normalized to 16-byte v4-mapped form")
+ require.True(t, ep.IP.To4() != nil, "Endpoint must still be detected as IPv4")
+}
+
+func TestAddPeer_LowerLocalPubkey_LeavesEndpointNil(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0x00, srv) // local spk smaller
+
+ remotePubKey := make([]byte, 32)
+ remotePubKey[0] = 0xFF
+ err := m.addPeer(remotePubKey, "rp:5000", "100.1.1.1", validWGKey(t, 2))
+ require.NoError(t, err)
+
+ require.Nil(t, srv.addCalls[0].cfg.Endpoint, "responder side must NOT set Endpoint")
+}
+
+func TestAddPeer_PresharedKeyPropagated(t *testing.T) {
+ srv := &mockServer{}
+ psk := &wgtypes.Key{0x42}
+ m := newTestManager(0xFF, srv)
+ m.preSharedKey = (*[32]byte)(psk)
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 3))
+ require.NoError(t, err)
+ require.Equal(t, [32]byte(*psk), [32]byte(srv.addCalls[0].cfg.PresharedKey))
+}
+
+func TestAddPeer_InvalidRosenpassAddr_ReturnsError(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv) // initiator path → parses rosenpassAddr
+
+ err := m.addPeer(make([]byte, 32), "not-a-host-port", "100.1.1.1", validWGKey(t, 1))
+ require.Error(t, err)
+ require.Empty(t, srv.addCalls, "server.AddPeer must not run when address parse fails")
+}
+
+func TestAddPeer_InvalidWireGuardPubKey_ReturnsError(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", "not-a-valid-key")
+ require.Error(t, err)
+}
+
+func TestAddPeer_ServerError_Propagates(t *testing.T) {
+ srv := &mockServer{addErr: errors.New("boom")}
+ m := newTestManager(0xFF, srv)
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
+ require.Error(t, err)
+}
+
+// Regression guard for issue #4341 (Android crash). If Run() has not completed
+// before OnConnected fires, m.rpWgHandler or m.server may be nil. Without the
+// nil guards, m.rpWgHandler.AddPeer panics on nil receiver.
+func TestAddPeer_NilHandler_ReturnsErrorNoCrash(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+ m.rpWgHandler = nil // simulate Run() not yet completed
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "wg handler not initialized")
+}
+
+func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) {
+ m := newTestManager(0xFF, nil)
+ m.server = nil // simulate Run() not yet completed
+
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "server not initialized")
+}
+
+// NewManager must pre-initialize rpWgHandler so the nil-receiver crash from
+// issue #4341 cannot occur in the window between NewManager and Run().
+func TestNewManager_PreInitializesHandler(t *testing.T) {
+ psk := wgtypes.Key{}
+ m, err := NewManager(&psk, "wt0", wgtypes.Key{0x01})
+ require.NoError(t, err)
+ require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager")
+}
+
+func TestAddPeer_RecordsPeerID(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ wgKey := validWGKey(t, 5)
+ err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey)
+ require.NoError(t, err)
+ require.Contains(t, m.rpPeerIDs, wgKey)
+}
+
+// --- OnConnected / OnDisconnected ------------------------------------------
+
+func TestOnConnected_NilRemotePubKey_NoAddPeer(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ m.OnConnected(validWGKey(t, 1), nil, "100.1.1.1", "rp:5000")
+ require.Empty(t, srv.addCalls, "nil remote rosenpass pubkey must skip AddPeer")
+ require.Empty(t, m.rpPeerIDs)
+}
+
+func TestOnConnected_ValidPubKey_CallsAddPeer(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ wgKey := validWGKey(t, 1)
+ m.OnConnected(wgKey, make([]byte, 32), "100.1.1.1", "rp:5000")
+ require.Len(t, srv.addCalls, 1)
+ require.Contains(t, m.rpPeerIDs, wgKey)
+}
+
+func TestOnDisconnected_UnknownPeer_NoOp(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ m.OnDisconnected(validWGKey(t, 99))
+ require.Empty(t, srv.removed, "unknown peer key must not call RemovePeer")
+}
+
+func TestOnDisconnected_KnownPeer_CallsRemoveAndForgets(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ wgKey := validWGKey(t, 1)
+ require.NoError(t, m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey))
+ require.Contains(t, m.rpPeerIDs, wgKey)
+
+ m.OnDisconnected(wgKey)
+ require.Len(t, srv.removed, 1)
+ require.NotContains(t, m.rpPeerIDs, wgKey, "peer must be forgotten after disconnect")
+}
+
+// --- IsPresharedKeyInitialized ---------------------------------------------
+
+func TestIsPresharedKeyInitialized_UnknownPeer_ReturnsFalse(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+ require.False(t, m.IsPresharedKeyInitialized(validWGKey(t, 1)))
+}
+
+func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing.T) {
+ srv := &mockServer{}
+ m := newTestManager(0xFF, srv)
+
+ wgKey := validWGKey(t, 2)
+ require.NoError(t, m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey))
+ require.False(t, m.IsPresharedKeyInitialized(wgKey))
+}
+
+// --- NetbirdHandler.applyKey ----------------------------------------------
+
+func TestHandler_ApplyKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
+ h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
+ iface := &mockIface{}
+ h.SetInterface(iface)
+
+ pid := rp.PeerID{0x01}
+ wgKey := wgtypes.Key{0xAA}
+ h.AddPeer(pid, "wt0", rp.Key(wgKey))
+
+ psk := rp.Key{0xBB}
+ h.HandshakeCompleted(pid, psk)
+
+ require.Len(t, iface.calls, 1)
+ require.False(t, iface.calls[0].updateOnly, "first PSK rotation must use updateOnly=false")
+ require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
+}
+
+func TestHandler_ApplyKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
+ h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
+ iface := &mockIface{}
+ h.SetInterface(iface)
+
+ pid := rp.PeerID{0x02}
+ h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{0xCC}))
+
+ h.HandshakeCompleted(pid, rp.Key{0x01}) // first
+ h.HandshakeCompleted(pid, rp.Key{0x02}) // second
+
+ require.Len(t, iface.calls, 2)
+ require.False(t, iface.calls[0].updateOnly)
+ require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true")
+}
+
+func TestHandler_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{}))
+
+ // Must not panic.
+ h.HandshakeCompleted(pid, rp.Key{})
+}
+
+func TestHandler_ApplyKey_UnknownPeer_NoCall(t *testing.T) {
+ h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
+ iface := &mockIface{}
+ h.SetInterface(iface)
+
+ h.HandshakeCompleted(rp.PeerID{0xFF}, rp.Key{})
+ require.Empty(t, iface.calls, "unknown peer id must not trigger SetPresharedKey")
+}
+
+func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
+ h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
+ iface := &mockIface{}
+ h.SetInterface(iface)
+
+ pid := rp.PeerID{0x04}
+ h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{0xDD}))
+ h.HandshakeCompleted(pid, rp.Key{0x01})
+ require.True(t, h.IsPeerInitialized(pid))
+
+ h.RemovePeer(pid)
+ require.False(t, h.IsPeerInitialized(pid), "RemovePeer must clear initialized flag")
+}
+
+func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) {
+ h := NewNetbirdHandler(nil, wgtypes.Key{0x01})
+ pid := rp.PeerID{0x05}
+ wgKey := wgtypes.Key{0xEE}
+ h.AddPeer(pid, "wt0", rp.Key(wgKey))
+
+ iface := &mockIface{}
+ h.SetInterface(iface) // set after AddPeer
+
+ h.HandshakeCompleted(pid, rp.Key{0x42})
+ require.Len(t, iface.calls, 1)
+ require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
+}
diff --git a/client/internal/rosenpass/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
new file mode 100644
index 000000000..052c11ed4
--- /dev/null
+++ b/client/internal/rosenpass/seed.go
@@ -0,0 +1,59 @@
+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
+// lexicographically before concatenation.
+//
+// NetBird uses this value as the initial Rosenpass-side preshared key when no
+// explicit account-level PSK is configured, so both peers converge on the same
+// PSK before the first post-quantum handshake completes.
+//
+// The resulting key MUST NOT be treated as quantum-safe: it is deterministic
+// from public keys and exists only to seed WireGuard until Rosenpass rotates
+// in a real post-quantum PSK.
+func DeterministicSeedKey(localKey, remoteKey string) (*wgtypes.Key, error) {
+ lk := []byte(localKey)
+ rk := []byte(remoteKey)
+ if len(lk) < 16 || len(rk) < 16 {
+ return nil, fmt.Errorf("rosenpass: peer keys must be at least 16 bytes (got local=%d, remote=%d)", len(lk), len(rk))
+ }
+
+ var keyInput []byte
+ if localKey > remoteKey {
+ keyInput = append(keyInput, lk[:16]...)
+ keyInput = append(keyInput, rk[:16]...)
+ } else {
+ keyInput = append(keyInput, rk[:16]...)
+ keyInput = append(keyInput, lk[:16]...)
+ }
+
+ key, err := wgtypes.NewKey(keyInput)
+ if err != nil {
+ return nil, fmt.Errorf("rosenpass: deterministic seed key: %w", err)
+ }
+ return &key, nil
+}
diff --git a/client/internal/rosenpass/seed_test.go b/client/internal/rosenpass/seed_test.go
new file mode 100644
index 000000000..b6a9a5991
--- /dev/null
+++ b/client/internal/rosenpass/seed_test.go
@@ -0,0 +1,43 @@
+package rosenpass
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDeterministicSeedKey_SameForBothSides(t *testing.T) {
+ // Peer A and peer B must derive the same PSK regardless of which side
+ // computes it: the function orders inputs internally.
+ a := strings.Repeat("a", 32)
+ b := strings.Repeat("b", 32)
+
+ keyAB, err := DeterministicSeedKey(a, b)
+ require.NoError(t, err)
+ keyBA, err := DeterministicSeedKey(b, a)
+ require.NoError(t, err)
+ require.Equal(t, keyAB.String(), keyBA.String(), "swapping arguments must yield identical key")
+}
+
+func TestDeterministicSeedKey_ChangesWithKeys(t *testing.T) {
+ a := strings.Repeat("a", 32)
+ b := strings.Repeat("b", 32)
+ c := strings.Repeat("c", 32)
+
+ keyAB, err := DeterministicSeedKey(a, b)
+ require.NoError(t, err)
+ keyAC, err := DeterministicSeedKey(a, c)
+ require.NoError(t, err)
+ require.NotEqual(t, keyAB.String(), keyAC.String(), "different peer pair must yield different key")
+}
+
+func TestDeterministicSeedKey_TooShortKey_ReturnsError(t *testing.T) {
+ short := "short" // < 16 bytes
+ long := strings.Repeat("x", 32)
+
+ _, err := DeterministicSeedKey(short, long)
+ require.Error(t, err)
+ _, err = DeterministicSeedKey(long, short)
+ require.Error(t, err)
+}
diff --git a/client/internal/routemanager/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/manager.go b/client/internal/routemanager/manager.go
index 839ec14c0..0ccfa83ac 100644
--- a/client/internal/routemanager/manager.go
+++ b/client/internal/routemanager/manager.go
@@ -8,12 +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"
@@ -50,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)
}
@@ -69,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
@@ -142,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) {
@@ -213,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)
@@ -230,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()
@@ -263,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()
}
@@ -332,6 +331,8 @@ func (m *DefaultManager) Stop(stateManager *statemanager.Manager) {
}
}
+ m.notifier.Close()
+
m.mux.Lock()
defer m.mux.Unlock()
m.clientRoutes = nil
@@ -427,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)
@@ -439,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
@@ -458,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
@@ -579,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
@@ -654,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
@@ -698,15 +724,49 @@ 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) {
- exitNodeInfo := m.collectExitNodeInfo(clientRoutes)
- if len(exitNodeInfo.allIDs) == 0 {
+ m.mirrorV6ExitPairSelections(clientRoutes)
+
+ // An explicit user "deselect all" must not be overridden by management auto-apply.
+ // Auto-applying an exit node here would call SelectRoutes, which clears the
+ // deselect-all flag and re-enables every route the user turned off.
+ if m.routeSelector.IsDeselectAll() {
return
}
- m.updateExitNodeSelections(exitNodeInfo)
- m.logExitNodeUpdate(exitNodeInfo)
+ info := m.collectExitNodeInfo(clientRoutes)
+ if len(info.allIDs) == 0 {
+ return
+ }
+
+ preferred := pickPreferredExitNode(info)
+ m.enforceSingleExitNode(preferred, info.allIDs)
+ m.logExitNodeUpdate(info, preferred)
+}
+
+// mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection
+// consistent with its v4 base. The v4/v6 exit pair is a single toggle, so the v6
+// entry always follows the base: deselecting the v4 exit node also drops its ::/0
+// pair, and any stale (orphaned) explicit selection on the v6 entry is reset. This
+// runs before selection is read so both collectExitNodeInfo and FilterSelectedExitNodes
+// see consistent state, including pairs loaded from persisted selector state.
+func (m *DefaultManager) mirrorV6ExitPairSelections(clientRoutes route.HAMap) {
+ routesByNetID := make(map[route.NetID][]*route.Route, len(clientRoutes))
+ for haID, routes := range clientRoutes {
+ routesByNetID[haID.NetID()] = routes
+ }
+
+ for v6ID := range route.V6ExitMergeSet(routesByNetID) {
+ baseID := route.NetID(strings.TrimSuffix(string(v6ID), route.V6ExitSuffix))
+ m.routeSelector.SyncPairedSelection(baseID, v6ID)
+ }
}
type exitNodeInfo struct {
@@ -716,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) {
@@ -737,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)
@@ -761,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/manager_v6exit_test.go b/client/internal/routemanager/manager_v6exit_test.go
new file mode 100644
index 000000000..15ab99cbd
--- /dev/null
+++ b/client/internal/routemanager/manager_v6exit_test.go
@@ -0,0 +1,47 @@
+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"
+)
+
+// TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair reproduces the bug seen
+// in netbird-engine.log: persisted selector state has the v4 exit node deselected
+// but its synthesized "-v6" pair explicitly selected (orphaned), so the ::/0 route
+// leaked onto the tunnel. The management update must mirror the v4 deselect onto the
+// v6 pair so FilterSelectedExitNodes drops it.
+func TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair(t *testing.T) {
+ const (
+ v4ID = route.NetID("Exit Node (raspberrypi)")
+ v6ID = route.NetID("Exit Node (raspberrypi)-v6")
+ )
+ all := []route.NetID{v4ID, v6ID}
+
+ rs := routeselector.NewRouteSelector()
+ // Orphan the v6 selection: select the pair, then deselect only the v4 base.
+ require.NoError(t, rs.SelectRoutes([]route.NetID{v4ID, v6ID}, true, all))
+ require.NoError(t, rs.DeselectRoutes([]route.NetID{v4ID}, all))
+ require.True(t, rs.IsSelected(v6ID), "precondition: orphaned v6 selection survives v4 deselect")
+
+ m := &DefaultManager{routeSelector: rs}
+
+ v4Route := &route.Route{NetID: v4ID, Network: netip.MustParsePrefix("0.0.0.0/0")}
+ v6Route := &route.Route{NetID: v6ID, Network: netip.MustParsePrefix("::/0")}
+ clientRoutes := route.HAMap{
+ "Exit Node (raspberrypi)|0.0.0.0/0": {v4Route},
+ "Exit Node (raspberrypi)-v6|::/0": {v6Route},
+ }
+
+ m.updateRouteSelectorFromManagement(clientRoutes)
+
+ assert.False(t, rs.IsSelected(v6ID), "v6 pair must follow the v4 base deselect after the management update")
+
+ filtered := rs.FilterSelectedExitNodes(clientRoutes)
+ assert.Empty(t, filtered, "deselected v4 exit node must not leak its ::/0 pair onto the tunnel")
+}
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 140a583f7..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,21 +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 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 27a2a722d..d663dd471 100644
--- a/client/internal/routemanager/notifier/notifier_ios.go
+++ b/client/internal/routemanager/notifier/notifier_ios.go
@@ -14,10 +14,9 @@ import (
)
type Notifier struct {
+ mu sync.Mutex
currentPrefixes []string
-
- listener listener.NetworkChangeListener
- listenerMux sync.Mutex
+ listener listener.NetworkChangeListener
}
func NewNotifier() *Notifier {
@@ -25,16 +24,12 @@ 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
}
-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
}
@@ -43,30 +38,25 @@ func (n *Notifier) OnNewRoutes(route.HAMap) {
}
func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
- newNets := make([]string, 0)
+ newNets := make([]string, 0, len(prefixes))
for _, prefix := range prefixes {
newNets = append(newNets, prefix.String())
}
sort.Strings(newNets)
+ n.mu.Lock()
+ defer n.mu.Unlock()
if slices.Equal(n.currentPrefixes, newNets) {
return
}
-
n.currentPrefixes = newNets
- n.notify()
-}
-func (n *Notifier) notify() {
- n.listenerMux.Lock()
- defer n.listenerMux.Unlock()
- if n.listener == nil {
- return
+ if n.listener != nil {
+ n.listener.OnNetworkChanged(strings.Join(n.currentPrefixes, ","))
}
+}
- go func(l listener.NetworkChangeListener) {
- l.OnNetworkChanged(strings.Join(n.currentPrefixes, ","))
- }(n.listener)
+func (n *Notifier) Close() {
}
func (n *Notifier) GetInitialRouteRanges() []string {
diff --git a/client/internal/routemanager/notifier/notifier_other.go b/client/internal/routemanager/notifier/notifier_other.go
index f57cadb0b..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,6 +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/selector_management_test.go b/client/internal/routemanager/selector_management_test.go
new file mode 100644
index 000000000..04659db65
--- /dev/null
+++ b/client/internal/routemanager/selector_management_test.go
@@ -0,0 +1,71 @@
+package routemanager
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal/routeselector"
+ "github.com/netbirdio/netbird/route"
+)
+
+func exitNodeRoutes(netID route.NetID, skipAutoApply bool) route.HAMap {
+ haID := route.HAUniqueID(string(netID) + "|0.0.0.0/0")
+ return route.HAMap{
+ haID: []*route.Route{
+ {
+ ID: "r-" + route.ID(netID),
+ NetID: netID,
+ Network: netip.MustParsePrefix("0.0.0.0/0"),
+ NetworkType: route.IPv4Network,
+ Enabled: true,
+ SkipAutoApply: skipAutoApply,
+ },
+ },
+ }
+}
+
+func TestUpdateRouteSelectorFromManagement(t *testing.T) {
+ t.Run("management auto-apply selects exit node without user selection", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ routes := exitNodeRoutes("exit1", false)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsSelected("exit1"), "auto-apply exit node should be selected")
+ require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "selected exit node should pass the filter")
+ })
+
+ t.Run("management SkipAutoApply leaves exit node deselected", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ routes := exitNodeRoutes("exit1", true)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.False(t, m.routeSelector.IsSelected("exit1"), "SkipAutoApply exit node should not be selected")
+ require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "deselected exit node should be filtered out")
+ })
+
+ t.Run("user selection is not overridden by management", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exit1"}, true, []route.NetID{"exit1"}))
+ routes := exitNodeRoutes("exit1", true)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsSelected("exit1"), "explicit user selection must survive a management sync that wants to skip auto-apply")
+ require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "user-selected exit node should pass the filter")
+ })
+
+ t.Run("deselect-all is preserved across a management sync", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ m.routeSelector.DeselectAllRoutes()
+ routes := exitNodeRoutes("exit1", false)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsDeselectAll(), "an explicit deselect-all must not be cleared by management auto-apply")
+ require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "no routes should be selected while deselect-all is set")
+ })
+}
diff --git a/client/internal/routemanager/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..46b7c9fb7 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,11 @@ func Setup(wgIface iface) (map[string]int, error) {
continue
}
- i := fmt.Sprintf(rpFilterInterfacePath, intf.Name)
+ // Escape '%' and '.' so they survive the dot-to-slash conversion in Set()
+ safeName := strings.ReplaceAll(intf.Name, "%", percentEscape)
+ safeName = strings.ReplaceAll(safeName, ".", dotEscape)
+
+ i := fmt.Sprintf(rpFilterInterfacePath, safeName)
oldVal, err := Set(i, 2, true)
if err != nil {
result = multierror.Append(result, err)
@@ -70,7 +76,11 @@ func Setup(wgIface iface) (map[string]int, error) {
// 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/routeflags_addfilter_bsd.go b/client/internal/routemanager/systemops/routeflags_addfilter_bsd.go
new file mode 100644
index 000000000..45a1bfceb
--- /dev/null
+++ b/client/internal/routemanager/systemops/routeflags_addfilter_bsd.go
@@ -0,0 +1,9 @@
+//go:build dragonfly || freebsd || netbsd || openbsd
+
+package systemops
+
+// IgnoreAddedDefaultRoute reports whether an RTM_ADD default route with the
+// given flags should be ignored by the network monitor.
+func IgnoreAddedDefaultRoute(flags int) bool {
+ return filterRoutesByFlags(flags)
+}
diff --git a/client/internal/routemanager/systemops/routeflags_addfilter_darwin.go b/client/internal/routemanager/systemops/routeflags_addfilter_darwin.go
new file mode 100644
index 000000000..e8f655387
--- /dev/null
+++ b/client/internal/routemanager/systemops/routeflags_addfilter_darwin.go
@@ -0,0 +1,21 @@
+//go:build darwin
+
+package systemops
+
+import "golang.org/x/sys/unix"
+
+// IgnoreAddedDefaultRoute reports whether an RTM_ADD default route with the
+// given flags should be ignored by the network monitor. Scoped routes
+// (RTF_IFSCOPE) are tied to a specific interface index and cannot replace the
+// unscoped default the kernel uses for general egress, so flapping ones (e.g.
+// Wi-Fi calling IMS tunnels on ipsec0, Docker bridges, scoped utun defaults)
+// must not trigger an engine restart.
+func IgnoreAddedDefaultRoute(flags int) bool {
+ if filterRoutesByFlags(flags) {
+ return true
+ }
+ if flags&unix.RTF_IFSCOPE != 0 {
+ return true
+ }
+ return false
+}
diff --git a/client/internal/routemanager/systemops/rt_tables_linux_test.go b/client/internal/routemanager/systemops/rt_tables_linux_test.go
new file mode 100644
index 000000000..bc9cca8b1
--- /dev/null
+++ b/client/internal/routemanager/systemops/rt_tables_linux_test.go
@@ -0,0 +1,69 @@
+//go:build linux && !android
+
+package systemops
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEntryExists(t *testing.T) {
+ tempDir := t.TempDir()
+ tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir)
+
+ content := []string{
+ "1000 reserved",
+ fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName),
+ "9999 other_table",
+ }
+ require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644))
+
+ file, err := os.Open(tempFilePath)
+ require.NoError(t, err)
+ defer func() {
+ assert.NoError(t, file.Close())
+ }()
+
+ tests := []struct {
+ name string
+ id int
+ shouldExist bool
+ err error
+ }{
+ {
+ name: "ExistsWithNetbirdPrefix",
+ id: 7120,
+ shouldExist: true,
+ err: nil,
+ },
+ {
+ name: "ExistsWithDifferentName",
+ id: 1000,
+ shouldExist: true,
+ err: ErrTableIDExists,
+ },
+ {
+ name: "DoesNotExist",
+ id: 1234,
+ shouldExist: false,
+ err: nil,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ exists, err := entryExists(file, tc.id)
+ if tc.err != nil {
+ assert.ErrorIs(t, err, tc.err)
+ } else {
+ assert.NoError(t, err)
+ }
+ assert.Equal(t, tc.shouldExist, exists)
+ })
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go
new file mode 100644
index 000000000..d45028c19
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go
@@ -0,0 +1,191 @@
+//go:build (darwin || dragonfly || freebsd || netbsd || openbsd) && privileged
+
+package systemops
+
+import (
+ "fmt"
+ "net"
+ "net/netip"
+ "os/exec"
+ "regexp"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func init() {
+ testCases = append(testCases, []testCase{
+ {
+ name: "To more specific route without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53),
+ },
+ }...)
+}
+
+func TestConcurrentRoutes(t *testing.T) {
+ baseIP := netip.MustParseAddr("192.0.2.0")
+
+ var intf *net.Interface
+ var nexthop Nexthop
+
+ _, intf = setupDummyInterface(t)
+ nexthop = Nexthop{netip.Addr{}, intf}
+
+ r := New(nil, nil)
+
+ var wg sync.WaitGroup
+ for i := 0; i < 1024; i++ {
+ wg.Add(1)
+ go func(ip netip.Addr) {
+ defer wg.Done()
+ prefix := netip.PrefixFrom(ip, 32)
+ if err := r.addToRouteTable(prefix, nexthop); err != nil {
+ t.Errorf("Failed to add route for %s: %v", prefix, err)
+ }
+ }(baseIP)
+ baseIP = baseIP.Next()
+ }
+
+ wg.Wait()
+
+ baseIP = netip.MustParseAddr("192.0.2.0")
+
+ for i := 0; i < 1024; i++ {
+ wg.Add(1)
+ go func(ip netip.Addr) {
+ defer wg.Done()
+ prefix := netip.PrefixFrom(ip, 32)
+ if err := r.removeFromRouteTable(prefix, nexthop); err != nil {
+ t.Errorf("Failed to remove route for %s: %v", prefix, err)
+ }
+ }(baseIP)
+ baseIP = baseIP.Next()
+ }
+
+ wg.Wait()
+}
+
+func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string {
+ t.Helper()
+
+ if runtime.GOOS == "darwin" {
+ err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run()
+ require.NoError(t, err, "Failed to create loopback alias")
+
+ t.Cleanup(func() {
+ err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run()
+ assert.NoError(t, err, "Failed to remove loopback alias")
+ })
+
+ return intf
+ }
+
+ prefix, err := netip.ParsePrefix(ipAddressCIDR)
+ require.NoError(t, err, "Failed to parse prefix")
+
+ netIntf, err := net.InterfaceByName(intf)
+ require.NoError(t, err, "Failed to get interface by name")
+
+ nexthop := Nexthop{netip.Addr{}, netIntf}
+
+ r := New(nil, nil)
+ err = r.addToRouteTable(prefix, nexthop)
+ require.NoError(t, err, "Failed to add route to table")
+
+ t.Cleanup(func() {
+ err := r.removeFromRouteTable(prefix, nexthop)
+ assert.NoError(t, err, "Failed to remove route from table")
+ })
+
+ return intf
+}
+
+func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) {
+ t.Helper()
+
+ var originalNexthop net.IP
+ if dstCIDR == "0.0.0.0/0" {
+ var err error
+ originalNexthop, err = fetchOriginalGateway()
+ if err != nil {
+ t.Logf("Failed to fetch original gateway: %v", err)
+ }
+
+ if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil {
+ t.Logf("Failed to delete route: %v, output: %s", err, output)
+ }
+ }
+
+ t.Cleanup(func() {
+ if originalNexthop != nil {
+ err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run()
+ assert.NoError(t, err, "Failed to restore original route")
+ }
+ })
+
+ err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run()
+ require.NoError(t, err, "Failed to add route")
+
+ t.Cleanup(func() {
+ err := exec.Command("route", "delete", "-net", dstCIDR).Run()
+ assert.NoError(t, err, "Failed to remove route")
+ })
+}
+
+func fetchOriginalGateway() (net.IP, error) {
+ output, err := exec.Command("route", "-n", "get", "default").CombinedOutput()
+ if err != nil {
+ return nil, err
+ }
+
+ matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output))
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("gateway not found")
+ }
+
+ return net.ParseIP(matches[1]), nil
+}
+
+// setupDummyInterface creates a dummy tun interface for FreeBSD route testing
+func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) {
+ t.Helper()
+
+ if runtime.GOOS == "darwin" {
+ return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"}
+ }
+
+ output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput()
+ require.NoError(t, err, "Failed to create tun interface: %s", string(output))
+
+ tunName := strings.TrimSpace(string(output))
+
+ output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput()
+ require.NoError(t, err, "Failed to configure tun interface: %s", string(output))
+
+ intf, err := net.InterfaceByName(tunName)
+ require.NoError(t, err, "Failed to get interface by name")
+
+ t.Cleanup(func() {
+ if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil {
+ t.Logf("Failed to destroy tun interface %s: %v", tunName, err)
+ }
+ })
+
+ return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf
+}
+
+func setupDummyInterfacesAndRoutes(t *testing.T) {
+ t.Helper()
+
+ defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24")
+ addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy)
+
+ otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24")
+ addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy)
+}
diff --git a/client/internal/routemanager/systemops/systemops_bsd_test.go b/client/internal/routemanager/systemops/systemops_bsd_test.go
index ec4fc406e..9650945b3 100644
--- a/client/internal/routemanager/systemops/systemops_bsd_test.go
+++ b/client/internal/routemanager/systemops/systemops_bsd_test.go
@@ -3,79 +3,24 @@
package systemops
import (
- "fmt"
- "net"
- "net/netip"
- "os/exec"
- "regexp"
- "runtime"
- "strings"
- "sync"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
"golang.org/x/net/route"
)
+// Interface names used by the shared routing test fixtures. Kept untagged (no
+// privileged build tag) so the non-privileged test files in this package compile.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedVPNint = "utun100"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedExternalInt = "lo0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedInternalInt = "lo0"
-func init() {
- testCases = append(testCases, []testCase{
- {
- name: "To more specific route without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53),
- },
- }...)
-}
-
-func TestConcurrentRoutes(t *testing.T) {
- baseIP := netip.MustParseAddr("192.0.2.0")
-
- var intf *net.Interface
- var nexthop Nexthop
-
- _, intf = setupDummyInterface(t)
- nexthop = Nexthop{netip.Addr{}, intf}
-
- r := New(nil, nil)
-
- var wg sync.WaitGroup
- for i := 0; i < 1024; i++ {
- wg.Add(1)
- go func(ip netip.Addr) {
- defer wg.Done()
- prefix := netip.PrefixFrom(ip, 32)
- if err := r.addToRouteTable(prefix, nexthop); err != nil {
- t.Errorf("Failed to add route for %s: %v", prefix, err)
- }
- }(baseIP)
- baseIP = baseIP.Next()
- }
-
- wg.Wait()
-
- baseIP = netip.MustParseAddr("192.0.2.0")
-
- for i := 0; i < 1024; i++ {
- wg.Add(1)
- go func(ip netip.Addr) {
- defer wg.Done()
- prefix := netip.PrefixFrom(ip, 32)
- if err := r.removeFromRouteTable(prefix, nexthop); err != nil {
- t.Errorf("Failed to remove route for %s: %v", prefix, err)
- }
- }(baseIP)
- baseIP = baseIP.Next()
- }
-
- wg.Wait()
-}
-
func TestBits(t *testing.T) {
tests := []struct {
name string
@@ -122,122 +67,3 @@ func TestBits(t *testing.T) {
})
}
}
-
-func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string {
- t.Helper()
-
- if runtime.GOOS == "darwin" {
- err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run()
- require.NoError(t, err, "Failed to create loopback alias")
-
- t.Cleanup(func() {
- err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run()
- assert.NoError(t, err, "Failed to remove loopback alias")
- })
-
- return intf
- }
-
- prefix, err := netip.ParsePrefix(ipAddressCIDR)
- require.NoError(t, err, "Failed to parse prefix")
-
- netIntf, err := net.InterfaceByName(intf)
- require.NoError(t, err, "Failed to get interface by name")
-
- nexthop := Nexthop{netip.Addr{}, netIntf}
-
- r := New(nil, nil)
- err = r.addToRouteTable(prefix, nexthop)
- require.NoError(t, err, "Failed to add route to table")
-
- t.Cleanup(func() {
- err := r.removeFromRouteTable(prefix, nexthop)
- assert.NoError(t, err, "Failed to remove route from table")
- })
-
- return intf
-}
-
-func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) {
- t.Helper()
-
- var originalNexthop net.IP
- if dstCIDR == "0.0.0.0/0" {
- var err error
- originalNexthop, err = fetchOriginalGateway()
- if err != nil {
- t.Logf("Failed to fetch original gateway: %v", err)
- }
-
- if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil {
- t.Logf("Failed to delete route: %v, output: %s", err, output)
- }
- }
-
- t.Cleanup(func() {
- if originalNexthop != nil {
- err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run()
- assert.NoError(t, err, "Failed to restore original route")
- }
- })
-
- err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run()
- require.NoError(t, err, "Failed to add route")
-
- t.Cleanup(func() {
- err := exec.Command("route", "delete", "-net", dstCIDR).Run()
- assert.NoError(t, err, "Failed to remove route")
- })
-}
-
-func fetchOriginalGateway() (net.IP, error) {
- output, err := exec.Command("route", "-n", "get", "default").CombinedOutput()
- if err != nil {
- return nil, err
- }
-
- matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output))
- if len(matches) == 0 {
- return nil, fmt.Errorf("gateway not found")
- }
-
- return net.ParseIP(matches[1]), nil
-}
-
-// setupDummyInterface creates a dummy tun interface for FreeBSD route testing
-func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) {
- t.Helper()
-
- if runtime.GOOS == "darwin" {
- return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"}
- }
-
- output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput()
- require.NoError(t, err, "Failed to create tun interface: %s", string(output))
-
- tunName := strings.TrimSpace(string(output))
-
- output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput()
- require.NoError(t, err, "Failed to configure tun interface: %s", string(output))
-
- intf, err := net.InterfaceByName(tunName)
- require.NoError(t, err, "Failed to get interface by name")
-
- t.Cleanup(func() {
- if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil {
- t.Logf("Failed to destroy tun interface %s: %v", tunName, err)
- }
- })
-
- return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf
-}
-
-func setupDummyInterfacesAndRoutes(t *testing.T) {
- t.Helper()
-
- defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24")
- addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy)
-
- otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24")
- addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy)
-}
diff --git a/client/internal/routemanager/systemops/systemops_dialer_test.go b/client/internal/routemanager/systemops/systemops_dialer_test.go
new file mode 100644
index 000000000..f00f9099c
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_dialer_test.go
@@ -0,0 +1,17 @@
+//go:build !android && !ios
+
+package systemops
+
+import (
+ "context"
+ "net"
+)
+
+// dialer is shared by the per-platform routing test cases. Kept untagged (no
+// privileged build tag) so the non-privileged test files compile on every platform.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
+type dialer interface {
+ Dial(network, address string) (net.Conn, error)
+ DialContext(ctx context.Context, network, address string) (net.Conn, error)
+}
diff --git a/client/internal/routemanager/systemops/systemops_generic.go b/client/internal/routemanager/systemops/systemops_generic.go
index 2b96c14dc..bb9ac494d 100644
--- a/client/internal/routemanager/systemops/systemops_generic.go
+++ b/client/internal/routemanager/systemops/systemops_generic.go
@@ -121,9 +121,12 @@ func (r *SysOps) addRouteToNonVPNIntf(prefix netip.Prefix, vpnIntf wgIface, init
return Nexthop{}, vars.ErrRouteNotAllowed
}
- // Check if the prefix is part of any local subnets
- if isLocal, subnet := r.isPrefixInLocalSubnets(prefix); isLocal {
- return Nexthop{}, fmt.Errorf("prefix %s is part of local subnet %s: %w", prefix, subnet, vars.ErrRouteNotAllowed)
+ // BSDs blackhole a /32 added inside a directly-connected subnet; Linux/Windows need it to beat the wt0 route.
+ switch runtime.GOOS {
+ case "darwin", "freebsd", "netbsd", "openbsd", "dragonfly":
+ if isLocal, subnet := r.isPrefixInLocalSubnets(prefix); isLocal {
+ return Nexthop{}, fmt.Errorf("prefix %s is part of local subnet %s: %w", prefix, subnet, vars.ErrRouteNotAllowed)
+ }
}
// Determine the exit interface and next hop for the prefix, so we can add a specific route
diff --git a/client/internal/routemanager/systemops/systemops_generic_test.go b/client/internal/routemanager/systemops/systemops_generic_test.go
index 5695c40c3..c4f739c30 100644
--- a/client/internal/routemanager/systemops/systemops_generic_test.go
+++ b/client/internal/routemanager/systemops/systemops_generic_test.go
@@ -1,4 +1,4 @@
-//go:build !android && !ios
+//go:build !android && !ios && privileged
package systemops
@@ -26,11 +26,6 @@ import (
nbnet "github.com/netbirdio/netbird/client/net"
)
-type dialer interface {
- Dial(network, address string) (net.Conn, error)
- DialContext(ctx context.Context, network, address string) (net.Conn, error)
-}
-
func TestAddVPNRoute(t *testing.T) {
testCases := []struct {
name string
@@ -515,125 +510,3 @@ func setupTestEnv(t *testing.T) {
// unique route in vpn table
setupRouteAndCleanup(t, r, netip.MustParsePrefix("172.16.0.0/12"), intf)
}
-
-func TestIsVpnRoute(t *testing.T) {
- tests := []struct {
- name string
- addr string
- vpnRoutes []string
- localRoutes []string
- expectedVpn bool
- expectedPrefix netip.Prefix
- }{
- {
- name: "Match in VPN routes",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Match in local routes",
- addr: "10.1.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"),
- },
- {
- name: "No match",
- addr: "172.16.0.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.Prefix{},
- },
- {
- name: "Default route ignored",
- addr: "192.168.1.1",
- vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Default route matches but ignored",
- addr: "172.16.1.1",
- vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.Prefix{},
- },
- {
- name: "Longest prefix match local",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.0.0/16"},
- localRoutes: []string{"192.168.1.0/24"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Longest prefix match local multiple",
- addr: "192.168.0.1",
- vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
- localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"),
- },
- {
- name: "Longest prefix match vpn",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"192.168.0.0/16"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Longest prefix match vpn multiple",
- addr: "192.168.0.1",
- vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
- localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"),
- },
- {
- name: "Duplicate prefix in both",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"192.168.1.0/24"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- addr, err := netip.ParseAddr(tt.addr)
- if err != nil {
- t.Fatalf("Failed to parse address %s: %v", tt.addr, err)
- }
-
- var vpnRoutes, localRoutes []netip.Prefix
- for _, route := range tt.vpnRoutes {
- prefix, err := netip.ParsePrefix(route)
- if err != nil {
- t.Fatalf("Failed to parse VPN route %s: %v", route, err)
- }
- vpnRoutes = append(vpnRoutes, prefix)
- }
-
- for _, route := range tt.localRoutes {
- prefix, err := netip.ParsePrefix(route)
- if err != nil {
- t.Fatalf("Failed to parse local route %s: %v", route, err)
- }
- localRoutes = append(localRoutes, prefix)
- }
-
- isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes)
- assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value")
- assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix")
- })
- }
-}
diff --git a/client/internal/routemanager/systemops/systemops_isvpnroute_test.go b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go
new file mode 100644
index 000000000..677fe1287
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go
@@ -0,0 +1,132 @@
+//go:build !android && !ios
+
+package systemops
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsVpnRoute(t *testing.T) {
+ tests := []struct {
+ name string
+ addr string
+ vpnRoutes []string
+ localRoutes []string
+ expectedVpn bool
+ expectedPrefix netip.Prefix
+ }{
+ {
+ name: "Match in VPN routes",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Match in local routes",
+ addr: "10.1.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"),
+ },
+ {
+ name: "No match",
+ addr: "172.16.0.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.Prefix{},
+ },
+ {
+ name: "Default route ignored",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Default route matches but ignored",
+ addr: "172.16.1.1",
+ vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.Prefix{},
+ },
+ {
+ name: "Longest prefix match local",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.0.0/16"},
+ localRoutes: []string{"192.168.1.0/24"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Longest prefix match local multiple",
+ addr: "192.168.0.1",
+ vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
+ localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"),
+ },
+ {
+ name: "Longest prefix match vpn",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"192.168.0.0/16"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Longest prefix match vpn multiple",
+ addr: "192.168.0.1",
+ vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
+ localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"),
+ },
+ {
+ name: "Duplicate prefix in both",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"192.168.1.0/24"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ addr, err := netip.ParseAddr(tt.addr)
+ if err != nil {
+ t.Fatalf("Failed to parse address %s: %v", tt.addr, err)
+ }
+
+ var vpnRoutes, localRoutes []netip.Prefix
+ for _, route := range tt.vpnRoutes {
+ prefix, err := netip.ParsePrefix(route)
+ if err != nil {
+ t.Fatalf("Failed to parse VPN route %s: %v", route, err)
+ }
+ vpnRoutes = append(vpnRoutes, prefix)
+ }
+
+ for _, route := range tt.localRoutes {
+ prefix, err := netip.ParsePrefix(route)
+ if err != nil {
+ t.Fatalf("Failed to parse local route %s: %v", route, err)
+ }
+ localRoutes = append(localRoutes, prefix)
+ }
+
+ isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes)
+ assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value")
+ assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix")
+ })
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_linux_test.go b/client/internal/routemanager/systemops/systemops_linux_test.go
index 880296d91..06c528ce5 100644
--- a/client/internal/routemanager/systemops/systemops_linux_test.go
+++ b/client/internal/routemanager/systemops/systemops_linux_test.go
@@ -1,13 +1,10 @@
-//go:build !android
+//go:build linux && !android && privileged
package systemops
import (
"errors"
- "fmt"
"net"
- "os"
- "strings"
"syscall"
"testing"
@@ -18,10 +15,6 @@ import (
"github.com/netbirdio/netbird/client/internal/routemanager/vars"
)
-var expectedVPNint = "wgtest0"
-var expectedExternalInt = "dummyext0"
-var expectedInternalInt = "dummyint0"
-
func init() {
testCases = append(testCases, []testCase{
{
@@ -33,62 +26,6 @@ func init() {
}...)
}
-func TestEntryExists(t *testing.T) {
- tempDir := t.TempDir()
- tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir)
-
- content := []string{
- "1000 reserved",
- fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName),
- "9999 other_table",
- }
- require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644))
-
- file, err := os.Open(tempFilePath)
- require.NoError(t, err)
- defer func() {
- assert.NoError(t, file.Close())
- }()
-
- tests := []struct {
- name string
- id int
- shouldExist bool
- err error
- }{
- {
- name: "ExistsWithNetbirdPrefix",
- id: 7120,
- shouldExist: true,
- err: nil,
- },
- {
- name: "ExistsWithDifferentName",
- id: 1000,
- shouldExist: true,
- err: ErrTableIDExists,
- },
- {
- name: "DoesNotExist",
- id: 1234,
- shouldExist: false,
- err: nil,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- exists, err := entryExists(file, tc.id)
- if tc.err != nil {
- assert.ErrorIs(t, err, tc.err)
- } else {
- assert.NoError(t, err)
- }
- assert.Equal(t, tc.shouldExist, exists)
- })
- }
-}
-
func createAndSetupDummyInterface(t *testing.T, interfaceName, ipAddressCIDR string) string {
t.Helper()
diff --git a/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go
new file mode 100644
index 000000000..9be267980
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go
@@ -0,0 +1,15 @@
+//go:build linux && !android
+
+package systemops
+
+// Interface names used by the shared routing test fixtures. Kept untagged (no
+// privileged build tag) so the non-privileged test files in this package compile.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedVPNint = "wgtest0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedExternalInt = "dummyext0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedInternalInt = "dummyint0"
diff --git a/client/internal/routemanager/systemops/systemops_routing_data_test.go b/client/internal/routemanager/systemops/systemops_routing_data_test.go
new file mode 100644
index 000000000..16f17f5b9
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_routing_data_test.go
@@ -0,0 +1,83 @@
+//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly
+
+package systemops
+
+import (
+ "net"
+
+ nbnet "github.com/netbirdio/netbird/client/net"
+)
+
+// Shared, non-privileged routing test fixtures. The privileged TestRouting (and its
+// per-platform init() appenders) consume these; they live here so the unprivileged
+// BSD/darwin test files compile without the privileged build tag.
+
+type PacketExpectation struct {
+ SrcIP net.IP
+ DstIP net.IP
+ SrcPort int
+ DstPort int
+ UDP bool
+ TCP bool
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+type testCase struct {
+ name string
+ expectedInterface string
+ dialer dialer
+ expectedPacket PacketExpectation
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var testCases = []testCase{
+ {
+ name: "To external host without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53),
+ },
+ {
+ name: "To external host with custom dialer via physical interface",
+ expectedInterface: expectedExternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53),
+ },
+
+ {
+ name: "To duplicate internal route with custom dialer via physical interface",
+ expectedInterface: expectedInternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
+ },
+ {
+ name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence
+ expectedInterface: expectedInternalInt,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
+ },
+
+ {
+ name: "To unique vpn route with custom dialer via physical interface",
+ expectedInterface: expectedExternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53),
+ },
+ {
+ name: "To unique vpn route without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53),
+ },
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation {
+ return PacketExpectation{
+ SrcIP: net.ParseIP(srcIP),
+ DstIP: net.ParseIP(dstIP),
+ SrcPort: srcPort,
+ DstPort: dstPort,
+ UDP: true,
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_unix_test.go b/client/internal/routemanager/systemops/systemops_unix_test.go
index 959c697e4..efb0ae4e4 100644
--- a/client/internal/routemanager/systemops/systemops_unix_test.go
+++ b/client/internal/routemanager/systemops/systemops_unix_test.go
@@ -1,4 +1,4 @@
-//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly
+//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly) && privileged
package systemops
@@ -20,63 +20,6 @@ import (
nbnet "github.com/netbirdio/netbird/client/net"
)
-type PacketExpectation struct {
- SrcIP net.IP
- DstIP net.IP
- SrcPort int
- DstPort int
- UDP bool
- TCP bool
-}
-
-type testCase struct {
- name string
- expectedInterface string
- dialer dialer
- expectedPacket PacketExpectation
-}
-
-var testCases = []testCase{
- {
- name: "To external host without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53),
- },
- {
- name: "To external host with custom dialer via physical interface",
- expectedInterface: expectedExternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53),
- },
-
- {
- name: "To duplicate internal route with custom dialer via physical interface",
- expectedInterface: expectedInternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
- },
- {
- name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence
- expectedInterface: expectedInternalInt,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
- },
-
- {
- name: "To unique vpn route with custom dialer via physical interface",
- expectedInterface: expectedExternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53),
- },
- {
- name: "To unique vpn route without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53),
- },
-}
-
func TestRouting(t *testing.T) {
nbnet.Init()
for _, tc := range testCases {
@@ -102,16 +45,6 @@ func TestRouting(t *testing.T) {
}
}
-func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation {
- return PacketExpectation{
- SrcIP: net.ParseIP(srcIP),
- DstIP: net.ParseIP(dstIP),
- SrcPort: srcPort,
- DstPort: dstPort,
- UDP: true,
- }
-}
-
func startPacketCapture(t *testing.T, intf, filter string) *pcap.Handle {
t.Helper()
diff --git a/client/internal/routemanager/systemops/systemops_windows_test.go b/client/internal/routemanager/systemops/systemops_windows_test.go
index 3561adec4..77e349bd6 100644
--- a/client/internal/routemanager/systemops/systemops_windows_test.go
+++ b/client/internal/routemanager/systemops/systemops_windows_test.go
@@ -1,3 +1,5 @@
+//go:build windows && privileged
+
package systemops
import (
diff --git a/client/internal/routemanager/systemops/v6route_bsd_test.go b/client/internal/routemanager/systemops/v6route_bsd_test.go
index 98ce29c6d..90e49f54e 100644
--- a/client/internal/routemanager/systemops/v6route_bsd_test.go
+++ b/client/internal/routemanager/systemops/v6route_bsd_test.go
@@ -11,6 +11,8 @@ import (
// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback
// interface so route lookups for global IPv6 prefixes resolve in environments
// without v6 connectivity. If a default already exists it is left alone.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
func ensureIPv6DefaultRoute(t *testing.T) {
t.Helper()
diff --git a/client/internal/routemanager/systemops/v6route_linux_test.go b/client/internal/routemanager/systemops/v6route_linux_test.go
index 0b17cefff..449d4cbd2 100644
--- a/client/internal/routemanager/systemops/v6route_linux_test.go
+++ b/client/internal/routemanager/systemops/v6route_linux_test.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && privileged
package systemops
diff --git a/client/internal/routemanager/systemops/v6route_windows_test.go b/client/internal/routemanager/systemops/v6route_windows_test.go
index f79277b87..2c813a790 100644
--- a/client/internal/routemanager/systemops/v6route_windows_test.go
+++ b/client/internal/routemanager/systemops/v6route_windows_test.go
@@ -8,11 +8,14 @@ import (
"testing"
)
+//nolint:unused // consumed by the privileged-tagged routing tests
const loopbackIfaceWindows = "Loopback Pseudo-Interface 1"
// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback
// interface so route lookups for global IPv6 prefixes resolve in environments
// without v6 connectivity. If a default already exists it is left alone.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
func ensureIPv6DefaultRoute(t *testing.T) {
t.Helper()
diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go
index 2ddc24bf2..1254b384d 100644
--- a/client/internal/routeselector/routeselector.go
+++ b/client/internal/routeselector/routeselector.go
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"slices"
- "strings"
"sync"
"github.com/hashicorp/go-multierror"
@@ -116,6 +115,45 @@ func (rs *RouteSelector) DeselectAllRoutes() {
clear(rs.selectedRoutes)
}
+// 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()
+
+ return rs.deselectAll
+}
+
// IsSelected checks if a specific route is selected.
func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
rs.mu.RLock()
@@ -124,6 +162,33 @@ func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
return rs.isSelectedLocked(routeID)
}
+// SyncPairedSelection forces pairedID's explicit selection state to match baseID's,
+// so a synthesized "-v6" exit route always follows its v4 base: selecting or
+// deselecting the v4 exit node governs the ::/0 pair, and any stale (orphaned)
+// explicit state on the v6 entry is reset. The v4/v6 exit pair is treated as a single
+// toggle, so the v6 entry carries no independent selection of its own.
+func (rs *RouteSelector) SyncPairedSelection(baseID, pairedID route.NetID) {
+ rs.mu.Lock()
+ defer rs.mu.Unlock()
+
+ if rs.deselectAll {
+ return
+ }
+
+ _, baseSelected := rs.selectedRoutes[baseID]
+ _, baseDeselected := rs.deselectedRoutes[baseID]
+
+ delete(rs.selectedRoutes, pairedID)
+ delete(rs.deselectedRoutes, pairedID)
+
+ switch {
+ case baseSelected:
+ rs.selectedRoutes[pairedID] = struct{}{}
+ case baseDeselected:
+ rs.deselectedRoutes[pairedID] = struct{}{}
+ }
+}
+
// FilterSelected removes unselected routes from the provided map.
func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
rs.mu.RLock()
@@ -143,14 +208,13 @@ func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
}
// HasUserSelectionForRoute returns true if the user has explicitly selected or deselected this route.
-// Intended for exit-node code paths: a v6 exit-node pair (e.g. "MyExit-v6") with no explicit state of
-// its own inherits its v4 base's state, so legacy persisted selections that predate v6 pairing
-// transparently apply to the synthesized v6 entry.
+// The lookup is literal; v4/v6 exit pairs are kept consistent at write time via SyncPairedSelection,
+// so a synthesized "-v6" entry carries the same explicit state as its v4 base.
func (rs *RouteSelector) HasUserSelectionForRoute(routeID route.NetID) bool {
rs.mu.RLock()
defer rs.mu.RUnlock()
- return rs.hasUserSelectionForRouteLocked(rs.effectiveNetID(routeID))
+ return rs.hasUserSelectionForRouteLocked(routeID)
}
func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap {
@@ -179,83 +243,6 @@ func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap
return filtered
}
-// effectiveNetID returns the v4 base for a "-v6" exit pair entry that has no explicit
-// state of its own, so selections made on the v4 entry govern the v6 entry automatically.
-// Only call this from exit-node-specific code paths: applying it to a non-exit "-v6" route
-// would make it inherit unrelated v4 state. Must be called with rs.mu held.
-func (rs *RouteSelector) effectiveNetID(id route.NetID) route.NetID {
- name := string(id)
- if !strings.HasSuffix(name, route.V6ExitSuffix) {
- return id
- }
- if _, ok := rs.selectedRoutes[id]; ok {
- return id
- }
- if _, ok := rs.deselectedRoutes[id]; ok {
- return id
- }
- return route.NetID(strings.TrimSuffix(name, route.V6ExitSuffix))
-}
-
-func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool {
- if rs.deselectAll {
- return false
- }
- _, deselected := rs.deselectedRoutes[routeID]
- return !deselected
-}
-
-func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool {
- if rs.deselectAll {
- return true
- }
- _, deselected := rs.deselectedRoutes[netID]
- return deselected
-}
-
-func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool {
- _, selected := rs.selectedRoutes[routeID]
- _, deselected := rs.deselectedRoutes[routeID]
- return selected || deselected
-}
-
-func isExitNode(rt []*route.Route) bool {
- return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network))
-}
-
-func (rs *RouteSelector) applyExitNodeFilter(
- id route.HAUniqueID,
- netID route.NetID,
- rt []*route.Route,
- out route.HAMap,
-) {
- // Exit-node path: apply the v4/v6 pair mirror so a deselect on the v4 base also
- // drops the synthesized v6 entry that lacks its own explicit state.
- effective := rs.effectiveNetID(netID)
- if rs.hasUserSelectionForRouteLocked(effective) {
- if rs.isSelectedLocked(effective) {
- out[id] = rt
- }
- return
- }
-
- // no explicit selection for this route: defer to management's SkipAutoApply flag
- sel := collectSelected(rt)
- if len(sel) > 0 {
- out[id] = sel
- }
-}
-
-func collectSelected(rt []*route.Route) []*route.Route {
- var sel []*route.Route
- for _, r := range rt {
- if !r.SkipAutoApply {
- sel = append(sel, r)
- }
- }
- return sel
-}
-
// MarshalJSON implements the json.Marshaler interface
func (rs *RouteSelector) MarshalJSON() ([]byte, error) {
rs.mu.RLock()
@@ -309,3 +296,59 @@ func (rs *RouteSelector) UnmarshalJSON(data []byte) error {
return nil
}
+
+func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool {
+ if rs.deselectAll {
+ return false
+ }
+ _, deselected := rs.deselectedRoutes[routeID]
+ return !deselected
+}
+
+func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool {
+ if rs.deselectAll {
+ return true
+ }
+ _, deselected := rs.deselectedRoutes[netID]
+ return deselected
+}
+
+func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool {
+ _, selected := rs.selectedRoutes[routeID]
+ _, deselected := rs.deselectedRoutes[routeID]
+ return selected || deselected
+}
+
+func (rs *RouteSelector) applyExitNodeFilter(
+ id route.HAUniqueID,
+ netID route.NetID,
+ rt []*route.Route,
+ out route.HAMap,
+) {
+ if rs.hasUserSelectionForRouteLocked(netID) {
+ if rs.isSelectedLocked(netID) {
+ out[id] = rt
+ }
+ return
+ }
+
+ // no explicit selection for this route: defer to management's SkipAutoApply flag
+ sel := collectSelected(rt)
+ if len(sel) > 0 {
+ out[id] = sel
+ }
+}
+
+func isExitNode(rt []*route.Route) bool {
+ return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network))
+}
+
+func collectSelected(rt []*route.Route) []*route.Route {
+ var sel []*route.Route
+ for _, r := range rt {
+ if !r.SkipAutoApply {
+ sel = append(sel, r)
+ }
+ }
+ return sel
+}
diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go
index 3f0d9f120..2b1ba3fb9 100644
--- a/client/internal/routeselector/routeselector_test.go
+++ b/client/internal/routeselector/routeselector_test.go
@@ -330,39 +330,73 @@ func TestRouteSelector_FilterSelectedExitNodes(t *testing.T) {
assert.Len(t, filtered, 0) // No routes should be selected
}
-// TestRouteSelector_V6ExitPairInherits covers the v4/v6 exit-node pair selection
-// mirror. The mirror is scoped to exit-node code paths: HasUserSelectionForRoute
-// and FilterSelectedExitNodes resolve a "-v6" entry without explicit state to its
-// v4 base, so legacy persisted selections that predate v6 pairing transparently
-// apply to the synthesized v6 entry. General lookups (IsSelected, FilterSelected)
-// stay literal so unrelated routes named "*-v6" don't inherit unrelated state.
-func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
+// TestRouteSelector_V6ExitPairSync covers SyncPairedSelection, which keeps a v4
+// exit node and its synthesized "-v6" counterpart consistent. The selector itself
+// is literal and never infers a v6 entry's state from its v4 base; callers that know
+// the pairing (exit-node code paths) call SyncPairedSelection to force the v6 entry
+// to follow the base, treating the pair as a single toggle.
+func TestRouteSelector_V6ExitPairSync(t *testing.T) {
all := []route.NetID{"exit1", "exit1-v6", "exit2", "exit2-v6", "corp", "corp-v6"}
- t.Run("HasUserSelectionForRoute mirrors deselected v4 base", func(t *testing.T) {
+ t.Run("selector lookups stay literal without sync", func(t *testing.T) {
rs := routeselector.NewRouteSelector()
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
- assert.True(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 pair sees v4 base's user selection")
+ // The selector does not pair-resolve: the v6 entry is independent until synced.
+ assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 entry has no state of its own")
+ assert.True(t, rs.IsSelected("exit1-v6"), "unsynced v6 entry stays selected by default")
- // unrelated v6 with no v4 base touched is unaffected
- assert.False(t, rs.HasUserSelectionForRoute("exit2-v6"))
+ // A route literally named "exit1-something" must never pair-resolve either.
+ assert.False(t, rs.HasUserSelectionForRoute("exit1-something"))
})
- t.Run("IsSelected stays literal for non-exit lookups", func(t *testing.T) {
- rs := routeselector.NewRouteSelector()
- require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all))
-
- // A non-exit route literally named "corp-v6" must not inherit "corp"'s state
- // via the mirror; the mirror only applies in exit-node code paths.
- assert.False(t, rs.IsSelected("corp"))
- assert.True(t, rs.IsSelected("corp-v6"), "non-exit *-v6 routes must not inherit unrelated v4 state")
- })
-
- t.Run("explicit v6 state overrides v4 base in filter", func(t *testing.T) {
+ t.Run("sync mirrors deselected v4 base onto v6", func(t *testing.T) {
rs := routeselector.NewRouteSelector()
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.False(t, rs.IsSelected("exit1"))
+ assert.False(t, rs.IsSelected("exit1-v6"), "v6 pair follows v4 base deselect")
+ assert.True(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 carries explicit deselect after sync")
+ })
+
+ t.Run("sync mirrors selected v4 base onto v6", func(t *testing.T) {
+ rs := routeselector.NewRouteSelector()
+ require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1"}, false, all))
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.True(t, rs.IsSelected("exit1"))
+ assert.True(t, rs.IsSelected("exit1-v6"), "v6 pair follows v4 base select")
+ })
+
+ t.Run("sync clears v6 state when base has no explicit selection", func(t *testing.T) {
+ rs := routeselector.NewRouteSelector()
require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1-v6"}, true, all))
+ require.True(t, rs.HasUserSelectionForRoute("exit1-v6"))
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"),
+ "v6 explicit state is cleared so it follows management like its base")
+ })
+
+ // Regression for the observed bug (see netbird-engine.log): persisted state has
+ // the v4 base deselected but the v6 sibling explicitly selected (orphaned). The
+ // sync must reset the orphan so the ::/0 route does not leak onto the tunnel.
+ t.Run("sync clears orphaned explicit v6 selection on deselected base", func(t *testing.T) {
+ rs := routeselector.NewRouteSelector()
+
+ // Prior state: both explicitly selected, then only the v4 base deselected,
+ // leaving the v6 entry as a stale explicit selection.
+ require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1", "exit1-v6"}, true, all))
+ require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
+ require.True(t, rs.IsSelected("exit1-v6"), "precondition: orphaned v6 selection")
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.False(t, rs.IsSelected("exit1-v6"), "orphaned v6 selection reset to follow v4 deselect")
v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")}
v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")}
@@ -370,23 +404,14 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
"exit1|0.0.0.0/0": {v4Route},
"exit1-v6|::/0": {v6Route},
}
-
filtered := rs.FilterSelectedExitNodes(routes)
- assert.NotContains(t, filtered, route.HAUniqueID("exit1|0.0.0.0/0"))
- assert.Contains(t, filtered, route.HAUniqueID("exit1-v6|::/0"), "explicit v6 select wins over v4 base")
+ assert.Empty(t, filtered, "deselecting v4 base must drop the v6 pair even if it was explicitly selected before")
})
- t.Run("non-v6-suffix routes unaffected", func(t *testing.T) {
- rs := routeselector.NewRouteSelector()
- require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
-
- // A route literally named "exit1-something" must not pair-resolve.
- assert.False(t, rs.HasUserSelectionForRoute("exit1-something"))
- })
-
- t.Run("filter v6 paired with deselected v4 base", func(t *testing.T) {
+ t.Run("filter drops synced v6 pair of deselected v4 base", func(t *testing.T) {
rs := routeselector.NewRouteSelector()
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
+ rs.SyncPairedSelection("exit1", "exit1-v6")
v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")}
v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")}
@@ -399,6 +424,15 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
assert.Empty(t, filtered, "deselecting v4 base must also drop the v6 pair")
})
+ t.Run("deselectAll makes sync a no-op", func(t *testing.T) {
+ rs := routeselector.NewRouteSelector()
+ rs.DeselectAllRoutes()
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), "sync must not write explicit state under deselectAll")
+ })
+
t.Run("non-exit *-v6 routes pass through FilterSelectedExitNodes", func(t *testing.T) {
rs := routeselector.NewRouteSelector()
require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all))
@@ -825,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/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 2c9e46290..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"
@@ -96,17 +97,19 @@ func (m *Manager) Stop(ctx context.Context) error {
}
m.mu.Lock()
- defer m.mu.Unlock()
+ cancel := m.cancel
+ done := m.done
+ m.mu.Unlock()
- if m.cancel == nil {
+ if cancel == nil {
return nil
}
- m.cancel()
+ cancel()
select {
case <-ctx.Done():
return ctx.Err()
- case <-m.done:
+ case <-done:
}
return nil
@@ -303,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/syncstore/disk.go b/client/internal/syncstore/disk.go
new file mode 100644
index 000000000..eb24e87a7
--- /dev/null
+++ b/client/internal/syncstore/disk.go
@@ -0,0 +1,99 @@
+package syncstore
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/proto"
+
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+ "github.com/netbirdio/netbird/util"
+)
+
+// syncResponseFileName is the name of the file the sync response is serialized
+// to, placed inside the configured directory (the state directory).
+const syncResponseFileName = "networkmap.pb"
+
+// diskStore serializes the latest sync response to a file on disk instead of
+// keeping it in memory. This trades disk I/O for a much smaller memory
+// footprint, which matters on memory-constrained platforms (iOS).
+type diskStore struct {
+ mu sync.Mutex
+ path string
+}
+
+// NewDiskStore returns a Store that serializes the sync response to a file in
+// the given directory. If dir is empty it falls back to the OS temp directory.
+//
+// Any file left over from a previous run is removed on construction so a fresh
+// store never reads stale data (e.g. another profile's network map).
+func NewDiskStore(dir string) Store {
+ if dir == "" {
+ dir = os.TempDir()
+ }
+ s := &diskStore{
+ path: filepath.Join(dir, syncResponseFileName),
+ }
+ if err := s.Clear(); err != nil {
+ log.Warnf("failed to clear stale sync response file: %v", err)
+ }
+ return s
+}
+
+func (s *diskStore) Set(resp *mgmProto.SyncResponse) error {
+ if resp == nil {
+ return s.Clear()
+ }
+
+ bs, err := proto.Marshal(resp)
+ if err != nil {
+ return fmt.Errorf("marshal sync response: %w", err)
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := util.WriteBytesWithRestrictedPermission(context.Background(), s.path, bs); err != nil {
+ return fmt.Errorf("write sync response to %s: %w", s.path, err)
+ }
+
+ log.Debugf("sync response persisted to %s (%d bytes)", s.path, len(bs))
+ return nil
+}
+
+func (s *diskStore) Get() (*mgmProto.SyncResponse, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ bs, err := os.ReadFile(s.path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ //nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
+ return nil, nil
+ }
+ return nil, fmt.Errorf("read sync response from %s: %w", s.path, err)
+ }
+
+ resp := &mgmProto.SyncResponse{}
+ if err := proto.Unmarshal(bs, resp); err != nil {
+ return nil, fmt.Errorf("unmarshal sync response: %w", err)
+ }
+
+ log.Debugf("retrieving latest sync response from %s (%d bytes)", s.path, len(bs))
+ return resp, nil
+}
+
+func (s *diskStore) Clear() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("remove sync response file %s: %w", s.path, err)
+ }
+ return nil
+}
diff --git a/client/internal/syncstore/factory_ios.go b/client/internal/syncstore/factory_ios.go
new file mode 100644
index 000000000..f19ab5e5c
--- /dev/null
+++ b/client/internal/syncstore/factory_ios.go
@@ -0,0 +1,9 @@
+//go:build ios
+
+package syncstore
+
+// New returns the platform default store. On iOS the sync response is
+// serialized to disk (in dir) to keep it out of the constrained process memory.
+func New(dir string) Store {
+ return NewDiskStore(dir)
+}
diff --git a/client/internal/syncstore/factory_other.go b/client/internal/syncstore/factory_other.go
new file mode 100644
index 000000000..79ea46116
--- /dev/null
+++ b/client/internal/syncstore/factory_other.go
@@ -0,0 +1,9 @@
+//go:build !ios
+
+package syncstore
+
+// New returns the platform default store. On all non-iOS platforms the sync
+// response is kept in memory; dir is unused.
+func New(_ string) Store {
+ return NewMemoryStore()
+}
diff --git a/client/internal/syncstore/memory.go b/client/internal/syncstore/memory.go
new file mode 100644
index 000000000..8fc069069
--- /dev/null
+++ b/client/internal/syncstore/memory.go
@@ -0,0 +1,56 @@
+package syncstore
+
+import (
+ "fmt"
+ "sync"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/protobuf/proto"
+
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// memoryStore keeps the latest sync response in memory.
+type memoryStore struct {
+ mu sync.RWMutex
+ latest *mgmProto.SyncResponse
+}
+
+// NewMemoryStore returns a Store that keeps the sync response in memory.
+func NewMemoryStore() Store {
+ return &memoryStore{}
+}
+
+func (s *memoryStore) Set(resp *mgmProto.SyncResponse) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.latest = resp
+ return nil
+}
+
+func (s *memoryStore) Get() (*mgmProto.SyncResponse, error) {
+ s.mu.RLock()
+ latest := s.latest
+ s.mu.RUnlock()
+
+ if latest == nil {
+ //nolint:nilnil // nil,nil means "nothing stored", per the Store contract; preserve the original behaviour
+ return nil, nil
+ }
+
+ log.Debugf("retrieving latest sync response with size %d bytes", proto.Size(latest))
+ sr, ok := proto.Clone(latest).(*mgmProto.SyncResponse)
+ if !ok {
+ return nil, fmt.Errorf("clone sync response")
+ }
+ return sr, nil
+}
+
+func (s *memoryStore) Clear() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.latest = nil
+ return nil
+}
diff --git a/client/internal/syncstore/syncstore.go b/client/internal/syncstore/syncstore.go
new file mode 100644
index 000000000..ba24b9c57
--- /dev/null
+++ b/client/internal/syncstore/syncstore.go
@@ -0,0 +1,29 @@
+// Package syncstore stores the latest Management sync response (which carries
+// the network map) for debug bundle generation.
+//
+// The storage backend is selected at build time per operating system: on iOS
+// the response is serialized to disk to keep it out of the (tightly
+// constrained) process memory, while on all other platforms it is kept in
+// memory. The backend is chosen by the New constructor; see factory_ios.go and
+// factory_other.go.
+package syncstore
+
+import (
+ mgmProto "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// Store persists the latest sync response and returns it on demand.
+//
+// Implementations must be safe for concurrent use.
+type Store interface {
+ // Set stores the given sync response, replacing any previously stored one.
+ Set(resp *mgmProto.SyncResponse) error
+
+ // Get returns the stored sync response, or nil if none is stored.
+ // The returned value is an independent copy that the caller may retain.
+ Get() (*mgmProto.SyncResponse, error)
+
+ // Clear removes any stored sync response. It is safe to call when nothing
+ // is stored.
+ Clear() error
+}
diff --git a/client/internal/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/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/manager.go b/client/internal/updater/manager.go
index dfcb93177..7fc300739 100644
--- a/client/internal/updater/manager.go
+++ b/client/internal/updater/manager.go
@@ -19,8 +19,6 @@ import (
const (
latestVersion = "latest"
- // this version will be ignored
- developmentVersion = "development"
)
var errNoUpdateState = errors.New("no update state found")
@@ -483,7 +481,7 @@ func (m *Manager) loadAndDeleteUpdateState(ctx context.Context) (*UpdateState, e
}
func (m *Manager) shouldUpdate(updateVersion *v.Version, forceUpdate bool) bool {
- if m.currentVersion == developmentVersion {
+ if version.IsDevelopmentVersion(m.currentVersion) {
log.Debugf("skipping auto-update, running development version")
return false
}
diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go
index bafbb0031..2d5460d03 100644
--- a/client/ios/NetBirdSDK/client.go
+++ b/client/ios/NetBirdSDK/client.go
@@ -13,10 +13,10 @@ import (
"time"
log "github.com/sirupsen/logrus"
- "golang.org/x/exp/maps"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
+ "github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/dns"
"github.com/netbirdio/netbird/client/internal/listener"
"github.com/netbirdio/netbird/client/internal/peer"
@@ -25,6 +25,7 @@ import (
"github.com/netbirdio/netbird/formatter"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/domain"
+ types "github.com/netbirdio/netbird/upload-server/types"
)
// ConnectionListener export internal Listener for mobile
@@ -54,6 +55,7 @@ type selectRoute struct {
Network netip.Prefix
Domains domain.List
Selected bool
+ Status string
extraNetworks []netip.Prefix
}
@@ -65,6 +67,8 @@ func init() {
type Client struct {
cfgFile string
stateFile string
+ cacheDir string
+ logFilePath string
recorder *peer.Status
ctxCancel context.CancelFunc
ctxCancelLock *sync.Mutex
@@ -75,16 +79,21 @@ type Client struct {
onHostDnsFn func([]string)
dnsManager dns.IosDnsManager
loginComplete bool
- connectClient *internal.ConnectClient
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
preloadedConfig *profilemanager.Config
+
+ stateMu sync.RWMutex
+ connectClient *internal.ConnectClient
+ config *profilemanager.Config
}
// NewClient instantiate a new Client
-func NewClient(cfgFile, stateFile, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client {
+func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client {
return &Client{
cfgFile: cfgFile,
stateFile: stateFile,
+ cacheDir: cacheDir,
+ logFilePath: logFilePath,
deviceName: deviceName,
osName: osName,
osVersion: osVersion,
@@ -149,20 +158,31 @@ 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
- c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder)
- return c.connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile)
+ connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
+ 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
+ // process memory (see the syncstore package).
+ connectClient.SetSyncResponsePersistence(true)
+ return connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile, c.cacheDir, c.logFilePath)
}
// Stop the internal client and free the resources
@@ -174,6 +194,87 @@ func (c *Client) Stop() {
}
c.ctxCancel()
+ c.setState(nil, nil)
+}
+
+// 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) {
+ cfg, cc := c.stateSnapshot()
+
+ // If the engine hasn't been started, load config so we can reach management.
+ if cfg == nil {
+ if c.preloadedConfig != nil {
+ cfg = c.preloadedConfig
+ } else {
+ var err error
+ // Use DirectUpdateOrCreateConfig to avoid atomic file operations
+ // (temp file + rename) blocked by the tvOS sandbox.
+ cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: c.cfgFile,
+ StateFilePath: c.stateFile,
+ })
+ if err != nil {
+ return "", fmt.Errorf("load config: %w", err)
+ }
+ }
+ }
+
+ deps := debug.GeneratorDependencies{
+ InternalConfig: cfg,
+ StatusRecorder: c.recorder,
+ TempDir: c.cacheDir,
+ StatePath: c.stateFile,
+ LogPath: c.logFilePath,
+ }
+
+ if cc != nil {
+ resp, err := cc.GetLatestSyncResponse()
+ if err != nil {
+ log.Warnf("get latest sync response: %v", err)
+ }
+ 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
+ }
+ }
+ }
+
+ bundleGenerator := debug.NewBundleGenerator(
+ deps,
+ debug.BundleConfig{
+ Anonymize: anonymize,
+ IncludeSystemInfo: true,
+ },
+ )
+
+ path, err := bundleGenerator.Generate()
+ if err != nil {
+ return "", fmt.Errorf("generate debug bundle: %w", err)
+ }
+ defer func() {
+ if err := os.Remove(path); err != nil {
+ log.Errorf("failed to remove debug bundle file: %v", err)
+ }
+ }()
+
+ uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false)
+ if err != nil {
+ return "", fmt.Errorf("upload debug bundle: %w", err)
+ }
+
+ log.Infof("debug bundle uploaded with key %s", key)
+ return key, nil
}
// SetTraceLogLevel configure the logger to trace level
@@ -227,6 +328,16 @@ func (c *Client) RemoveConnectionListener() {
c.recorder.RemoveConnectionListener()
}
+// IsLoginRequiredCached reports whether the LAST observed management error was an
+// auth failure (PermissionDenied/InvalidArgument), using the in-memory status
+// recorder. Unlike IsLoginRequired() it performs NO network call, so it is safe to
+// call from the connection listener during teardown (e.g. onDisconnected) without
+// blocking on a slow or unavailable network. Returns false while connected to
+// management or when the last error was not auth-related.
+func (c *Client) IsLoginRequiredCached() bool {
+ return c.recorder.IsLoginRequired()
+}
+
func (c *Client) IsLoginRequired() bool {
var ctx context.Context
//nolint
@@ -354,11 +465,12 @@ func (c *Client) ClearLoginComplete() {
}
func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) {
- if c.connectClient == nil {
+ _, connectClient := c.stateSnapshot()
+ if connectClient == nil {
return nil, fmt.Errorf("not connected")
}
- engine := c.connectClient.Engine()
+ engine := connectClient.Engine()
if engine == nil {
return nil, fmt.Errorf("not connected")
}
@@ -377,9 +489,57 @@ func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) {
routes := buildSelectRoutes(routesMap, routeSelector.IsSelected, v6ExitMerged)
resolvedDomains := c.recorder.GetResolvedDomainsStates()
+ // Compute each route's connection status in the core (mirroring the Android
+ // bridge), so the UI doesn't have to infer it by string-matching the joined
+ // Network value against peer routes. For a merged exit node the status reflects
+ // whichever of the v4/v6 prefixes is served by a connected peer; for dynamic
+ // (DNS) routes the peer route key is the domain pattern (see dynamic.Route.String).
+ connectedRoutes := c.connectedRouteSet()
+ for _, r := range routes {
+ r.Status = routeStatus(r, connectedRoutes)
+ }
+
return prepareRouteSelectionDetails(routes, resolvedDomains), nil
}
+// connectedRouteSet returns the set of route keys (as strings) currently served by a
+// connected peer, gathered across all connected peers' route tables. The keys match
+// what the route manager records: a prefix string for static routes (e.g. "0.0.0.0/0")
+// and the domain pattern for dynamic routes (e.g. "*.example.com").
+func (c *Client) connectedRouteSet() map[string]struct{} {
+ connected := map[string]struct{}{}
+ for _, p := range c.recorder.GetFullStatus().Peers {
+ if p.ConnStatus != peer.StatusConnected {
+ continue
+ }
+ for r := range p.GetRoutes() {
+ connected[r] = struct{}{}
+ }
+ }
+ return connected
+}
+
+// routeStatus reports "Connected" if any of the route's keys is served by a connected
+// peer: the primary Network prefix, an extra v6 network of a merged exit node, or the
+// domain pattern for a dynamic DNS route. Otherwise "Idle".
+func routeStatus(r *selectRoute, connectedRoutes map[string]struct{}) string {
+ keys := make([]string, 0, 1+len(r.extraNetworks))
+ if len(r.Domains) > 0 {
+ keys = append(keys, r.Domains.SafeString())
+ } else {
+ keys = append(keys, r.Network.String())
+ }
+ for _, extra := range r.extraNetworks {
+ keys = append(keys, extra.String())
+ }
+ for _, k := range keys {
+ if _, ok := connectedRoutes[k]; ok {
+ return peer.StatusConnected.String()
+ }
+ }
+ return peer.StatusIdle.String()
+}
+
func buildSelectRoutes(routesMap map[route.NetID][]*route.Route, isSelected func(route.NetID) bool, v6Merged map[route.NetID]struct{}) []*selectRoute {
var routes []*selectRoute
for id, rt := range routesMap {
@@ -462,6 +622,7 @@ func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[dom
Network: netStr,
Domains: &domainDetails,
Selected: r.Selected,
+ Status: r.Status,
})
}
@@ -470,63 +631,72 @@ func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[dom
}
func (c *Client) SelectRoute(id string) error {
- if c.connectClient == nil {
+ _, connectClient := c.stateSnapshot()
+ if connectClient == nil {
return fmt.Errorf("not connected")
}
- engine := c.connectClient.Engine()
+ engine := connectClient.Engine()
if engine == nil {
return fmt.Errorf("not connected")
}
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 {
- if c.connectClient == nil {
+ _, connectClient := c.stateSnapshot()
+ if connectClient == nil {
return fmt.Errorf("not connected")
}
- engine := c.connectClient.Engine()
+ engine := connectClient.Engine()
if engine == nil {
return fmt.Errorf("not connected")
}
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
}
+// setState stores the running engine state so DebugBundle can reuse the live
+// config and ConnectClient. It is cleared on Stop.
+func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) {
+ c.stateMu.Lock()
+ defer c.stateMu.Unlock()
+ c.config = cfg
+ c.connectClient = cc
+}
+
+// stateSnapshot returns the current config and ConnectClient under the lock.
+func (c *Client) stateSnapshot() (*profilemanager.Config, *internal.ConnectClient) {
+ c.stateMu.RLock()
+ defer c.stateMu.RUnlock()
+ return c.config, c.connectClient
+}
+
func formatDuration(d time.Duration) string {
ds := d.String()
dotIndex := strings.Index(ds, ".")
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 9d447ef3f..6cba0c411 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,16 +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
}
+ // Use a cancellable context so Stop() can abort an in-progress interactive
+ // login. The PKCE flow's WaitToken blocks (and keeps its loopback HTTP server
+ // bound to a port) until the OAuth callback arrives or the flow expires;
+ // cancelling the context unblocks WaitToken, which then shuts that server down
+ // and frees the port for the next login attempt. iOS runs login in the main-app
+ // process (decoupled from the network extension), so without this the server
+ // lingers after the user dismisses the browser and the next connect stalls
+ // trying to bind the same port.
+ ctx, cancel := context.WithCancel(context.Background())
+
return &Auth{
- ctx: context.Background(),
+ ctx: ctx,
+ cancel: cancel,
config: cfg,
cfgPath: cfgPath,
}, nil
@@ -60,12 +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.
@@ -183,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 {
@@ -202,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 != "" {
@@ -216,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 := ""
diff --git a/client/ios/NetBirdSDK/routes.go b/client/ios/NetBirdSDK/routes.go
index 025313bfa..56af2a1ad 100644
--- a/client/ios/NetBirdSDK/routes.go
+++ b/client/ios/NetBirdSDK/routes.go
@@ -20,6 +20,7 @@ type RoutesSelectionInfo struct {
Network string
Domains *DomainDetails
Selected bool
+ Status string
}
type DomainCollection interface {
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
new file mode 100644
index 000000000..29288b511
--- /dev/null
+++ b/client/mdm/canonical_loaders.go
@@ -0,0 +1,53 @@
+//go:build windows || darwin
+
+package mdm
+
+import "strings"
+
+// allKeys is the set of recognised MDM keys. Unknown keys in a managed
+// configuration are ignored but logged. Lives in this build-tagged file
+// (windows || darwin) because only desktop loaders need the
+// canonicalisation table that consumes it; including it unconditionally
+// would trigger the `unused` golangci-lint check on platforms that
+// don't import canonical_loaders.go.
+var allKeys = []string{
+ KeyManagementURL,
+ 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
+// its canonical mdm.Key* form. Admins commonly write PascalCase value
+// names in ADMX / Group Policy ("ManagementURL"); the iOS/AppConfig and
+// macOS plist conventions are camelCase ("managementURL"); both must
+// resolve to the same Policy lookup.
+//
+// Lives in a desktop-loader-only file (build tag `windows || darwin`)
+// because no other build path consumes it. Linux / FreeBSD / mobile
+// builds don't ship a platform loader that reads arbitrary-case key
+// names, so they don't need the canonicalisation table — and including
+// the var unconditionally would trigger the `unused` golangci-lint
+// check on those platforms.
+var canonicalKey = func() map[string]string {
+ m := make(map[string]string, len(allKeys))
+ for _, k := range allKeys {
+ m[strings.ToLower(k)] = k
+ }
+ return m
+}()
diff --git a/client/mdm/policy.go b/client/mdm/policy.go
new file mode 100644
index 000000000..1feff28f8
--- /dev/null
+++ b/client/mdm/policy.go
@@ -0,0 +1,268 @@
+// Package mdm reads MDM-managed configuration from platform-native sources
+// (plist on macOS, registry on Windows, UserDefaults on iOS,
+// RestrictionsManager on Android). The returned Policy is consumed by
+// profilemanager.Config.apply() as the highest-priority override layer.
+//
+// An empty Policy (no source present, or source present with zero keys)
+// means no MDM enforcement is active and the client behaves as if the
+// feature did not exist.
+package mdm
+
+import (
+ "sort"
+ "strconv"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// Well-known policy keys. Names mirror the corresponding ConfigInput Go field
+// names (lowerCamelCase) so the daemon can map a Policy key directly to a
+// configuration field.
+const (
+ 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"
+ // 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
+ // ("allow" or "disallow"); KeySplitTunnelApps is a comma-separated
+ // list of package names. The values are mutually exclusive by
+ // 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).
+const (
+ SplitTunnelModeAllow = "allow"
+ SplitTunnelModeDisallow = "disallow"
+)
+
+// SecretKeys lists keys whose values must be redacted in logs.
+var SecretKeys = map[string]struct{}{
+ KeyPreSharedKey: {},
+}
+
+// boolStringLiterals enumerates the textual boolean encodings the
+// platform loaders may produce (Windows REG_SZ "true", iOS / Android
+// managed-config booleans-as-strings, etc.). Lookup keeps GetBool flat
+// (no nested switch on the string case).
+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 {
+ values map[string]any
+}
+
+// NewPolicy constructs a Policy from a key→value map. Pass nil or an
+// empty map to construct an empty (no-enforcement) Policy. The returned
+// *Policy is always non-nil.
+func NewPolicy(values map[string]any) *Policy {
+ if values == nil {
+ values = map[string]any{}
+ }
+ return &Policy{values: values}
+}
+
+// LoadPolicy reads the platform-native MDM configuration. Returns an
+// empty (but non-nil) Policy when no source is present, the source is
+// empty, or the platform is unsupported.
+//
+// Diagnostic logging differentiates the three states:
+// - source absent / unsupported platform: trace log only
+// - source present, zero keys: info "MDM enrolled (no managed keys)"
+// - source present, N keys: info "MDM enrolled with N managed keys: [...]"
+func LoadPolicy() *Policy {
+ values, err := loadPlatformPolicy()
+ if err != nil {
+ log.Tracef("MDM policy load: %v", err)
+ return &Policy{values: map[string]any{}}
+ }
+ if values == nil {
+ return &Policy{values: map[string]any{}}
+ }
+ if len(values) == 0 {
+ log.Info("MDM enrolled (no managed keys)")
+ } else {
+ log.Infof("MDM enrolled with %d managed key(s): %v", len(values), sortedKeys(values))
+ }
+ return &Policy{values: values}
+}
+
+// IsEmpty reports whether the Policy has no managed keys.
+func (p *Policy) IsEmpty() bool {
+ return p == nil || len(p.values) == 0
+}
+
+// HasKey reports whether the given key is MDM-managed.
+func (p *Policy) HasKey(key string) bool {
+ if p == nil {
+ return false
+ }
+ _, ok := p.values[key]
+ return ok
+}
+
+// ManagedKeys returns the sorted list of managed key names. Returns an empty
+// slice (not nil) on an empty Policy.
+func (p *Policy) ManagedKeys() []string {
+ if p == nil {
+ return []string{}
+ }
+ return sortedKeys(p.values)
+}
+
+// GetString returns the managed value for key coerced to string, and whether
+// the key was set. A non-string value returns ("", false).
+func (p *Policy) GetString(key string) (string, bool) {
+ if p == nil {
+ return "", false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return "", false
+ }
+ s, ok := v.(string)
+ if !ok || s == "" {
+ return "", false
+ }
+ return s, true
+}
+
+// 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,
+// 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
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return false, false
+ }
+ switch t := v.(type) {
+ case bool:
+ return t, true
+ case string:
+ b, known := boolStringLiterals[strings.ToLower(strings.TrimSpace(t))]
+ return b, known
+ case int:
+ return t != 0, true
+ case int64:
+ return t != 0, true
+ }
+ return false, false
+}
+
+// GetInt returns the managed value for key as int64, and whether the key
+// was set. Accepts native int / int64 (as produced by the Windows registry
+// loader for REG_DWORD/REG_QWORD) and numeric strings (decimal).
+func (p *Policy) GetInt(key string) (int64, bool) {
+ if p == nil {
+ return 0, false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return 0, false
+ }
+ switch t := v.(type) {
+ case int64:
+ return t, true
+ case int:
+ return int64(t), true
+ case int32:
+ return int64(t), true
+ case uint64:
+ return int64(t), true
+ case float64:
+ return int64(t), true
+ case string:
+ if n, err := strconv.ParseInt(t, 10, 64); err == nil {
+ return n, true
+ }
+ }
+ return 0, false
+}
+
+// GetStringSlice returns the managed value for key as []string, and whether
+// the key was set. Accepts []string, []any (of strings), and a single string
+// (treated as a one-element list).
+func (p *Policy) GetStringSlice(key string) ([]string, bool) {
+ if p == nil {
+ return nil, false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return nil, false
+ }
+ switch t := v.(type) {
+ case []string:
+ return append([]string(nil), t...), true
+ case []any:
+ out := make([]string, 0, len(t))
+ for _, item := range t {
+ s, ok := item.(string)
+ if !ok {
+ return nil, false
+ }
+ out = append(out, s)
+ }
+ return out, true
+ case string:
+ return []string{t}, true
+ }
+ return nil, false
+}
+
+// sortedKeys returns the keys of m as a deterministic, lexicographically
+// sorted slice. Used internally by Policy.ManagedKeys and LoadPolicy's
+// diagnostic log line so callers see a stable key order across runs
+// regardless of Go's randomised map iteration.
+func sortedKeys(m map[string]any) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
diff --git a/client/mdm/policy_darwin.go b/client/mdm/policy_darwin.go
new file mode 100644
index 000000000..57aa1168c
--- /dev/null
+++ b/client/mdm/policy_darwin.go
@@ -0,0 +1,90 @@
+//go:build darwin && !ios
+
+package mdm
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+ "howett.net/plist"
+)
+
+// policyPlistPath is the well-known location where macOS writes the
+// device-level mandatory MDM payload for NetBird. The path is fixed by
+// Apple convention: when an MDM provider (Jamf / Kandji / Mosyle /
+// Intune for Mac / Workspace ONE) pushes a Configuration Profile that
+// contains a com.apple.ManagedClient.preferences payload targeting the
+// bundle id io.netbird.client, the OS materializes the payload here.
+//
+// Read-only — only the OS (root) is supposed to write this file. The
+// loader sanity-checks the file mode and refuses to honour a world-
+// writable plist, as a defense against tampered installs.
+const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist"
+
+// loadPlatformPolicy reads the MDM-managed configuration from the macOS
+// managed-preferences plist at policyPlistPath. Returns:
+// - (nil, nil) when the plist is absent (device not MDM-enrolled for
+// NetBird, or admin has not yet pushed a payload)
+// - (map, nil) with N entries when N managed values are present
+// (N may be 0 — empty plist still signals enrollment to the caller)
+// - (nil, err) on permission / parse / safety errors (including
+// refusal to read a world-writable plist)
+//
+// Top-level plist keys are canonicalised case-insensitively to the
+// package's internal mdm.Key* names; unknown keys are logged and
+// skipped so a stray entry in the payload does not block startup.
+// Native plist value types map naturally onto the Policy accessor
+// expectations (GetString / GetBool / GetInt / GetStringSlice).
+func loadPlatformPolicy() (map[string]any, error) {
+ f, err := os.Open(policyPlistPath)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ // Not enrolled for NetBird. Caller treats nil as
+ // "no MDM source present".
+ //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
+ return nil, nil
+ }
+ return nil, fmt.Errorf("open %s: %w", policyPlistPath, err)
+ }
+ defer func() {
+ if closeErr := f.Close(); closeErr != nil {
+ log.Warnf("MDM close plist %s: %v", policyPlistPath, closeErr)
+ }
+ }()
+
+ info, err := f.Stat()
+ if err != nil {
+ return nil, fmt.Errorf("stat %s: %w", policyPlistPath, err)
+ }
+ // World-writable plist => tampered install. Refuse rather than
+ // honour potentially attacker-controlled policy values.
+ if info.Mode().Perm()&0o002 != 0 {
+ return nil, fmt.Errorf("refusing to read world-writable MDM source %s (mode %o)",
+ policyPlistPath, info.Mode().Perm())
+ }
+
+ raw := make(map[string]any)
+ if err := plist.NewDecoder(f).Decode(&raw); err != nil {
+ return nil, fmt.Errorf("decode plist %s: %w", policyPlistPath, err)
+ }
+
+ out := make(map[string]any, len(raw))
+ for name, val := range raw {
+ // macOS / AppConfig conventions both use camelCase for managed
+ // preferences keys; canonicalize to the mdm.Key* form so a key
+ // written as "ManagementURL" (PascalCase, rare on macOS but
+ // possible if the admin reused an ADMX-style name) still
+ // resolves.
+ canonical, known := canonicalKey[strings.ToLower(name)]
+ if !known {
+ log.Warnf("MDM ignoring unknown plist key %s: %s", policyPlistPath, name)
+ continue
+ }
+ out[canonical] = val
+ }
+ return out, nil
+}
diff --git a/client/mdm/policy_mobile.go b/client/mdm/policy_mobile.go
new file mode 100644
index 000000000..ec25d4bb1
--- /dev/null
+++ b/client/mdm/policy_mobile.go
@@ -0,0 +1,14 @@
+//go:build ios || android
+
+package mdm
+
+// loadPlatformPolicy is unused on mobile: the native layer (Swift on iOS,
+// Kotlin/Java on Android) reads the OS managed-config store and pushes the
+// resulting dictionary in-process via a gomobile entry point that lands in
+// Phase 5 / Phase 6. The stub keeps the package compilable for mobile
+// builds and returns (nil, nil) — the platform-absent sentinel that
+// LoadPolicy in policy.go treats as "no MDM source present".
+func loadPlatformPolicy() (map[string]any, error) {
+ //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
+ return nil, nil
+}
diff --git a/client/mdm/policy_other.go b/client/mdm/policy_other.go
new file mode 100644
index 000000000..f4263afa2
--- /dev/null
+++ b/client/mdm/policy_other.go
@@ -0,0 +1,14 @@
+//go:build !windows && !darwin && !ios && !android
+
+package mdm
+
+// loadPlatformPolicy returns no policy on platforms without an MDM channel
+// (Linux, FreeBSD). MDM enforcement is off and the client behaves as if
+// the feature did not exist. Returns (nil, nil) — the platform-absent
+// sentinel the caller (LoadPolicy in policy.go) treats as "no MDM
+// source present"; an error here would just translate to the same
+// outcome with an extra log line.
+func loadPlatformPolicy() (map[string]any, error) {
+ //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
+ return nil, nil
+}
diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go
new file mode 100644
index 000000000..6cbe69776
--- /dev/null
+++ b/client/mdm/policy_test.go
@@ -0,0 +1,165 @@
+package mdm
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPolicy_NilSafe(t *testing.T) {
+ var p *Policy
+ assert.True(t, p.IsEmpty())
+ assert.False(t, p.HasKey(KeyManagementURL))
+ assert.Empty(t, p.ManagedKeys())
+
+ _, ok := p.GetString(KeyManagementURL)
+ assert.False(t, ok)
+ _, ok = p.GetBool(KeyDisableProfiles)
+ assert.False(t, ok)
+ _, ok = p.GetStringSlice(KeySplitTunnelApps)
+ assert.False(t, ok)
+}
+
+func TestPolicy_Empty(t *testing.T) {
+ p := NewPolicy(nil)
+ require.NotNil(t, p)
+ assert.True(t, p.IsEmpty())
+ assert.False(t, p.HasKey(KeyManagementURL))
+ assert.Empty(t, p.ManagedKeys())
+}
+
+func TestPolicy_HasKey(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeyManagementURL: "https://corp.example.com",
+ KeyDisableProfiles: true,
+ })
+ assert.False(t, p.IsEmpty())
+ assert.True(t, p.HasKey(KeyManagementURL))
+ assert.True(t, p.HasKey(KeyDisableProfiles))
+ assert.False(t, p.HasKey(KeyPreSharedKey))
+}
+
+func TestPolicy_ManagedKeysSorted(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeyDisableProfiles: true,
+ KeyManagementURL: "https://x",
+ KeyAllowServerSSH: false,
+ })
+ got := p.ManagedKeys()
+ assert.Equal(t, []string{KeyAllowServerSSH, KeyDisableProfiles, KeyManagementURL}, got)
+}
+
+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
+ })
+ v, ok := p.GetString(KeyManagementURL)
+ assert.True(t, ok)
+ assert.Equal(t, "https://corp.example.com", v)
+
+ _, ok = p.GetString(KeyDisableProfiles)
+ assert.False(t, ok, "non-string value must not be reported as string")
+
+ _, ok = p.GetString(KeyPreSharedKey)
+ assert.False(t, ok, "empty string treated as unset")
+
+ _, ok = p.GetString("nonexistent")
+ assert.False(t, ok)
+}
+
+func TestPolicy_GetBool(t *testing.T) {
+ cases := []struct {
+ name string
+ raw any
+ want bool
+ ok bool
+ }{
+ {"native true", true, true, true},
+ {"native false", false, false, true},
+ {"string true", "true", true, true},
+ {"string false", "false", false, true},
+ {"string 1", "1", true, true},
+ {"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},
+ {"int64 zero", int64(0), false, true},
+ {"string garbage", "maybe", false, false},
+ {"float unsupported", 1.0, false, false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ p := NewPolicy(map[string]any{KeyDisableProfiles: c.raw})
+ got, ok := p.GetBool(KeyDisableProfiles)
+ assert.Equal(t, c.ok, ok)
+ if c.ok {
+ assert.Equal(t, c.want, got)
+ }
+ })
+ }
+
+ _, ok := NewPolicy(nil).GetBool(KeyDisableProfiles)
+ assert.False(t, ok)
+}
+
+func TestPolicy_GetStringSlice(t *testing.T) {
+ t.Run("native string slice", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: []string{"com.a", "com.b"},
+ })
+ got, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.True(t, ok)
+ assert.Equal(t, []string{"com.a", "com.b"}, got)
+ })
+
+ t.Run("any slice of strings", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: []any{"com.a", "com.b"},
+ })
+ got, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.True(t, ok)
+ assert.Equal(t, []string{"com.a", "com.b"}, got)
+ })
+
+ t.Run("single string lifts to one-element slice", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: "com.a",
+ })
+ got, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.True(t, ok)
+ assert.Equal(t, []string{"com.a"}, got)
+ })
+
+ t.Run("mixed any slice rejected", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: []any{"com.a", 1},
+ })
+ _, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.False(t, ok)
+ })
+
+ t.Run("missing key", func(t *testing.T) {
+ p := NewPolicy(nil)
+ _, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.False(t, ok)
+ })
+}
+
+func TestLoadPolicy_PlatformStubReturnsEmpty(t *testing.T) {
+ // loadPlatformPolicy is a stub on every OS for Phase 1. LoadPolicy must
+ // degrade gracefully and never return nil.
+ p := LoadPolicy()
+ require.NotNil(t, p)
+ assert.True(t, p.IsEmpty())
+ assert.Empty(t, p.ManagedKeys())
+}
diff --git a/client/mdm/policy_windows.go b/client/mdm/policy_windows.go
new file mode 100644
index 000000000..0c2629f98
--- /dev/null
+++ b/client/mdm/policy_windows.go
@@ -0,0 +1,108 @@
+//go:build windows
+
+package mdm
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/sys/windows/registry"
+)
+
+// policyRegistryPath is the well-known MDM policy registry key for NetBird.
+// Admins push values here through Group Policy, Intune ADMX ingestion, an
+// Intune custom Registry CSP profile, or `reg add` during MSI deployment.
+// Listed in the project's docs/mdm/netbird.admx schema.
+const policyRegistryPath = `Software\Policies\NetBird`
+
+// readRegistryValue reads a single value under policyRegistryPath and,
+// on success, stores the type-coerced result in out[canonical]. Type
+// coercion mirrors loadPlatformPolicy's documented mapping:
+// - REG_SZ / REG_EXPAND_SZ -> string (REG_EXPAND_SZ is expanded by the API)
+// - REG_DWORD / REG_QWORD -> int64
+// - REG_MULTI_SZ -> []string
+//
+// Unsupported value types and per-value read failures are logged at
+// warn level and skipped — one malformed value must not block the
+// surrounding loop. Extracted from loadPlatformPolicy to keep that
+// function's cognitive complexity in check.
+func readRegistryValue(k registry.Key, name, canonical string, out map[string]any) {
+ _, valType, err := k.GetValue(name, nil)
+ if err != nil {
+ log.Warnf("MDM stat %s\\%s: %v", policyRegistryPath, name, err)
+ return
+ }
+ switch valType {
+ case registry.SZ, registry.EXPAND_SZ:
+ if v, _, err := k.GetStringValue(name); err == nil {
+ out[canonical] = v
+ } else {
+ log.Warnf("MDM read string %s\\%s: %v", policyRegistryPath, name, err)
+ }
+ case registry.DWORD, registry.QWORD:
+ if v, _, err := k.GetIntegerValue(name); err == nil {
+ // uint64 from the registry API; Policy.GetBool / GetInt
+ // helpers consume int64, so narrow safely.
+ out[canonical] = int64(v)
+ } else {
+ log.Warnf("MDM read int %s\\%s: %v", policyRegistryPath, name, err)
+ }
+ case registry.MULTI_SZ:
+ if v, _, err := k.GetStringsValue(name); err == nil {
+ out[canonical] = v
+ } else {
+ log.Warnf("MDM read multi-string %s\\%s: %v", policyRegistryPath, name, err)
+ }
+ default:
+ log.Warnf("MDM ignoring unsupported registry value type %d at %s\\%s",
+ valType, policyRegistryPath, name)
+ }
+}
+
+// loadPlatformPolicy reads the MDM-managed configuration from the
+// Windows registry under HKLM\Software\Policies\NetBird. Returns:
+// - (nil, nil) when the key is absent (device not MDM-enrolled for NetBird)
+// - (map, nil) with N entries when N managed values are set (N may be 0)
+// - (nil, err) on open / enumerate registry errors
+//
+// Per-value type coercion + skip-on-error is delegated to
+// readRegistryValue. Unknown value names are logged and skipped so a
+// malformed deployment does not block startup.
+func loadPlatformPolicy() (map[string]any, error) {
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, policyRegistryPath, registry.QUERY_VALUE)
+ if err != nil {
+ if errors.Is(err, registry.ErrNotExist) {
+ // Not enrolled. Caller treats nil as "no MDM source present".
+ //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
+ return nil, nil
+ }
+ return nil, fmt.Errorf("open %s: %w", policyRegistryPath, err)
+ }
+ defer func() {
+ if closeErr := k.Close(); closeErr != nil {
+ log.Warnf("MDM close registry key %s: %v", policyRegistryPath, closeErr)
+ }
+ }()
+
+ names, err := k.ReadValueNames(-1)
+ if err != nil {
+ return nil, fmt.Errorf("enumerate values of %s: %w", policyRegistryPath, err)
+ }
+
+ out := make(map[string]any, len(names))
+ for _, name := range names {
+ // Canonicalize the registry value name against the known MDM key
+ // set so Policy.HasKey lookups (which use the canonical names)
+ // succeed regardless of the casing used by the admin's ADMX or
+ // `reg add` command.
+ canonical, known := canonicalKey[strings.ToLower(name)]
+ if !known {
+ log.Warnf("MDM ignoring unknown registry value %s\\%s", policyRegistryPath, name)
+ continue
+ }
+ readRegistryValue(k, name, canonical, out)
+ }
+ return out, nil
+}
diff --git a/client/mdm/ticker.go b/client/mdm/ticker.go
new file mode 100644
index 000000000..abd6ae233
--- /dev/null
+++ b/client/mdm/ticker.go
@@ -0,0 +1,129 @@
+package mdm
+
+import (
+ "context"
+ "reflect"
+ "sort"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// DefaultReloadInterval is the production cadence at which the desktop daemon
+// re-reads the OS-native MDM policy. Picked to balance responsiveness against
+// registry/plist I/O overhead. Mobile builds use OS-side notifications
+// instead, hence anticipating the ticker mechanism entirely.
+const DefaultReloadInterval = 1 * time.Minute
+
+// policyLoader is the indirection through which the ticker reads the
+// OS-native policy, both for the initial observation and on every tick.
+// Production points it at LoadPolicy; tests in this package override it to
+// feed a scripted sequence of policies without touching the real OS store.
+var policyLoader = LoadPolicy
+
+// Ticker periodically re-reads the OS-native MDM policy via LoadPolicy and
+// invokes the onChange callback (supplied to Run) whenever the observed
+// Policy diverges from the last observation (added / removed / changed
+// keys). Launch with Run from a goroutine; cancel the supplied context
+// to stop.
+type Ticker struct {
+ interval time.Duration
+ prev *Policy
+}
+
+// NewTicker constructs a Ticker that will re-read the OS-native policy
+// every reloadInterval once Run is called.
+// The initial snapshot is populated by calling policyLoader at
+// construction time so the first tick only fires
+// onChange when the policy actually changed since boot — without
+// this baseline the first tick would report every currently-managed
+// key as "added" and trigger a spurious engine restart.
+func NewTicker(reloadInterval time.Duration) *Ticker {
+ return &Ticker{
+ interval: reloadInterval,
+ prev: policyLoader(),
+ }
+}
+
+// Run blocks until ctx is cancelled, polling the OS-native policy store at
+// the configured cadence and emitting log lines + onChange callback on
+// every observed diff. onChange must be non-nil.
+func (t *Ticker) Run(ctx context.Context, onChange func(prev, curr *Policy) error) {
+ tk := time.NewTicker(t.interval)
+ defer tk.Stop()
+ log.Infof("MDM policy reload ticker started (interval=%s)", t.interval)
+ for {
+ select {
+ case <-ctx.Done():
+ log.Info("MDM policy reload ticker stopped")
+ return
+ case <-tk.C:
+ curr := policyLoader()
+ if policiesEqual(t.prev, curr) {
+ continue
+ }
+ added, removed, changed := diffPolicies(t.prev, curr)
+ log.Infof("MDM policy changed: added=%v removed=%v changed=%v",
+ added, removed, changed)
+ prev := t.prev
+ if err := onChange(prev, curr); err != nil {
+ log.Errorf("MDM policy change handler failed (retrying in 1 minute): %v", err)
+ continue
+ }
+ t.prev = curr
+ }
+ }
+}
+
+// policiesEqual reports whether two Policy instances carry the same
+// managed key set with identical values. Nil and empty policies
+// compare equal; one-nil/one-non-empty compare not equal; otherwise
+// the underlying values maps are compared with reflect.DeepEqual.
+func policiesEqual(a, b *Policy) bool {
+ if a.IsEmpty() && b.IsEmpty() {
+ return true
+ }
+ if a == nil || b == nil {
+ return false
+ }
+ return reflect.DeepEqual(a.values, b.values)
+}
+
+// diffPolicies returns the keys added in curr, removed from prev, and
+// whose values changed between prev and curr. Each slice is sorted
+// lexicographically for stable log output; value differences are
+// determined with reflect.DeepEqual.
+func diffPolicies(prev, curr *Policy) (added, removed, changed []string) {
+ prevKVs := mapOf(prev)
+ currKVs := mapOf(curr)
+ for k := range currKVs {
+ if _, ok := prevKVs[k]; !ok {
+ added = append(added, k)
+ } else if !reflect.DeepEqual(prevKVs[k], currKVs[k]) {
+ changed = append(changed, k)
+ }
+ }
+ for k := range prevKVs {
+ if _, ok := currKVs[k]; !ok {
+ removed = append(removed, k)
+ }
+ }
+ sort.Strings(added)
+ sort.Strings(removed)
+ sort.Strings(changed)
+ return added, removed, changed
+}
+
+// mapOf returns a (possibly empty, never nil) copy of the underlying
+// values map of a Policy so callers outside this package can compare
+// keys/values across the type boundary. Returns an empty map on nil p.
+func mapOf(p *Policy) map[string]any {
+ if p == nil {
+ return map[string]any{}
+ }
+ out := make(map[string]any, len(p.values))
+ for k, v := range p.values {
+ out[k] = v
+ }
+ return out
+}
diff --git a/client/mdm/ticker_test.go b/client/mdm/ticker_test.go
new file mode 100644
index 000000000..17f3cfc2f
--- /dev/null
+++ b/client/mdm/ticker_test.go
@@ -0,0 +1,100 @@
+package mdm
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// testReloadInterval for speeding up the ticker cadence under `go test`
+const testReloadInterval = 1 * time.Second
+
+// withPolicyLoader overrides the package-level policyLoader for the duration
+// of the test so the ticker observes a scripted policy instead of the real
+// OS-native store. The original loader is restored on cleanup.
+func withPolicyLoader(t *testing.T, fn func() *Policy) {
+ t.Helper()
+ prev := policyLoader
+ policyLoader = fn
+ t.Cleanup(func() { policyLoader = prev })
+}
+
+func TestTicker_FiresOnChangeWithDelta(t *testing.T) {
+ var mu sync.Mutex
+ current := NewPolicy(nil) // initial observation: empty (no enforcement)
+ withPolicyLoader(t, func() *Policy {
+ mu.Lock()
+ defer mu.Unlock()
+ return current
+ })
+
+ type change struct{ prev, curr *Policy }
+ changes := make(chan change, 1)
+ tk := NewTicker(testReloadInterval)
+ require.Equal(t, testReloadInterval, tk.interval)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ tk.Run(ctx, func(prev, curr *Policy) error {
+ select {
+ case changes <- change{prev, curr}:
+ default:
+ }
+ return nil
+ })
+ close(done)
+ }()
+ // Stop Run and wait for it to exit before returning, so the policyLoader
+ // restore in t.Cleanup can't race the ticker goroutine still reading it.
+ defer func() { cancel(); <-done }()
+
+ // Flip the OS-observed policy from empty to one managed key. The next
+ // tick must detect the diff and invoke onChange.
+ mu.Lock()
+ current = NewPolicy(map[string]any{KeyManagementURL: "https://mdm.example.com:443"})
+ mu.Unlock()
+
+ select {
+ case c := <-changes:
+ assert.True(t, c.prev.IsEmpty(), "prev should be the initial empty policy")
+ assert.True(t, c.curr.HasKey(KeyManagementURL), "curr should carry the newly-pushed managed key")
+ case <-time.After(5 * time.Second):
+ t.Fatal("onChange not invoked within 5s; ticker should fire every 1s under test")
+ }
+}
+
+func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) {
+ withPolicyLoader(t, func() *Policy {
+ return NewPolicy(map[string]any{KeyBlockInbound: true})
+ })
+
+ fired := make(chan struct{}, 1)
+ tk := NewTicker(testReloadInterval)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ tk.Run(ctx, func(_, _ *Policy) error {
+ select {
+ case fired <- struct{}{}:
+ default:
+ }
+ return nil
+ })
+ close(done)
+ }()
+ defer func() { cancel(); <-done }()
+
+ // Over ~2 ticks at the 1s test cadence the policy never changes, so the
+ // diff guard must suppress the callback entirely.
+ select {
+ case <-fired:
+ t.Fatal("onChange fired despite an unchanged policy")
+ case <-time.After(2500 * time.Millisecond):
+ }
+}
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/proto/daemon.pb.go b/client/proto/daemon.pb.go
index 2c054c99a..d4deeb8ec 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
@@ -1191,8 +1215,14 @@ type GetConfigResponse struct {
DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"`
SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"`
DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // mDMManagedFields lists the names of configuration keys whose value is
+ // currently enforced by an MDM policy. Names match mdm.Key* constants
+ // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should
+ // render the corresponding inputs as read-only and display a "managed
+ // by MDM" indicator.
+ MDMManagedFields []string `protobuf:"bytes,28,rep,name=mDMManagedFields,proto3" json:"mDMManagedFields,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *GetConfigResponse) Reset() {
@@ -1414,6 +1444,13 @@ func (x *GetConfigResponse) GetDisableIpv6() bool {
return false
}
+func (x *GetConfigResponse) GetMDMManagedFields() []string {
+ if x != nil {
+ return x.MDMManagedFields
+ }
+ return nil
+}
+
// PeerState contains the latest state of a peer
type PeerState struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -1614,6 +1651,7 @@ type LocalPeerState struct {
RosenpassPermissive bool `protobuf:"varint,6,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"`
Networks []string `protobuf:"bytes,7,rep,name=networks,proto3" json:"networks,omitempty"`
Ipv6 string `protobuf:"bytes,8,opt,name=ipv6,proto3" json:"ipv6,omitempty"`
+ WgPort int32 `protobuf:"varint,9,opt,name=wgPort,proto3" json:"wgPort,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1704,6 +1742,13 @@ func (x *LocalPeerState) GetIpv6() string {
return ""
}
+func (x *LocalPeerState) GetWgPort() int32 {
+ if x != nil {
+ return x.WgPort
+ }
+ return 0
+}
+
// SignalState contains the latest state of a signal connection
type SignalState struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -1828,10 +1873,13 @@ func (x *ManagementState) GetError() string {
// RelayState contains the latest state of the relay
type RelayState struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- URI string `protobuf:"bytes,1,opt,name=URI,proto3" json:"URI,omitempty"`
- Available bool `protobuf:"varint,2,opt,name=available,proto3" json:"available,omitempty"`
- Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ URI string `protobuf:"bytes,1,opt,name=URI,proto3" json:"URI,omitempty"`
+ Available bool `protobuf:"varint,2,opt,name=available,proto3" json:"available,omitempty"`
+ Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"`
+ // transport is the negotiated relay transport (e.g. "ws", "quic"),
+ // empty for stun/turn probes or when not connected.
+ Transport string `protobuf:"bytes,4,opt,name=transport,proto3" json:"transport,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1887,6 +1935,13 @@ func (x *RelayState) GetError() string {
return ""
}
+func (x *RelayState) GetTransport() string {
+ if x != nil {
+ return x.Transport
+ }
+ return ""
+}
+
type NSGroupState struct {
state protoimpl.MessageState `protogen:"open.v1"`
Servers []string `protobuf:"bytes,1,rep,name=servers,proto3" json:"servers,omitempty"`
@@ -2098,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() {
@@ -2202,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"`
@@ -2704,13 +2771,18 @@ 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"`
- 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"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *DebugBundleRequest) Reset() {
@@ -2771,6 +2843,20 @@ func (x *DebugBundleRequest) GetLogFileCount() uint32 {
return 0
}
+func (x *DebugBundleRequest) GetCliVersion() string {
+ if x != nil {
+ return x.CliVersion
+ }
+ return ""
+}
+
+func (x *DebugBundleRequest) GetUploadInsecure() bool {
+ if x != nil {
+ return x.UploadInsecure
+ }
+ return false
+}
+
type DebugBundleResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
@@ -2991,6 +3077,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"`
@@ -3001,7 +3167,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)
}
@@ -3013,7 +3179,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 {
@@ -3026,7 +3192,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 {
@@ -3045,7 +3211,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)
}
@@ -3057,7 +3223,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 {
@@ -3070,7 +3236,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
@@ -3083,7 +3249,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)
}
@@ -3095,7 +3261,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 {
@@ -3108,7 +3274,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 {
@@ -3129,7 +3295,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)
}
@@ -3141,7 +3307,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 {
@@ -3154,7 +3320,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 {
@@ -3181,7 +3347,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)
}
@@ -3193,7 +3359,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 {
@@ -3206,7 +3372,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 {
@@ -3227,7 +3393,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)
}
@@ -3239,7 +3405,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 {
@@ -3252,7 +3418,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 {
@@ -3279,7 +3445,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)
}
@@ -3291,7 +3457,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 {
@@ -3304,7 +3470,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 {
@@ -3323,7 +3489,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)
}
@@ -3335,7 +3501,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 {
@@ -3348,7 +3514,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 {
@@ -3366,7 +3532,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)
}
@@ -3378,7 +3544,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 {
@@ -3391,7 +3557,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 {
@@ -3408,7 +3574,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)
}
@@ -3420,7 +3586,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 {
@@ -3433,7 +3599,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 {
@@ -3495,7 +3661,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)
}
@@ -3507,7 +3673,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 {
@@ -3520,7 +3686,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 {
@@ -3598,7 +3764,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)
}
@@ -3610,7 +3776,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 {
@@ -3623,7 +3789,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 {
@@ -3664,7 +3830,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)
}
@@ -3676,7 +3842,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 {
@@ -3689,7 +3855,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 {
@@ -3714,7 +3880,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)
}
@@ -3726,7 +3892,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 {
@@ -3739,7 +3905,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 {
@@ -3757,7 +3923,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)
}
@@ -3769,7 +3935,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 {
@@ -3782,7 +3948,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 {
@@ -3842,7 +4008,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)
}
@@ -3854,7 +4020,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 {
@@ -3867,7 +4033,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 {
@@ -3879,7 +4045,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)
}
@@ -3891,7 +4057,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 {
@@ -3904,7 +4070,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 {
@@ -3915,16 +4081,18 @@ func (x *GetEventsResponse) GetEvents() []*SystemEvent {
}
type SwitchProfileRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
- Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // profileName is treated as a handle: exact ID, unique ID prefix, or
+ // unique display name. The daemon resolves it server-side.
+ ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
+ Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
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)
}
@@ -3936,7 +4104,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 {
@@ -3949,7 +4117,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 {
@@ -3967,14 +4135,18 @@ func (x *SwitchProfileRequest) GetUsername() string {
}
type SwitchProfileResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // id is the resolved on-disk ID of the profile that became active.
+ // Lets CLI clients update their local active-profile state without
+ // duplicating the resolution logic.
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
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)
}
@@ -3986,7 +4158,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 {
@@ -3999,7 +4171,14 @@ 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 {
+ if x != nil {
+ return x.Id
+ }
+ return ""
}
type SetConfigRequest struct {
@@ -4048,7 +4227,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)
}
@@ -4060,7 +4239,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 {
@@ -4073,7 +4252,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 {
@@ -4329,7 +4508,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)
}
@@ -4341,7 +4520,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 {
@@ -4354,20 +4533,22 @@ 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 {
- state protoimpl.MessageState `protogen:"open.v1"`
- Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
- ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
+ // profileName carries the human-readable display name for the new
+ // profile. The on-disk filename is a separately-generated ID.
+ ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
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)
}
@@ -4379,7 +4560,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 {
@@ -4392,7 +4573,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 {
@@ -4410,14 +4591,17 @@ func (x *AddProfileRequest) GetProfileName() string {
}
type AddProfileResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // id is the generated on-disk ID of the new profile. CLI clients
+ // display a truncated form, UI clients can ignore it.
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
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)
}
@@ -4429,7 +4613,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 {
@@ -4442,20 +4626,136 @@ 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 {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+type RenameProfileRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
+ // handle: an exact ID, a unique ID prefix, or a unique display name.
+ Handle string `protobuf:"bytes,2,opt,name=handle,proto3" json:"handle,omitempty"`
+ // newProfileName is the new human-readable display name for the profile.
+ NewProfileName string `protobuf:"bytes,3,opt,name=newProfileName,proto3" json:"newProfileName,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RenameProfileRequest) Reset() {
+ *x = RenameProfileRequest{}
+ mi := &file_daemon_proto_msgTypes[62]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RenameProfileRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RenameProfileRequest) ProtoMessage() {}
+
+func (x *RenameProfileRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[62]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RenameProfileRequest.ProtoReflect.Descriptor instead.
+func (*RenameProfileRequest) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{62}
+}
+
+func (x *RenameProfileRequest) GetUsername() string {
+ if x != nil {
+ return x.Username
+ }
+ return ""
+}
+
+func (x *RenameProfileRequest) GetHandle() string {
+ if x != nil {
+ return x.Handle
+ }
+ return ""
+}
+
+func (x *RenameProfileRequest) GetNewProfileName() string {
+ if x != nil {
+ return x.NewProfileName
+ }
+ return ""
+}
+
+type RenameProfileResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // confirm the old profile name after resolving handle.
+ OldProfileName string `protobuf:"bytes,1,opt,name=oldProfileName,proto3" json:"oldProfileName,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RenameProfileResponse) Reset() {
+ *x = RenameProfileResponse{}
+ mi := &file_daemon_proto_msgTypes[63]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RenameProfileResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RenameProfileResponse) ProtoMessage() {}
+
+func (x *RenameProfileResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[63]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RenameProfileResponse.ProtoReflect.Descriptor instead.
+func (*RenameProfileResponse) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{63}
+}
+
+func (x *RenameProfileResponse) GetOldProfileName() string {
+ if x != nil {
+ return x.OldProfileName
+ }
+ return ""
}
type RemoveProfileRequest struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
- ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
+ // profileName is treated as a handle: an exact ID, a unique ID
+ // prefix, or a unique display name. Resolution happens server-side.
+ ProfileName string `protobuf:"bytes,2,opt,name=profileName,proto3" json:"profileName,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveProfileRequest) Reset() {
*x = RemoveProfileRequest{}
- mi := &file_daemon_proto_msgTypes[60]
+ mi := &file_daemon_proto_msgTypes[64]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4467,7 +4767,7 @@ func (x *RemoveProfileRequest) String() string {
func (*RemoveProfileRequest) ProtoMessage() {}
func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[60]
+ mi := &file_daemon_proto_msgTypes[64]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4480,7 +4780,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead.
func (*RemoveProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{60}
+ return file_daemon_proto_rawDescGZIP(), []int{64}
}
func (x *RemoveProfileRequest) GetUsername() string {
@@ -4498,14 +4798,17 @@ func (x *RemoveProfileRequest) GetProfileName() string {
}
type RemoveProfileResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // id is the full resolved ID of the removed profile, so callers can
+ // confirm exactly which profile a name/prefix handle resolved to.
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveProfileResponse) Reset() {
*x = RemoveProfileResponse{}
- mi := &file_daemon_proto_msgTypes[61]
+ mi := &file_daemon_proto_msgTypes[65]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4517,7 +4820,7 @@ func (x *RemoveProfileResponse) String() string {
func (*RemoveProfileResponse) ProtoMessage() {}
func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[61]
+ mi := &file_daemon_proto_msgTypes[65]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4530,7 +4833,14 @@ func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveProfileResponse.ProtoReflect.Descriptor instead.
func (*RemoveProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{61}
+ return file_daemon_proto_rawDescGZIP(), []int{65}
+}
+
+func (x *RemoveProfileResponse) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
}
type ListProfilesRequest struct {
@@ -4542,7 +4852,7 @@ type ListProfilesRequest struct {
func (x *ListProfilesRequest) Reset() {
*x = ListProfilesRequest{}
- mi := &file_daemon_proto_msgTypes[62]
+ mi := &file_daemon_proto_msgTypes[66]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4554,7 +4864,7 @@ func (x *ListProfilesRequest) String() string {
func (*ListProfilesRequest) ProtoMessage() {}
func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[62]
+ mi := &file_daemon_proto_msgTypes[66]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4567,7 +4877,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead.
func (*ListProfilesRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{62}
+ return file_daemon_proto_rawDescGZIP(), []int{66}
}
func (x *ListProfilesRequest) GetUsername() string {
@@ -4586,7 +4896,7 @@ type ListProfilesResponse struct {
func (x *ListProfilesResponse) Reset() {
*x = ListProfilesResponse{}
- mi := &file_daemon_proto_msgTypes[63]
+ mi := &file_daemon_proto_msgTypes[67]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4598,7 +4908,7 @@ func (x *ListProfilesResponse) String() string {
func (*ListProfilesResponse) ProtoMessage() {}
func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[63]
+ mi := &file_daemon_proto_msgTypes[67]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4611,7 +4921,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead.
func (*ListProfilesResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{63}
+ return file_daemon_proto_rawDescGZIP(), []int{67}
}
func (x *ListProfilesResponse) GetProfiles() []*Profile {
@@ -4625,13 +4935,14 @@ type Profile struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
IsActive bool `protobuf:"varint,2,opt,name=is_active,json=isActive,proto3" json:"is_active,omitempty"`
+ Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Profile) Reset() {
*x = Profile{}
- mi := &file_daemon_proto_msgTypes[64]
+ mi := &file_daemon_proto_msgTypes[68]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4643,7 +4954,7 @@ func (x *Profile) String() string {
func (*Profile) ProtoMessage() {}
func (x *Profile) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[64]
+ mi := &file_daemon_proto_msgTypes[68]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4656,7 +4967,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message {
// Deprecated: Use Profile.ProtoReflect.Descriptor instead.
func (*Profile) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{64}
+ return file_daemon_proto_rawDescGZIP(), []int{68}
}
func (x *Profile) GetName() string {
@@ -4673,6 +4984,13 @@ func (x *Profile) GetIsActive() bool {
return false
}
+func (x *Profile) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
type GetActiveProfileRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -4681,7 +4999,7 @@ type GetActiveProfileRequest struct {
func (x *GetActiveProfileRequest) Reset() {
*x = GetActiveProfileRequest{}
- mi := &file_daemon_proto_msgTypes[65]
+ mi := &file_daemon_proto_msgTypes[69]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4693,7 +5011,7 @@ func (x *GetActiveProfileRequest) String() string {
func (*GetActiveProfileRequest) ProtoMessage() {}
func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[65]
+ mi := &file_daemon_proto_msgTypes[69]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4706,20 +5024,21 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead.
func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{65}
+ return file_daemon_proto_rawDescGZIP(), []int{69}
}
type GetActiveProfileResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
ProfileName string `protobuf:"bytes,1,opt,name=profileName,proto3" json:"profileName,omitempty"`
Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"`
+ Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetActiveProfileResponse) Reset() {
*x = GetActiveProfileResponse{}
- mi := &file_daemon_proto_msgTypes[66]
+ mi := &file_daemon_proto_msgTypes[70]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4731,7 +5050,7 @@ func (x *GetActiveProfileResponse) String() string {
func (*GetActiveProfileResponse) ProtoMessage() {}
func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[66]
+ mi := &file_daemon_proto_msgTypes[70]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4744,7 +5063,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead.
func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{66}
+ return file_daemon_proto_rawDescGZIP(), []int{70}
}
func (x *GetActiveProfileResponse) GetProfileName() string {
@@ -4761,6 +5080,13 @@ func (x *GetActiveProfileResponse) GetUsername() string {
return ""
}
+func (x *GetActiveProfileResponse) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
type LogoutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
@@ -4771,7 +5097,7 @@ type LogoutRequest struct {
func (x *LogoutRequest) Reset() {
*x = LogoutRequest{}
- mi := &file_daemon_proto_msgTypes[67]
+ mi := &file_daemon_proto_msgTypes[71]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4783,7 +5109,7 @@ func (x *LogoutRequest) String() string {
func (*LogoutRequest) ProtoMessage() {}
func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[67]
+ mi := &file_daemon_proto_msgTypes[71]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4796,7 +5122,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead.
func (*LogoutRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{67}
+ return file_daemon_proto_rawDescGZIP(), []int{71}
}
func (x *LogoutRequest) GetProfileName() string {
@@ -4821,7 +5147,7 @@ type LogoutResponse struct {
func (x *LogoutResponse) Reset() {
*x = LogoutResponse{}
- mi := &file_daemon_proto_msgTypes[68]
+ mi := &file_daemon_proto_msgTypes[72]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4833,7 +5159,7 @@ func (x *LogoutResponse) String() string {
func (*LogoutResponse) ProtoMessage() {}
func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[68]
+ mi := &file_daemon_proto_msgTypes[72]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4846,7 +5172,79 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead.
func (*LogoutResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{68}
+ return file_daemon_proto_rawDescGZIP(), []int{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 {
@@ -4857,7 +5255,7 @@ type GetFeaturesRequest struct {
func (x *GetFeaturesRequest) Reset() {
*x = GetFeaturesRequest{}
- mi := &file_daemon_proto_msgTypes[69]
+ mi := &file_daemon_proto_msgTypes[75]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4869,7 +5267,7 @@ func (x *GetFeaturesRequest) String() string {
func (*GetFeaturesRequest) ProtoMessage() {}
func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[69]
+ mi := &file_daemon_proto_msgTypes[75]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4882,7 +5280,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead.
func (*GetFeaturesRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{69}
+ return file_daemon_proto_rawDescGZIP(), []int{75}
}
type GetFeaturesResponse struct {
@@ -4890,13 +5288,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[70]
+ mi := &file_daemon_proto_msgTypes[76]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4908,7 +5312,7 @@ func (x *GetFeaturesResponse) String() string {
func (*GetFeaturesResponse) ProtoMessage() {}
func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[70]
+ mi := &file_daemon_proto_msgTypes[76]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4921,7 +5325,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead.
func (*GetFeaturesResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{70}
+ return file_daemon_proto_rawDescGZIP(), []int{76}
}
func (x *GetFeaturesResponse) GetDisableProfiles() bool {
@@ -4945,6 +5349,62 @@ 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.
+// The fields list contains the offending key names; the entire request is
+// rejected (no partial apply).
+type MDMManagedFieldsViolation struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Fields []string `protobuf:"bytes,1,rep,name=fields,proto3" json:"fields,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *MDMManagedFieldsViolation) Reset() {
+ *x = MDMManagedFieldsViolation{}
+ mi := &file_daemon_proto_msgTypes[77]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *MDMManagedFieldsViolation) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*MDMManagedFieldsViolation) ProtoMessage() {}
+
+func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[77]
+ 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 MDMManagedFieldsViolation.ProtoReflect.Descriptor instead.
+func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{77}
+}
+
+func (x *MDMManagedFieldsViolation) GetFields() []string {
+ if x != nil {
+ return x.Fields
+ }
+ return nil
+}
+
type TriggerUpdateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -4953,7 +5413,7 @@ type TriggerUpdateRequest struct {
func (x *TriggerUpdateRequest) Reset() {
*x = TriggerUpdateRequest{}
- mi := &file_daemon_proto_msgTypes[71]
+ mi := &file_daemon_proto_msgTypes[78]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -4965,7 +5425,7 @@ func (x *TriggerUpdateRequest) String() string {
func (*TriggerUpdateRequest) ProtoMessage() {}
func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[71]
+ mi := &file_daemon_proto_msgTypes[78]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -4978,7 +5438,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead.
func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{71}
+ return file_daemon_proto_rawDescGZIP(), []int{78}
}
type TriggerUpdateResponse struct {
@@ -4991,7 +5451,7 @@ type TriggerUpdateResponse struct {
func (x *TriggerUpdateResponse) Reset() {
*x = TriggerUpdateResponse{}
- mi := &file_daemon_proto_msgTypes[72]
+ mi := &file_daemon_proto_msgTypes[79]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5003,7 +5463,7 @@ func (x *TriggerUpdateResponse) String() string {
func (*TriggerUpdateResponse) ProtoMessage() {}
func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[72]
+ mi := &file_daemon_proto_msgTypes[79]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5016,7 +5476,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead.
func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{72}
+ return file_daemon_proto_rawDescGZIP(), []int{79}
}
func (x *TriggerUpdateResponse) GetSuccess() bool {
@@ -5044,7 +5504,7 @@ type GetPeerSSHHostKeyRequest struct {
func (x *GetPeerSSHHostKeyRequest) Reset() {
*x = GetPeerSSHHostKeyRequest{}
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[80]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5056,7 +5516,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string {
func (*GetPeerSSHHostKeyRequest) ProtoMessage() {}
func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[80]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5069,7 +5529,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead.
func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{73}
+ return file_daemon_proto_rawDescGZIP(), []int{80}
}
func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string {
@@ -5096,7 +5556,7 @@ type GetPeerSSHHostKeyResponse struct {
func (x *GetPeerSSHHostKeyResponse) Reset() {
*x = GetPeerSSHHostKeyResponse{}
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[81]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5108,7 +5568,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string {
func (*GetPeerSSHHostKeyResponse) ProtoMessage() {}
func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[81]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5121,7 +5581,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead.
func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{74}
+ return file_daemon_proto_rawDescGZIP(), []int{81}
}
func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte {
@@ -5163,7 +5623,7 @@ type RequestJWTAuthRequest struct {
func (x *RequestJWTAuthRequest) Reset() {
*x = RequestJWTAuthRequest{}
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[82]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5175,7 +5635,7 @@ func (x *RequestJWTAuthRequest) String() string {
func (*RequestJWTAuthRequest) ProtoMessage() {}
func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[82]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5188,7 +5648,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead.
func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{75}
+ return file_daemon_proto_rawDescGZIP(), []int{82}
}
func (x *RequestJWTAuthRequest) GetHint() string {
@@ -5221,7 +5681,7 @@ type RequestJWTAuthResponse struct {
func (x *RequestJWTAuthResponse) Reset() {
*x = RequestJWTAuthResponse{}
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[83]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5233,7 +5693,7 @@ func (x *RequestJWTAuthResponse) String() string {
func (*RequestJWTAuthResponse) ProtoMessage() {}
func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[83]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5246,7 +5706,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead.
func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{76}
+ return file_daemon_proto_rawDescGZIP(), []int{83}
}
func (x *RequestJWTAuthResponse) GetVerificationURI() string {
@@ -5311,7 +5771,7 @@ type WaitJWTTokenRequest struct {
func (x *WaitJWTTokenRequest) Reset() {
*x = WaitJWTTokenRequest{}
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[84]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5323,7 +5783,7 @@ func (x *WaitJWTTokenRequest) String() string {
func (*WaitJWTTokenRequest) ProtoMessage() {}
func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[84]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5336,7 +5796,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead.
func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{77}
+ return file_daemon_proto_rawDescGZIP(), []int{84}
}
func (x *WaitJWTTokenRequest) GetDeviceCode() string {
@@ -5368,7 +5828,7 @@ type WaitJWTTokenResponse struct {
func (x *WaitJWTTokenResponse) Reset() {
*x = WaitJWTTokenResponse{}
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[85]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5380,7 +5840,7 @@ func (x *WaitJWTTokenResponse) String() string {
func (*WaitJWTTokenResponse) ProtoMessage() {}
func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[85]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5393,7 +5853,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead.
func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{78}
+ return file_daemon_proto_rawDescGZIP(), []int{85}
}
func (x *WaitJWTTokenResponse) GetToken() string {
@@ -5417,6 +5877,318 @@ 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"`
+ 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 ""
+}
+
+// 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"`
@@ -5426,7 +6198,7 @@ type StartCPUProfileRequest struct {
func (x *StartCPUProfileRequest) Reset() {
*x = StartCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[92]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5438,7 +6210,7 @@ func (x *StartCPUProfileRequest) String() string {
func (*StartCPUProfileRequest) ProtoMessage() {}
func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[92]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5451,7 +6223,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead.
func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{79}
+ return file_daemon_proto_rawDescGZIP(), []int{92}
}
// StartCPUProfileResponse confirms CPU profiling has started
@@ -5463,7 +6235,7 @@ type StartCPUProfileResponse struct {
func (x *StartCPUProfileResponse) Reset() {
*x = StartCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[93]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5475,7 +6247,7 @@ func (x *StartCPUProfileResponse) String() string {
func (*StartCPUProfileResponse) ProtoMessage() {}
func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[93]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5488,7 +6260,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead.
func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{80}
+ return file_daemon_proto_rawDescGZIP(), []int{93}
}
// StopCPUProfileRequest for stopping CPU profiling
@@ -5500,7 +6272,7 @@ type StopCPUProfileRequest struct {
func (x *StopCPUProfileRequest) Reset() {
*x = StopCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[94]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5512,7 +6284,7 @@ func (x *StopCPUProfileRequest) String() string {
func (*StopCPUProfileRequest) ProtoMessage() {}
func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[94]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5525,7 +6297,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead.
func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{81}
+ return file_daemon_proto_rawDescGZIP(), []int{94}
}
// StopCPUProfileResponse confirms CPU profiling has stopped
@@ -5537,7 +6309,7 @@ type StopCPUProfileResponse struct {
func (x *StopCPUProfileResponse) Reset() {
*x = StopCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[95]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5549,7 +6321,7 @@ func (x *StopCPUProfileResponse) String() string {
func (*StopCPUProfileResponse) ProtoMessage() {}
func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[95]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5562,7 +6334,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead.
func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{82}
+ return file_daemon_proto_rawDescGZIP(), []int{95}
}
type InstallerResultRequest struct {
@@ -5573,7 +6345,7 @@ type InstallerResultRequest struct {
func (x *InstallerResultRequest) Reset() {
*x = InstallerResultRequest{}
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[96]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5585,7 +6357,7 @@ func (x *InstallerResultRequest) String() string {
func (*InstallerResultRequest) ProtoMessage() {}
func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[96]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5598,7 +6370,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead.
func (*InstallerResultRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{83}
+ return file_daemon_proto_rawDescGZIP(), []int{96}
}
type InstallerResultResponse struct {
@@ -5611,7 +6383,7 @@ type InstallerResultResponse struct {
func (x *InstallerResultResponse) Reset() {
*x = InstallerResultResponse{}
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[97]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5623,7 +6395,7 @@ func (x *InstallerResultResponse) String() string {
func (*InstallerResultResponse) ProtoMessage() {}
func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[97]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5636,7 +6408,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead.
func (*InstallerResultResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{84}
+ return file_daemon_proto_rawDescGZIP(), []int{97}
}
func (x *InstallerResultResponse) GetSuccess() bool {
@@ -5669,7 +6441,7 @@ type ExposeServiceRequest struct {
func (x *ExposeServiceRequest) Reset() {
*x = ExposeServiceRequest{}
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[98]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5681,7 +6453,7 @@ func (x *ExposeServiceRequest) String() string {
func (*ExposeServiceRequest) ProtoMessage() {}
func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[98]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5694,7 +6466,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead.
func (*ExposeServiceRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{85}
+ return file_daemon_proto_rawDescGZIP(), []int{98}
}
func (x *ExposeServiceRequest) GetPort() uint32 {
@@ -5765,7 +6537,7 @@ type ExposeServiceEvent struct {
func (x *ExposeServiceEvent) Reset() {
*x = ExposeServiceEvent{}
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[99]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5777,7 +6549,7 @@ func (x *ExposeServiceEvent) String() string {
func (*ExposeServiceEvent) ProtoMessage() {}
func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[99]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5790,7 +6562,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead.
func (*ExposeServiceEvent) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{86}
+ return file_daemon_proto_rawDescGZIP(), []int{99}
}
func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event {
@@ -5831,7 +6603,7 @@ type ExposeServiceReady struct {
func (x *ExposeServiceReady) Reset() {
*x = ExposeServiceReady{}
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[100]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5843,7 +6615,7 @@ func (x *ExposeServiceReady) String() string {
func (*ExposeServiceReady) ProtoMessage() {}
func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[100]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5856,7 +6628,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead.
func (*ExposeServiceReady) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{87}
+ return file_daemon_proto_rawDescGZIP(), []int{100}
}
func (x *ExposeServiceReady) GetServiceName() string {
@@ -5901,7 +6673,7 @@ type StartCaptureRequest struct {
func (x *StartCaptureRequest) Reset() {
*x = StartCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[101]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5913,7 +6685,7 @@ func (x *StartCaptureRequest) String() string {
func (*StartCaptureRequest) ProtoMessage() {}
func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[101]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5926,7 +6698,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{88}
+ return file_daemon_proto_rawDescGZIP(), []int{101}
}
func (x *StartCaptureRequest) GetTextOutput() bool {
@@ -5980,7 +6752,7 @@ type CapturePacket struct {
func (x *CapturePacket) Reset() {
*x = CapturePacket{}
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[102]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5992,7 +6764,7 @@ func (x *CapturePacket) String() string {
func (*CapturePacket) ProtoMessage() {}
func (x *CapturePacket) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[102]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6005,7 +6777,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message {
// Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead.
func (*CapturePacket) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{89}
+ return file_daemon_proto_rawDescGZIP(), []int{102}
}
func (x *CapturePacket) GetData() []byte {
@@ -6026,7 +6798,7 @@ type StartBundleCaptureRequest struct {
func (x *StartBundleCaptureRequest) Reset() {
*x = StartBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[103]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6038,7 +6810,7 @@ func (x *StartBundleCaptureRequest) String() string {
func (*StartBundleCaptureRequest) ProtoMessage() {}
func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[103]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6051,7 +6823,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{90}
+ return file_daemon_proto_rawDescGZIP(), []int{103}
}
func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration {
@@ -6069,7 +6841,7 @@ type StartBundleCaptureResponse struct {
func (x *StartBundleCaptureResponse) Reset() {
*x = StartBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[104]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6081,7 +6853,7 @@ func (x *StartBundleCaptureResponse) String() string {
func (*StartBundleCaptureResponse) ProtoMessage() {}
func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[104]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6094,7 +6866,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{91}
+ return file_daemon_proto_rawDescGZIP(), []int{104}
}
type StopBundleCaptureRequest struct {
@@ -6105,7 +6877,7 @@ type StopBundleCaptureRequest struct {
func (x *StopBundleCaptureRequest) Reset() {
*x = StopBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[105]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6117,7 +6889,7 @@ func (x *StopBundleCaptureRequest) String() string {
func (*StopBundleCaptureRequest) ProtoMessage() {}
func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[105]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6130,7 +6902,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{92}
+ return file_daemon_proto_rawDescGZIP(), []int{105}
}
type StopBundleCaptureResponse struct {
@@ -6141,7 +6913,7 @@ type StopBundleCaptureResponse struct {
func (x *StopBundleCaptureResponse) Reset() {
*x = StopBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[106]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6153,7 +6925,7 @@ func (x *StopBundleCaptureResponse) String() string {
func (*StopBundleCaptureResponse) ProtoMessage() {}
func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[106]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6166,7 +6938,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{93}
+ return file_daemon_proto_rawDescGZIP(), []int{106}
}
type PortInfo_Range struct {
@@ -6179,7 +6951,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
- mi := &file_daemon_proto_msgTypes[95]
+ mi := &file_daemon_proto_msgTypes[108]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6191,7 +6963,7 @@ func (x *PortInfo_Range) String() string {
func (*PortInfo_Range) ProtoMessage() {}
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[95]
+ mi := &file_daemon_proto_msgTypes[108]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6309,10 +7081,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" +
@@ -6321,18 +7094,19 @@ 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" +
"\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" +
- "\busername\x18\x02 \x01(\tR\busername\"\xfe\b\n" +
+ "\busername\x18\x02 \x01(\tR\busername\"\xaa\t\n" +
"\x11GetConfigResponse\x12$\n" +
"\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" +
"\n" +
@@ -6364,7 +7138,8 @@ const file_daemon_proto_rawDesc = "" +
"\x1denableSSHRemotePortForwarding\x18\x17 \x01(\bR\x1denableSSHRemotePortForwarding\x12&\n" +
"\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" +
"\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" +
- "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\"\x92\x06\n" +
+ "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12*\n" +
+ "\x10mDMManagedFields\x18\x1c \x03(\tR\x10mDMManagedFields\"\x92\x06\n" +
"\tPeerState\x12\x0e\n" +
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12\x1e\n" +
@@ -6389,7 +7164,7 @@ const file_daemon_proto_rawDesc = "" +
"\n" +
"sshHostKey\x18\x13 \x01(\fR\n" +
"sshHostKey\x12\x12\n" +
- "\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x84\x02\n" +
+ "\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x9c\x02\n" +
"\x0eLocalPeerState\x12\x0e\n" +
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12(\n" +
@@ -6398,7 +7173,8 @@ const file_daemon_proto_rawDesc = "" +
"\x10rosenpassEnabled\x18\x05 \x01(\bR\x10rosenpassEnabled\x120\n" +
"\x13rosenpassPermissive\x18\x06 \x01(\bR\x13rosenpassPermissive\x12\x1a\n" +
"\bnetworks\x18\a \x03(\tR\bnetworks\x12\x12\n" +
- "\x04ipv6\x18\b \x01(\tR\x04ipv6\"S\n" +
+ "\x04ipv6\x18\b \x01(\tR\x04ipv6\x12\x16\n" +
+ "\x06wgPort\x18\t \x01(\x05R\x06wgPort\"S\n" +
"\vSignalState\x12\x10\n" +
"\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" +
"\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" +
@@ -6406,12 +7182,13 @@ const file_daemon_proto_rawDesc = "" +
"\x0fManagementState\x12\x10\n" +
"\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" +
"\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" +
- "\x05error\x18\x03 \x01(\tR\x05error\"R\n" +
+ "\x05error\x18\x03 \x01(\tR\x05error\"p\n" +
"\n" +
"RelayState\x12\x10\n" +
"\x03URI\x18\x01 \x01(\tR\x03URI\x12\x1c\n" +
"\tavailable\x18\x02 \x01(\bR\tavailable\x12\x14\n" +
- "\x05error\x18\x03 \x01(\tR\x05error\"r\n" +
+ "\x05error\x18\x03 \x01(\tR\x05error\x12\x1c\n" +
+ "\ttransport\x18\x04 \x01(\tR\ttransport\"r\n" +
"\fNSGroupState\x12\x18\n" +
"\aservers\x18\x01 \x03(\tR\aservers\x12\x18\n" +
"\adomains\x18\x02 \x03(\tR\adomains\x12\x18\n" +
@@ -6425,7 +7202,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" +
@@ -6439,7 +7216,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" +
@@ -6475,14 +7253,18 @@ const file_daemon_proto_rawDesc = "" +
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
"\x17ForwardingRulesResponse\x12,\n" +
- "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x94\x01\n" +
+ "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xdc\x01\n" +
"\x12DebugBundleRequest\x12\x1c\n" +
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
"\n" +
"systemInfo\x18\x03 \x01(\bR\n" +
"systemInfo\x12\x1c\n" +
"\tuploadURL\x18\x04 \x01(\tR\tuploadURL\x12\"\n" +
- "\flogFileCount\x18\x05 \x01(\rR\flogFileCount\"}\n" +
+ "\flogFileCount\x18\x05 \x01(\rR\flogFileCount\x12\x1e\n" +
+ "\n" +
+ "cliVersion\x18\x06 \x01(\tR\n" +
+ "cliVersion\x12&\n" +
+ "\x0euploadInsecure\x18\a \x01(\bR\x0euploadInsecure\"}\n" +
"\x13DebugBundleResponse\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +
@@ -6492,7 +7274,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" +
@@ -6578,8 +7363,9 @@ const file_daemon_proto_rawDesc = "" +
"\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" +
"\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" +
"\f_profileNameB\v\n" +
- "\t_username\"\x17\n" +
- "\x15SwitchProfileResponse\"\x98\x11\n" +
+ "\t_username\"'\n" +
+ "\x15SwitchProfileResponse\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" +
"\x10SetConfigRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
"\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" +
@@ -6648,34 +7434,50 @@ const file_daemon_proto_rawDesc = "" +
"\x11SetConfigResponse\"Q\n" +
"\x11AddProfileRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
- "\vprofileName\x18\x02 \x01(\tR\vprofileName\"\x14\n" +
- "\x12AddProfileResponse\"T\n" +
+ "\vprofileName\x18\x02 \x01(\tR\vprofileName\"$\n" +
+ "\x12AddProfileResponse\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\"r\n" +
+ "\x14RenameProfileRequest\x12\x1a\n" +
+ "\busername\x18\x01 \x01(\tR\busername\x12\x16\n" +
+ "\x06handle\x18\x02 \x01(\tR\x06handle\x12&\n" +
+ "\x0enewProfileName\x18\x03 \x01(\tR\x0enewProfileName\"?\n" +
+ "\x15RenameProfileResponse\x12&\n" +
+ "\x0eoldProfileName\x18\x01 \x01(\tR\x0eoldProfileName\"T\n" +
"\x14RemoveProfileRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
- "\vprofileName\x18\x02 \x01(\tR\vprofileName\"\x17\n" +
- "\x15RemoveProfileResponse\"1\n" +
+ "\vprofileName\x18\x02 \x01(\tR\vprofileName\"'\n" +
+ "\x15RemoveProfileResponse\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\"1\n" +
"\x13ListProfilesRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\"C\n" +
"\x14ListProfilesResponse\x12+\n" +
- "\bprofiles\x18\x01 \x03(\v2\x0f.daemon.ProfileR\bprofiles\":\n" +
+ "\bprofiles\x18\x01 \x03(\v2\x0f.daemon.ProfileR\bprofiles\"J\n" +
"\aProfile\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x1b\n" +
- "\tis_active\x18\x02 \x01(\bR\bisActive\"\x19\n" +
- "\x17GetActiveProfileRequest\"X\n" +
+ "\tis_active\x18\x02 \x01(\bR\bisActive\x12\x0e\n" +
+ "\x02id\x18\x03 \x01(\tR\x02id\"\x19\n" +
+ "\x17GetActiveProfileRequest\"h\n" +
"\x18GetActiveProfileResponse\x12 \n" +
"\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" +
- "\busername\x18\x02 \x01(\tR\busername\"t\n" +
+ "\busername\x18\x02 \x01(\tR\busername\x12\x0e\n" +
+ "\x02id\x18\x03 \x01(\tR\x02id\"t\n" +
"\rLogoutRequest\x12%\n" +
"\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" +
"\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" +
"\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\"\x16\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" +
"\x15TriggerUpdateResponse\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x1a\n" +
@@ -6710,7 +7512,27 @@ 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\"C\n" +
+ "\x1fRequestExtendAuthSessionRequest\x12\x17\n" +
+ "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01B\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" +
@@ -6773,12 +7595,13 @@ const file_daemon_proto_rawDesc = "" +
"\n" +
"EXPOSE_UDP\x10\x03\x12\x0e\n" +
"\n" +
- "EXPOSE_TLS\x10\x042\xaf\x17\n" +
+ "EXPOSE_TLS\x10\x042\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" +
@@ -6800,10 +7623,12 @@ 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" +
"AddProfile\x12\x19.daemon.AddProfileRequest\x1a\x1a.daemon.AddProfileResponse\"\x00\x12N\n" +
+ "\rRenameProfile\x12\x1c.daemon.RenameProfileRequest\x1a\x1d.daemon.RenameProfileResponse\"\x00\x12N\n" +
"\rRemoveProfile\x12\x1c.daemon.RemoveProfileRequest\x1a\x1d.daemon.RemoveProfileResponse\"\x00\x12K\n" +
"\fListProfiles\x12\x1b.daemon.ListProfilesRequest\x1a\x1c.daemon.ListProfilesResponse\"\x00\x12W\n" +
"\x10GetActiveProfile\x12\x1f.daemon.GetActiveProfileRequest\x1a .daemon.GetActiveProfileResponse\"\x00\x129\n" +
@@ -6812,11 +7637,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
@@ -6831,7 +7660,7 @@ func file_daemon_proto_rawDescGZIP() []byte {
}
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
-var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 97)
+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
@@ -6874,190 +7703,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
- (*RemoveProfileRequest)(nil), // 64: daemon.RemoveProfileRequest
- (*RemoveProfileResponse)(nil), // 65: daemon.RemoveProfileResponse
- (*ListProfilesRequest)(nil), // 66: daemon.ListProfilesRequest
- (*ListProfilesResponse)(nil), // 67: daemon.ListProfilesResponse
- (*Profile)(nil), // 68: daemon.Profile
- (*GetActiveProfileRequest)(nil), // 69: daemon.GetActiveProfileRequest
- (*GetActiveProfileResponse)(nil), // 70: daemon.GetActiveProfileResponse
- (*LogoutRequest)(nil), // 71: daemon.LogoutRequest
- (*LogoutResponse)(nil), // 72: daemon.LogoutResponse
- (*GetFeaturesRequest)(nil), // 73: daemon.GetFeaturesRequest
- (*GetFeaturesResponse)(nil), // 74: daemon.GetFeaturesResponse
- (*TriggerUpdateRequest)(nil), // 75: daemon.TriggerUpdateRequest
- (*TriggerUpdateResponse)(nil), // 76: daemon.TriggerUpdateResponse
- (*GetPeerSSHHostKeyRequest)(nil), // 77: daemon.GetPeerSSHHostKeyRequest
- (*GetPeerSSHHostKeyResponse)(nil), // 78: daemon.GetPeerSSHHostKeyResponse
- (*RequestJWTAuthRequest)(nil), // 79: daemon.RequestJWTAuthRequest
- (*RequestJWTAuthResponse)(nil), // 80: daemon.RequestJWTAuthResponse
- (*WaitJWTTokenRequest)(nil), // 81: daemon.WaitJWTTokenRequest
- (*WaitJWTTokenResponse)(nil), // 82: daemon.WaitJWTTokenResponse
- (*StartCPUProfileRequest)(nil), // 83: daemon.StartCPUProfileRequest
- (*StartCPUProfileResponse)(nil), // 84: daemon.StartCPUProfileResponse
- (*StopCPUProfileRequest)(nil), // 85: daemon.StopCPUProfileRequest
- (*StopCPUProfileResponse)(nil), // 86: daemon.StopCPUProfileResponse
- (*InstallerResultRequest)(nil), // 87: daemon.InstallerResultRequest
- (*InstallerResultResponse)(nil), // 88: daemon.InstallerResultResponse
- (*ExposeServiceRequest)(nil), // 89: daemon.ExposeServiceRequest
- (*ExposeServiceEvent)(nil), // 90: daemon.ExposeServiceEvent
- (*ExposeServiceReady)(nil), // 91: daemon.ExposeServiceReady
- (*StartCaptureRequest)(nil), // 92: daemon.StartCaptureRequest
- (*CapturePacket)(nil), // 93: daemon.CapturePacket
- (*StartBundleCaptureRequest)(nil), // 94: daemon.StartBundleCaptureRequest
- (*StartBundleCaptureResponse)(nil), // 95: daemon.StartBundleCaptureResponse
- (*StopBundleCaptureRequest)(nil), // 96: daemon.StopBundleCaptureRequest
- (*StopBundleCaptureResponse)(nil), // 97: daemon.StopBundleCaptureResponse
- nil, // 98: daemon.Network.ResolvedIPsEntry
- (*PortInfo_Range)(nil), // 99: daemon.PortInfo.Range
- nil, // 100: daemon.SystemEvent.MetadataEntry
- (*durationpb.Duration)(nil), // 101: google.protobuf.Duration
- (*timestamppb.Timestamp)(nil), // 102: 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{
- 101, // 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
- 102, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
- 102, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
- 101, // 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
- 98, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
- 99, // 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
- 102, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
- 100, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
- 55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
- 101, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
- 68, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
- 1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
- 91, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
- 101, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
- 101, // 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
- 92, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
- 94, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
- 96, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
- 54, // 58: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
- 56, // 59: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
- 58, // 60: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
- 60, // 61: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
- 62, // 62: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
- 64, // 63: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
- 66, // 64: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
- 69, // 65: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
- 71, // 66: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
- 73, // 67: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
- 75, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
- 77, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
- 79, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
- 81, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
- 83, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
- 85, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
- 87, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
- 89, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
- 6, // 76: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
- 8, // 77: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
- 10, // 78: daemon.DaemonService.Up:output_type -> daemon.UpResponse
- 12, // 79: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
- 14, // 80: daemon.DaemonService.Down:output_type -> daemon.DownResponse
- 16, // 81: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
- 27, // 82: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
- 29, // 83: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
- 29, // 84: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
- 34, // 85: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
- 36, // 86: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
- 38, // 87: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
- 40, // 88: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
- 43, // 89: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
- 45, // 90: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
- 47, // 91: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
- 49, // 92: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
- 53, // 93: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
- 93, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
- 95, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
- 97, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
- 55, // 97: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
- 57, // 98: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
- 59, // 99: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
- 61, // 100: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
- 63, // 101: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
- 65, // 102: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
- 67, // 103: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
- 70, // 104: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
- 72, // 105: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
- 74, // 106: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
- 76, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
- 78, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
- 80, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
- 82, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
- 84, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
- 86, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
- 88, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
- 90, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
- 76, // [76:115] is the sub-list for method output_type
- 37, // [37:76] is the sub-list for method input_type
- 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() }
@@ -7072,13 +7930,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[67].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[75].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[86].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{}
@@ -7087,7 +7947,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: 97,
+ 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 dedff43e2..3c31156ec 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,12 +85,19 @@ 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) {}
rpc AddProfile(AddProfileRequest) returns (AddProfileResponse) {}
+ rpc RenameProfile(RenameProfileRequest) returns (RenameProfileResponse) {}
+
rpc RemoveProfile(RemoveProfileRequest) returns (RemoveProfileResponse) {}
rpc ListProfiles(ListProfilesRequest) returns (ListProfilesResponse) {}
@@ -109,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) {}
@@ -119,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) {}
}
@@ -227,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 {}
@@ -244,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 {}
@@ -314,6 +361,13 @@ message GetConfigResponse {
int32 sshJWTCacheTTL = 26;
bool disable_ipv6 = 27;
+
+ // mDMManagedFields lists the names of configuration keys whose value is
+ // currently enforced by an MDM policy. Names match mdm.Key* constants
+ // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should
+ // render the corresponding inputs as read-only and display a "managed
+ // by MDM" indicator.
+ repeated string mDMManagedFields = 28;
}
// PeerState contains the latest state of a peer
@@ -349,6 +403,7 @@ message LocalPeerState {
bool rosenpassPermissive = 6;
repeated string networks = 7;
string ipv6 = 8;
+ int32 wgPort = 9;
}
// SignalState contains the latest state of a signal connection
@@ -370,6 +425,9 @@ message RelayState {
string URI = 1;
bool available = 2;
string error = 3;
+ // transport is the negotiated relay transport (e.g. "ws", "quic"),
+ // empty for stun/turn probes or when not connected.
+ string transport = 4;
}
message NSGroupState {
@@ -408,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
@@ -471,6 +535,11 @@ message DebugBundleRequest {
bool systemInfo = 3;
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;
}
message DebugBundleResponse {
@@ -504,6 +573,13 @@ message SetLogLevelRequest {
message SetLogLevelResponse {
}
+message RegisterUILogRequest {
+ string path = 1;
+}
+
+message RegisterUILogResponse {
+}
+
// State represents a daemon state entry
message State {
string name = 1;
@@ -613,11 +689,18 @@ message GetEventsResponse {
}
message SwitchProfileRequest {
+ // profileName is treated as a handle: exact ID, unique ID prefix, or
+ // unique display name. The daemon resolves it server-side.
optional string profileName = 1;
optional string username = 2;
}
-message SwitchProfileResponse {}
+message SwitchProfileResponse {
+ // id is the resolved on-disk ID of the profile that became active.
+ // Lets CLI clients update their local active-profile state without
+ // duplicating the resolution logic.
+ string id = 1;
+}
message SetConfigRequest {
string username = 1;
@@ -684,17 +767,42 @@ message SetConfigResponse{}
message AddProfileRequest {
string username = 1;
+ // profileName carries the human-readable display name for the new
+ // profile. The on-disk filename is a separately-generated ID.
string profileName = 2;
}
-message AddProfileResponse {}
+message AddProfileResponse {
+ // id is the generated on-disk ID of the new profile. CLI clients
+ // display a truncated form, UI clients can ignore it.
+ string id = 1;
+}
+
+message RenameProfileRequest {
+ string username = 1;
+ // handle: an exact ID, a unique ID prefix, or a unique display name.
+ string handle = 2;
+ // newProfileName is the new human-readable display name for the profile.
+ string newProfileName = 3;
+}
+
+message RenameProfileResponse {
+ // confirm the old profile name after resolving handle.
+ string oldProfileName = 1;
+}
message RemoveProfileRequest {
string username = 1;
+ // profileName is treated as a handle: an exact ID, a unique ID
+ // prefix, or a unique display name. Resolution happens server-side.
string profileName = 2;
}
-message RemoveProfileResponse {}
+message RemoveProfileResponse {
+ // id is the full resolved ID of the removed profile, so callers can
+ // confirm exactly which profile a name/prefix handle resolved to.
+ string id = 1;
+}
message ListProfilesRequest {
string username = 1;
@@ -707,6 +815,7 @@ message ListProfilesResponse {
message Profile {
string name = 1;
bool is_active = 2;
+ string id = 3;
}
message GetActiveProfileRequest {}
@@ -714,6 +823,7 @@ message GetActiveProfileRequest {}
message GetActiveProfileResponse {
string profileName = 1;
string username = 2;
+ string id = 3;
}
message LogoutRequest {
@@ -723,12 +833,31 @@ 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
+// FailedPrecondition status returned from SetConfig (and similar mutating
+// RPCs) when the caller tries to modify one or more MDM-enforced fields.
+// The fields list contains the offending key names; the entire request is
+// rejected (no partial apply).
+message MDMManagedFieldsViolation {
+ repeated string fields = 1;
}
message TriggerUpdateRequest {}
@@ -798,6 +927,55 @@ 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;
+}
+
+// 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 66a8efcc3..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,9 +43,11 @@ 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"
+ DaemonService_RenameProfile_FullMethodName = "/daemon.DaemonService/RenameProfile"
DaemonService_RemoveProfile_FullMethodName = "/daemon.DaemonService/RemoveProfile"
DaemonService_ListProfiles_FullMethodName = "/daemon.DaemonService/ListProfiles"
DaemonService_GetActiveProfile_FullMethodName = "/daemon.DaemonService/GetActiveProfile"
@@ -54,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.
@@ -73,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.
@@ -109,9 +121,14 @@ 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)
+ RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error)
RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error)
ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error)
GetActiveProfile(ctx context.Context, in *GetActiveProfileRequest, opts ...grpc.CallOption) (*GetActiveProfileResponse, error)
@@ -127,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
@@ -134,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 {
@@ -184,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)
@@ -326,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
}
@@ -365,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
}
@@ -392,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)
@@ -422,6 +488,16 @@ func (c *daemonServiceClient) AddProfile(ctx context.Context, in *AddProfileRequ
return out, nil
}
+func (c *daemonServiceClient) RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(RenameProfileResponse)
+ err := c.cc.Invoke(ctx, DaemonService_RenameProfile_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
func (c *daemonServiceClient) RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RemoveProfileResponse)
@@ -512,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)
@@ -544,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
}
@@ -561,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.
@@ -574,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.
@@ -610,9 +731,14 @@ 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)
+ RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error)
RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error)
ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error)
GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error)
@@ -628,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
@@ -635,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()
}
@@ -657,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")
}
@@ -714,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")
}
@@ -723,6 +875,9 @@ func (UnimplementedDaemonServiceServer) SetConfig(context.Context, *SetConfigReq
func (UnimplementedDaemonServiceServer) AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AddProfile not implemented")
}
+func (UnimplementedDaemonServiceServer) RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error) {
+ return nil, status.Error(codes.Unimplemented, "method RenameProfile not implemented")
+}
func (UnimplementedDaemonServiceServer) RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RemoveProfile not implemented")
}
@@ -750,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")
}
@@ -762,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() {}
@@ -855,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 {
@@ -1183,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 {
@@ -1237,6 +1433,24 @@ func _DaemonService_AddProfile_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
+func _DaemonService_RenameProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(RenameProfileRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DaemonServiceServer).RenameProfile(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DaemonService_RenameProfile_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DaemonServiceServer).RenameProfile(ctx, req.(*RenameProfileRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
func _DaemonService_RemoveProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveProfileRequest)
if err := dec(in); err != nil {
@@ -1399,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 {
@@ -1464,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)
@@ -1555,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,
@@ -1567,6 +1857,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "AddProfile",
Handler: _DaemonService_AddProfile_Handler,
},
+ {
+ MethodName: "RenameProfile",
+ Handler: _DaemonService_RenameProfile_Handler,
+ },
{
MethodName: "RemoveProfile",
Handler: _DaemonService_RemoveProfile_Handler,
@@ -1603,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,
@@ -1615,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 e659cef90..cea8ae912 100755
--- a/client/proto/generate.sh
+++ b/client/proto/generate.sh
@@ -1,17 +1,22 @@
#!/bin/bash
set -e
-if ! which realpath > /dev/null 2>&1
-then
- echo realpath is not installed
- echo run: brew install coreutils
- exit 1
+if ! which realpath >/dev/null 2>&1; then
+ echo realpath is not installed
+ echo run: brew install coreutils
+ exit 1
fi
old_pwd=$(pwd)
-script_path=$(dirname $(realpath "$0"))
+script_path=$(dirname "$(realpath "$0")")
cd "$script_path"
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6
-go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
-protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional
+go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1
+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 33247db5f..60a401b0e 100644
--- a/client/server/debug.go
+++ b/client/server/debug.go
@@ -7,17 +7,62 @@ 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/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()
@@ -52,7 +97,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)
}
}
}
@@ -63,10 +111,14 @@ 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,
ClientMetrics: clientMetrics,
+ DaemonVersion: version.NetbirdVersion(),
+ CliVersion: req.CliVersion,
},
debug.BundleConfig{
Anonymize: req.GetAnonymize(),
@@ -75,23 +127,16 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
},
)
- 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.
@@ -121,9 +166,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/login_overrides_test.go b/client/server/login_overrides_test.go
index c45557c59..5a2298764 100644
--- a/client/server/login_overrides_test.go
+++ b/client/server/login_overrides_test.go
@@ -79,7 +79,7 @@ func TestPersistLoginOverrides(t *testing.T) {
_, err := profilemanager.UpdateOrCreateConfig(seed)
require.NoError(t, err, "seed config")
- activeProf := &profilemanager.ActiveProfileState{Name: "default"}
+ activeProf := &profilemanager.ActiveProfileState{ID: "default"}
err = persistLoginOverrides(activeProf, tt.newMgmtURL, tt.newPSK)
require.NoError(t, err, "persistLoginOverrides")
diff --git a/client/server/mdm.go b/client/server/mdm.go
new file mode 100644
index 000000000..9836c6bea
--- /dev/null
+++ b/client/server/mdm.go
@@ -0,0 +1,451 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/mdm"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// preSharedKeyRedactedSentinel is the value GetConfig returns in place
+// of an actual PSK, so a UI that round-trips the field back to the
+// daemon (via SetConfig / Login) can be distinguished from a deliberate
+// override. Any incoming PSK that equals this sentinel is treated as
+// a no-op echo, never as a conflict with the policy.
+const preSharedKeyRedactedSentinel = "**********"
+
+// loadMDMPolicy is the indirection used by server handlers to read the
+// active MDM policy. Tests override this to inject a fake policy.
+var loadMDMPolicy = mdm.LoadPolicy
+
+// conflictCheck is a value-aware comparison between a single field in
+// the incoming request and the corresponding MDM-enforced value. It
+// runs only when the field was actually set in the request (presence
+// already filtered upstream); ok=true reports the policy value, ok=false
+// means the policy is silent on the key — both are treated as conflicts
+// to be safe (an MDM key declared as managed must hold a value).
+type conflictCheck struct {
+ key string
+ check func(*mdm.Policy) (match bool)
+}
+
+// onMDMPolicyChange is invoked by the MDM reload ticker every time the
+// OS-native managed-config store reports a diff vs the last observation.
+//
+// Restart sequence:
+// 1. Cancel the active engine context (terminates connectWithRetryRuns).
+// 2. Wait briefly for that goroutine to exit (giveUpChan is closed on exit).
+// 3. Re-resolve Config from disk + MDM policy (Config.apply re-runs
+// applyMDMPolicy with the freshly loaded Policy).
+// 4. Spawn a fresh connectWithRetryRuns with the new context and config.
+// 5. Broadcast a SystemEvent so any GUI / CLI subscriber (SubscribeEvents
+// RPC) can refresh its cached config view without polling.
+//
+// The callback runs in the ticker's own goroutine. Ticker has already
+// logged the per-key diff before invoking this hook.
+func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error {
+ log.Warn("MDM policy changed; restarting engine to apply new configuration")
+
+ // Hold s.mutex for the entire restart sequence (cancel + quiescence
+ // wait + re-spawn). Any concurrent Up/Down/Status arriving while
+ // MDM is restarting blocks on the Lock until we are done — they
+ // then observe the post-restart state coherently. This is safe
+ // because the connectWithRetryRuns goroutine no longer acquires
+ // s.mutex in its defer (intent vs. goroutine-alive concerns are
+ // fully separated; see the connectionGoroutineRunning helper).
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ if !s.clientRunning {
+ // The client is not running, so there's no engine to restart.
+ return nil
+ }
+ if s.actCancel != nil {
+ s.actCancel()
+ }
+
+ // Wait for previous connectWithRetryRuns to exit so we don't end up
+ // with two goroutines fighting over the same status recorder + engine.
+ // The teardown engages a fan-out of engine goroutines (peer workers,
+ // signal handler, route manager, ...). close(clientGiveUpChan)
+ // happens in the function-scope defer of connectWithRetryRuns, on
+ // every exit path (ctx cancel, backoff exhausted, panic) — see the
+ // defer in server.go.
+ if s.clientGiveUpChan != nil {
+ select {
+ case <-s.clientGiveUpChan:
+ case <-time.After(10 * time.Second):
+ return fmt.Errorf("failed to restart the engine due to timeout")
+ }
+ }
+
+ if err := s.restartEngineForMDMLocked(); err != nil {
+ log.Errorf("MDM restart failed: %v", err)
+ return err
+ }
+
+ // publishConfigChangedEvent has already fired inside
+ // restartEngineForMDMLocked with source="mdm". Emit an MDM-specific
+ // user-visible toast so the operator knows their IT policy was
+ // applied (UserMessage != "" triggers the GUI notifier).
+ s.statusRecorder.PublishEvent(
+ proto.SystemEvent_INFO,
+ proto.SystemEvent_SYSTEM,
+ "MDM policy applied",
+ "NetBird configuration was updated by your IT policy.",
+ map[string]string{
+ proto.MetadataSourceKey: proto.MetadataSourceMDM,
+ proto.MetadataTypeKey: proto.MetadataTypePolicyApplied,
+ },
+ )
+ return nil
+}
+
+// publishConfigChangedEvent broadcasts a SystemEvent informing any active
+// SubscribeEvents subscriber (typically the GUI tray) that the daemon's
+// effective Config has been replaced and any cached client-side view
+// should be refreshed. Callers pass a stable `source` label so the GUI
+// can distinguish a startup spawn from a user-triggered Up or an
+// MDM-driven restart. Reusing the SYSTEM category keeps the proto enum
+// stable; metadata.type="config_changed" routes to the GUI's refresh
+// handler. UserMessage is left empty so the system tray does not toast
+// for every internal restart; the MDM path emits a separate
+// "policy_applied" event (with UserMessage) for that purpose.
+func (s *Server) publishConfigChangedEvent(source string) {
+ if s.statusRecorder == nil {
+ return
+ }
+ s.statusRecorder.PublishEvent(
+ proto.SystemEvent_INFO,
+ proto.SystemEvent_SYSTEM,
+ fmt.Sprintf("daemon config changed (source=%s)", source),
+ "",
+ map[string]string{
+ proto.MetadataSourceKey: source,
+ proto.MetadataTypeKey: proto.MetadataTypeConfigChanged,
+ },
+ )
+}
+
+// restartEngineForMDMLocked re-resolves the active profile config
+// (re-running applyMDMPolicy via Config.apply) and re-spawns
+// connectWithRetryRuns. Mirrors the tail of Server.Start so a runtime
+// MDM change behaves identically to a fresh boot under the new policy.
+//
+// MUST be called with s.mutex held — onMDMPolicyChange holds the lock
+// for the entire restart sequence (cancel + quiescence wait + re-spawn)
+// so concurrent Up/Down/Status RPCs observe a coherent post-restart
+// state.
+func (s *Server) restartEngineForMDMLocked() error {
+ activeProf, err := s.profileManager.GetActiveProfileState()
+ if err != nil {
+ return fmt.Errorf("get active profile state: %w", err)
+ }
+ config, _, err := s.getConfig(activeProf)
+ if err != nil {
+ return fmt.Errorf("get active profile config: %w", err)
+ }
+
+ s.config = config
+ s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
+ s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
+
+ ctx, cancel := context.WithCancel(s.rootCtx)
+ s.actCancel = cancel
+ s.clientRunning = true
+ s.clientRunningChan = make(chan struct{})
+ 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(proto.MetadataSourceMDM)
+ return nil
+}
+
+// conflictBool builds a conflictCheck for a boolean MDM key. If p is nil
+// the field is treated as matching (no override requested); otherwise the
+// check returns true only when the policy contains the key and its
+// boolean value equals *p.
+func conflictBool(key string, p *bool) conflictCheck {
+ return conflictCheck{
+ key: key,
+ check: func(pol *mdm.Policy) bool {
+ if p == nil {
+ return true // absent → match by definition
+ }
+ want, ok := pol.GetBool(key)
+ return ok && want == *p
+ },
+ }
+}
+
+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
+// value equals got.
+func conflictString(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 && want == got
+ },
+ }
+}
+
+// conflictInt64 builds a conflictCheck for an integer MDM key. If p is
+// nil the field is treated as matching; otherwise the check returns
+// true only when the policy contains the key and its int value equals *p.
+func conflictInt64(key string, p *int64) conflictCheck {
+ return conflictCheck{
+ key: key,
+ check: func(pol *mdm.Policy) bool {
+ if p == nil {
+ return true
+ }
+ want, ok := pol.GetInt(key)
+ return ok && want == *p
+ },
+ }
+}
+
+// resolveConflicts walks the per-field checks against the active MDM
+// policy and returns the names of keys whose requested value diverges
+// from the policy-enforced value. Keys not present in the policy are
+// skipped silently (the gate fires only for keys the admin has
+// actually pushed). Returns nil for an empty policy.
+func resolveConflicts(policy *mdm.Policy, checks []conflictCheck) []string {
+ if policy.IsEmpty() {
+ return nil
+ }
+ var conflicts []string
+ for _, c := range checks {
+ if !policy.HasKey(c.key) {
+ continue
+ }
+ if !c.check(policy) {
+ conflicts = append(conflicts, c.key)
+ }
+ }
+ return conflicts
+}
+
+// mdmManagedFieldConflicts returns the names of MDM-managed keys whose
+// requested value in the SetConfigRequest differs from the MDM-enforced
+// value. A field set to the same value the policy already enforces is
+// treated as a no-op echo (the GUI tray sends a full Config snapshot on
+// every toggle, so most fields in a typical request match the policy
+// exactly and must NOT be flagged as conflicts). The redacted PSK
+// sentinel ("**********") returned by GetConfig is recognised and
+// treated as no-op so the UI can safely round-trip it.
+func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) []string {
+ if msg == nil {
+ return nil
+ }
+
+ // PSK round-trip echo: collapse the sentinel to empty so the
+ // shared check treats it as "field not set".
+ pskGot := ""
+ if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != preSharedKeyRedactedSentinel {
+ pskGot = *msg.OptionalPreSharedKey
+ }
+
+ return resolveConflicts(policy, []conflictCheck{
+ conflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
+ conflictString(mdm.KeyPreSharedKey, pskGot),
+ conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
+ conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
+ conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
+ conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
+ conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
+ conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
+ conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
+ conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
+ })
+}
+
+// setConfigRequestHasConfigOverrides reports whether the SetConfigRequest
+// carries ANY field that would actually mutate the persisted config.
+// The CLI builds a SetConfigRequest unconditionally on every
+// `netbird up` (see setupSetConfigReq in cmd/up.go) — a plain
+// `netbird up` produces a request with every field at its zero value;
+// the gate must skip such no-op invocations or it would always fire
+// even when the user did not pass any --flag. Returns false on a nil
+// msg; true when any management/admin URL, PSK, DNS/NAT list+clean
+// flag, interface/port/MTU, or any optional bool/duration field is set.
+func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
+ if msg == nil {
+ return false
+ }
+ return msg.ManagementUrl != "" ||
+ msg.AdminURL != "" ||
+ msg.OptionalPreSharedKey != nil ||
+ len(msg.CustomDNSAddress) > 0 ||
+ len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs ||
+ len(msg.ExtraIFaceBlacklist) > 0 ||
+ len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
+ msg.DnsRouteInterval != nil ||
+ msg.RosenpassEnabled != nil ||
+ msg.RosenpassPermissive != nil ||
+ msg.InterfaceName != nil ||
+ msg.WireguardPort != nil ||
+ msg.Mtu != nil ||
+ msg.DisableAutoConnect != nil ||
+ msg.ServerSSHAllowed != nil ||
+ msg.NetworkMonitor != nil ||
+ msg.DisableClientRoutes != nil ||
+ msg.DisableServerRoutes != nil ||
+ msg.DisableDns != nil ||
+ msg.DisableFirewall != nil ||
+ msg.BlockLanAccess != nil ||
+ msg.DisableNotifications != nil ||
+ msg.BlockInbound != nil ||
+ msg.DisableIpv6 != nil ||
+ msg.EnableSSHRoot != nil ||
+ msg.EnableSSHSFTP != nil ||
+ msg.EnableSSHLocalPortForwarding != nil ||
+ msg.EnableSSHRemotePortForwarding != nil ||
+ msg.DisableSSHAuth != nil ||
+ msg.SshJWTCacheTTL != nil
+}
+
+// loginRequestHasConfigOverrides reports whether the LoginRequest
+// carries ANY field that would mutate persisted daemon configuration
+// (as opposed to pure-auth fields like setupKey, hostname, hint,
+// profileName, username). Used by the Login handler to decide whether
+// the `--disable-update-settings` / MDM gates must run: a re-auth that
+// changes nothing about the configuration is always allowed.
+func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
+ if msg == nil {
+ return false
+ }
+ return msg.ManagementUrl != "" ||
+ msg.AdminURL != "" ||
+ msg.PreSharedKey != "" || //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
+ msg.OptionalPreSharedKey != nil ||
+ len(msg.CustomDNSAddress) > 0 ||
+ len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs ||
+ msg.RosenpassEnabled != nil ||
+ msg.InterfaceName != nil ||
+ msg.WireguardPort != nil ||
+ msg.DisableAutoConnect != nil ||
+ msg.ServerSSHAllowed != nil ||
+ msg.RosenpassPermissive != nil ||
+ len(msg.ExtraIFaceBlacklist) > 0 ||
+ msg.NetworkMonitor != nil ||
+ msg.DnsRouteInterval != nil ||
+ msg.DisableClientRoutes != nil ||
+ msg.DisableServerRoutes != nil ||
+ msg.DisableDns != nil ||
+ msg.DisableFirewall != nil ||
+ msg.BlockLanAccess != nil ||
+ msg.DisableNotifications != nil ||
+ len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
+ msg.BlockInbound != nil
+}
+
+// loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the
+// LoginRequest surface. Same value-aware semantics: a field set to the
+// MDM-enforced value is a no-op echo, not a conflict; only a divergent
+// value is flagged. PSK has two proto fields — PreSharedKey (deprecated)
+// and OptionalPreSharedKey (current); either route trips the gate if it
+// diverges from the MDM-enforced PSK. OptionalPreSharedKey wins when
+// both are set; the redaction sentinel ("**********") is accepted as
+// a no-op echo.
+func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []string {
+ if msg == nil {
+ return nil
+ }
+
+ // Collapse the two PSK fields + the redaction sentinel down to a
+ // single "got" string the shared check can compare against the
+ // policy: OptionalPreSharedKey wins if set; PreSharedKey (deprecated)
+ // is the fallback; sentinel echo is treated as "field not set".
+ pskGot := ""
+ if msg.OptionalPreSharedKey != nil {
+ pskGot = *msg.OptionalPreSharedKey
+ } else if msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
+ pskGot = msg.PreSharedKey //nolint:staticcheck // SA1019
+ }
+ if pskGot == preSharedKeyRedactedSentinel {
+ pskGot = ""
+ }
+
+ return resolveConflicts(policy, []conflictCheck{
+ conflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
+ conflictString(mdm.KeyPreSharedKey, pskGot),
+ conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
+ conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
+ conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
+ conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
+ conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
+ conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
+ conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
+ conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
+ })
+}
+
+// rejectMDMManagedFieldConflicts returns a FailedPrecondition gRPC error
+// with an MDMManagedFieldsViolation detail when any of the requested
+// fields tries to change an MDM-enforced value to something else, and
+// nil otherwise. The whole request is rejected on any conflict; non-
+// conflicting fields in the same request are not applied either (no
+// partial apply).
+func rejectMDMManagedFieldConflicts(conflicts []string) error {
+ if len(conflicts) == 0 {
+ return nil
+ }
+ log.Warnf("MDM rejected request: tried to modify %d managed key(s): %v",
+ len(conflicts), conflicts)
+ st := gstatus.New(
+ codes.FailedPrecondition,
+ fmt.Sprintf("fields managed by MDM cannot be modified: %v", conflicts),
+ )
+ detailed, err := st.WithDetails(&proto.MDMManagedFieldsViolation{Fields: conflicts})
+ if err != nil {
+ // Detail attachment is best-effort; fall back to the plain status
+ // so the caller still gets a usable FailedPrecondition.
+ return st.Err()
+ }
+ return detailed.Err()
+}
diff --git a/client/server/network.go b/client/server/network.go
index 12cefbd9c..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"
@@ -30,7 +29,7 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.networksDisabled {
+ if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
@@ -143,7 +142,7 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.networksDisabled {
+ if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
@@ -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,
@@ -195,7 +186,7 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.networksDisabled {
+ if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
@@ -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/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 397fb37e4..01778b8e0 100644
--- a/client/server/server.go
+++ b/client/server/server.go
@@ -19,11 +19,13 @@ 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"
"github.com/netbirdio/netbird/client/internal/profilemanager"
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
+ "github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/system"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -66,12 +68,39 @@ 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
proto.UnimplementedDaemonServiceServer
- clientRunning bool // protected by mutex
+ // clientRunning tracks "the daemon wants to be connected" — set true by
+ // Start / Up, cleared by Down / Logout. Persists across retry
+ // loops, signal disconnects, and ErrResetConnection cycles. NOT
+ // changed by connectWithRetryRuns goroutine exit — for that
+ // (goroutine-still-alive) check, see connectionGoroutineRunning() which
+ // derives from clientGiveUpChan close state. Protected by s.mutex.
+ clientRunning bool
clientRunningChan chan struct{}
clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits
@@ -80,7 +109,7 @@ type Server struct {
statusRecorder *peer.Status
sessionWatcher *internal.SessionWatcher
- lastProbe time.Time
+ probeThrottle *probeThrottle
persistSyncResponse bool
isSessionActive atomic.Bool
@@ -98,9 +127,21 @@ type Server struct {
sleepHandler *sleephandler.SleepHandler
+ // mdmTicker periodically re-reads the OS-native MDM policy and triggers
+ // an engine restart when the policy changes. Launched once by Start;
+ // stopped by the rootCtx cancellation.
+ mdmTicker *mdm.Ticker
+
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 {
@@ -123,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)
@@ -140,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)
}
@@ -155,6 +207,17 @@ func (s *Server) Start() error {
s.updateManager.CheckUpdateSuccess(s.rootCtx)
}
+ // MDM policy reload ticker: every minute the desktop daemon re-reads
+ // the OS-native managed-config store and, on diff vs the previous
+ // observation, cancels the active engine context so connectWithRetry-
+ // Runs re-resolves Config (re-running profilemanager.Config.apply which
+ // applies the freshly-read MDM policy as the last layer) and brings
+ // the engine back with the new values.
+ if s.mdmTicker == nil {
+ s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval)
+ go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange)
+ }
+
// if current state contains any error, return it
// in all other cases we can continue execution only if status is idle and up command was
// not in the progress or already successfully established connection.
@@ -191,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)
@@ -213,17 +275,31 @@ 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(proto.MetadataSourceStartup)
return nil
}
// connectWithRetryRuns runs the client connection with a backoff strategy where we retry the operation as additional
// mechanism to keep the client connected even when the connection is lost.
// we cancel retry if the client receive a stop or down command, or if disable auto connect is configured.
+//
+// The goroutine's exit is signalled to the daemon via close(giveUpChan)
+// — placed in the function-scope defer so every return path (panic,
+// DisableAutoConnect early-exit, backoff exhausted, ctx cancel) closes
+// it. Callers that need to observe "is the goroutine still alive?" use
+// Server.connectionGoroutineRunning() which non-blockingly checks the close state
+// of clientGiveUpChan. The defer does NOT touch s.mutex; the daemon's
+// "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() {
- s.mutex.Lock()
- s.clientRunning = false
- s.mutex.Unlock()
+ if giveUpChan != nil {
+ close(giveUpChan)
+ }
}()
if s.config.DisableAutoConnect {
@@ -258,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
}
@@ -269,13 +354,57 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil
if err := backoff.Retry(runOperation, backOff); err != nil {
log.Errorf("operation failed: %v", err)
}
+ // giveUpChan is closed by the function-scope defer.
+}
- if giveUpChan != nil {
- close(giveUpChan)
+// connectionGoroutineRunning reports whether the connectWithRetryRuns goroutine is
+// still running. Returns false when no goroutine has ever been started
+// AND when the most recent one has already closed clientGiveUpChan on
+// exit (whether due to ctx cancel, DisableAutoConnect single-shot
+// completion, or backoff retry exhaustion).
+//
+// MUST be called with s.mutex held — accesses s.clientGiveUpChan which
+// is written by Start/Up under the same lock.
+func (s *Server) connectionGoroutineRunning() bool {
+ if s.clientGiveUpChan == nil {
+ return false
+ }
+ select {
+ case <-s.clientGiveUpChan:
+ return false
+ default:
+ return true
}
}
-// 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 {
@@ -301,57 +430,106 @@ 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()
- if s.checkUpdateSettingsDisabled() {
- return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
+ // Skip the update-settings gate when the request carries no actual
+ // overrides: the CLI builds a SetConfigRequest unconditionally on
+ // every `netbird up` (setupSetConfigReq in cmd/up.go), so a plain
+ // `netbird up` would otherwise always trip the gate and surface a
+ // misleading "setConfig method is not available" warning, even when
+ // the user did not pass any config flag.
+ if setConfigRequestHasConfigOverrides(msg) {
+ if s.checkUpdateSettingsDisabled() {
+ return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
+ }
}
- profState := profilemanager.ActiveProfileState{
- Name: msg.ProfileName,
- Username: msg.Username,
+ // MDM gate: refuse the whole request if any of its fields is enforced
+ // by the active MDM policy. The error carries an MDMManagedFields-
+ // Violation detail listing the offending key names. Non-conflicting
+ // fields in the same request are not applied either.
+ policy := loadMDMPolicy()
+ if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil {
+ return nil, err
}
- profPath, err := profState.FilePath()
+ stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username)
if err != nil {
- log.Errorf("failed to get active profile file path: %v", err)
- return nil, fmt.Errorf("failed to get active profile file path: %w", err)
+ 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
+ }
+
+ if _, err := profilemanager.UpdateConfig(config); err != nil {
+ log.Errorf("failed to update profile config: %v", err)
+ return nil, fmt.Errorf("failed to update profile config: %w", err)
+ }
+
+ return &proto.SetConfigResponse{}, nil
+}
+
+// setConfigInputFromRequest translates a SetConfigRequest into the
+// profilemanager.ConfigInput that profilemanager.UpdateConfig consumes.
+// Pure mapping with no business logic beyond presence-aware copying of
+// optional fields and the "empty / clean" semantics for the two slice
+// fields (DNS labels, NAT external IPs). Extracted from SetConfig to
+// keep the handler's cognitive complexity below the SonarCube
+// threshold; the body is intentionally linear because each proto
+// field is its own optional case. Returns the resolved ConfigInput
+// and a non-nil error only when the active profile file path cannot
+// be determined.
+func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) {
var config profilemanager.ConfigInput
+ resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
+ if err != nil {
+ log.Errorf("failed to resolve profile %q: %v", msg.ProfileName, err)
+ return config, err
+ }
+ profPath := resolved.Path
+ if profPath == "" {
+ profPath = profilemanager.DefaultConfigPath
+ }
config.ConfigPath = profPath
if msg.ManagementUrl != "" {
config.ManagementURL = msg.ManagementUrl
}
-
if msg.AdminURL != "" {
config.AdminURL = msg.AdminURL
}
-
if msg.InterfaceName != nil {
config.InterfaceName = msg.InterfaceName
}
-
if msg.WireguardPort != nil {
wgPort := int(*msg.WireguardPort)
config.WireguardPort = &wgPort
}
-
if msg.OptionalPreSharedKey != nil {
- if *msg.OptionalPreSharedKey != "" {
- config.PreSharedKey = msg.OptionalPreSharedKey
- }
+ config.PreSharedKey = msg.OptionalPreSharedKey
}
if msg.CleanDNSLabels {
config.DNSLabels = domain.List{}
-
} else if msg.DnsLabels != nil {
- dnsLabels := domain.FromPunycodeList(msg.DnsLabels)
- config.DNSLabels = dnsLabels
+ config.DNSLabels = domain.FromPunycodeList(msg.DnsLabels)
}
if msg.CleanNATExternalIPs {
@@ -364,7 +542,6 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
if string(msg.CustomDNSAddress) == "empty" {
config.CustomDNSAddress = []byte{}
}
-
config.ExtraIFaceBlackList = msg.ExtraIFaceBlacklist
if msg.DnsRouteInterval != nil {
@@ -383,7 +560,6 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
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
@@ -397,38 +573,48 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
ttl := int(*msg.SshJWTCacheTTL)
config.SSHJWTCacheTTL = &ttl
}
-
if msg.Mtu != nil {
mtu := uint16(*msg.Mtu)
config.MTU = &mtu
}
-
- if _, err := profilemanager.UpdateConfig(config); err != nil {
- log.Errorf("failed to update profile config: %v", err)
- return nil, fmt.Errorf("failed to update profile config: %w", err)
- }
-
- return &proto.SetConfigResponse{}, nil
+ return config, nil
}
// Login uses setup key to prepare configuration for the daemon.
func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*proto.LoginResponse, error) {
- 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)
+ // Config-override gates. LoginRequest carries the same surface as
+ // SetConfigRequest (managementUrl, PSK, ssh/rosenpass/port toggles,
+ // ...), so the same protections must apply. Without these the CLI
+ // command `netbird up --management-url=X` (which falls through to
+ // Login when SetConfig is rejected — see cmd/up.go) would silently
+ // bypass `--disable-update-settings` and any MDM policy.
+ if loginRequestHasConfigOverrides(msg) {
+ if s.checkUpdateSettingsDisabled() {
+ return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
+ }
+ policy := loadMDMPolicy()
+ if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil {
+ return nil, err
+ }
}
- s.actCancel = cancel
- s.mutex.Unlock()
+ 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)
+ }
- 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)
@@ -439,47 +625,19 @@ 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)
+ // 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)
+ }
+ return nil, err
}
- if msg.ProfileName != nil {
- if *msg.ProfileName != "default" && (msg.Username == nil || *msg.Username == "") {
- log.Errorf("profile name is set to %s, but username is not provided", *msg.ProfileName)
- return nil, fmt.Errorf("profile name is set to %s, but username is not provided", *msg.ProfileName)
- }
-
- var username string
- if *msg.ProfileName != "default" {
- username = *msg.Username
- }
-
- if *msg.ProfileName != activeProf.Name && username != activeProf.Username {
- if s.checkProfilesDisabled() {
- log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled")
- return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
- }
-
- log.Infof("switching to profile %s for user '%s'", *msg.ProfileName, username)
- if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: *msg.ProfileName,
- Username: username,
- }); err != nil {
- log.Errorf("failed to set active profile state: %v", err)
- return nil, fmt.Errorf("failed to set active profile state: %w", err)
- }
- }
- }
-
- 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)
- }
-
- log.Infof("active profile: %s for %s", activeProf.Name, activeProf.Username)
+ log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username)
s.mutex.Lock()
@@ -490,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)
@@ -504,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 {
@@ -525,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,
@@ -561,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
}
@@ -569,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 {
@@ -578,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)
@@ -603,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
@@ -630,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
}
@@ -639,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
@@ -651,8 +898,15 @@ 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()
- if s.clientRunning {
+ // clientRunning is the daemon-intent flag (set by previous Up/Start, cleared
+ // by Down). connectionGoroutineRunning() reports whether the previous retry-loop
+ // goroutine is still trying. When intent is up AND goroutine is alive,
+ // the existing engine is on the job — just wait for it. When intent
+ // is up but the goroutine has given up (backoff exhausted) OR when
+ // intent is down, fall through to spawn a fresh retry loop.
+ if s.clientRunning && s.connectionGoroutineRunning() {
state := internal.CtxGetState(s.rootCtx)
status, err := state.Status()
if err != nil {
@@ -666,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)
}
@@ -681,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)
@@ -711,10 +981,10 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
}
if msg != nil && msg.ProfileName != nil {
- if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
+ if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
s.mutex.Unlock()
log.Errorf("failed to switch profile: %v", err)
- return nil, fmt.Errorf("failed to switch profile: %w", err)
+ return nil, err
}
}
@@ -725,7 +995,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
return nil, fmt.Errorf("failed to get active profile state: %w", err)
}
- log.Infof("active profile: %s for %s", activeProf.Name, activeProf.Username)
+ log.Infof("active profile: %s for %s", activeProf.ID, activeProf.Username)
config, _, err := s.getConfig(activeProf)
if err != nil {
@@ -743,8 +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(proto.MetadataSourceUpRPC)
s.mutex.Unlock()
+ if msg.GetAsync() {
+ return &proto.UpResponse{}, nil
+ }
return s.waitForUp(callerCtx)
}
@@ -768,34 +1042,117 @@ func (s *Server) waitForUp(callerCtx context.Context) (*proto.UpResponse, error)
}
}
-func (s *Server) switchProfileIfNeeded(profileName string, userName *string, activeProf *profilemanager.ActiveProfileState) error {
- if profileName != "default" && (userName == nil || *userName == "") {
- log.Errorf("profile name is set to %s, but username is not provided", profileName)
- return fmt.Errorf("profile name is set to %s, but username is not provided", profileName)
+// 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.
+func (s *Server) resolveProfileHandle(handle, username string) (*profilemanager.Profile, error) {
+ p, err := s.profileManager.ResolveProfile(handle, username)
+ if err == nil {
+ return p, nil
+ }
+ var amb *profilemanager.ErrAmbiguousHandle
+ if errors.As(err, &amb) {
+ return nil, gstatus.Errorf(codes.InvalidArgument, "%v", amb)
+ }
+ if errors.Is(err, profilemanager.ErrProfileNotFound) {
+ return nil, gstatus.Errorf(codes.NotFound, "profile %q not found", handle)
+ }
+ return nil, fmt.Errorf("resolve profile: %w", err)
+}
+
+// switchProfileIfNeeded resolves the user-supplied handle, updates the
+// active profile state if it differs from the current one, and returns
+// the resolved profile so callers can include its ID in RPC responses.
+func (s *Server) switchProfileIfNeeded(handle string, userName *string, activeProf *profilemanager.ActiveProfileState) (*profilemanager.Profile, error) {
+ if handle != profilemanager.DefaultProfileName && (userName == nil || *userName == "") {
+ log.Errorf("profile name is set to %s, but username is not provided", handle)
+ return nil, fmt.Errorf("profile name is set to %s, but username is not provided", handle)
}
var username string
- if profileName != "default" {
+ if handle != profilemanager.DefaultProfileName {
username = *userName
}
- if profileName != activeProf.Name || username != activeProf.Username {
+ resolved, err := s.resolveProfileHandle(handle, username)
+ if err != nil {
+ return nil, err
+ }
+
+ if resolved.ID != activeProf.ID || username != activeProf.Username {
if s.checkProfilesDisabled() {
log.Errorf("profiles are disabled, you cannot use this feature without profiles enabled")
- return gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
+ return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
}
- log.Infof("switching to profile %s for user %s", profileName, username)
+ log.Infof("switching to profile %s (%s) for user %s", resolved.Name, resolved.ID, username)
if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: profileName,
+ ID: resolved.ID,
Username: username,
}); err != nil {
log.Errorf("failed to set active profile state: %v", err)
- return fmt.Errorf("failed to set active profile state: %w", err)
+ return nil, fmt.Errorf("failed to set active profile state: %w", err)
}
}
- return nil
+ return resolved, nil
}
// SwitchProfile switches the active profile in the daemon.
@@ -810,9 +1167,9 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
}
if msg != nil && msg.ProfileName != nil {
- if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
+ if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
log.Errorf("failed to switch profile: %v", err)
- return nil, fmt.Errorf("failed to switch profile: %w", err)
+ return nil, err
}
}
activeProf, err = s.profileManager.GetActiveProfileState()
@@ -828,7 +1185,11 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
s.config = config
- return &proto.SwitchProfileResponse{}, nil
+ if msg != nil && msg.ProfileName != nil {
+ s.publishProfileListChanged(*msg.ProfileName)
+ }
+
+ return &proto.SwitchProfileResponse{Id: activeProf.ID.String()}, nil
}
// Down engine work in the daemon.
@@ -839,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
}
@@ -871,6 +1249,12 @@ func (s *Server) cleanupConnection() error {
return ErrServiceNotUp
}
+ // Daemon intent flips to "down" — all callers (Down RPC,
+ // Logout RPC handlers) tear down the connection because the user
+ // explicitly asked for it. MDM restart does NOT go through this
+ // path, so its clientRunning stays true.
+ s.clientRunning = false
+
// Capture the engine reference before cancelling the context.
// After actCancel(), the connectWithRetryRuns goroutine wakes up
// and sets connectClient.engine = nil, causing connectClient.Stop()
@@ -886,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)
}
}
@@ -912,22 +1300,33 @@ func (s *Server) Logout(ctx context.Context, msg *proto.LogoutRequest) (*proto.L
}
func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutRequest) (*proto.LogoutResponse, error) {
- if err := s.validateProfileOperation(*msg.ProfileName, true); err != nil {
- return nil, err
- }
-
if msg.Username == nil || *msg.Username == "" {
return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided when profile name is specified")
}
username := *msg.Username
- if err := s.logoutFromProfile(ctx, *msg.ProfileName, username); err != nil {
- log.Errorf("failed to logout from profile %s: %v", *msg.ProfileName, err)
+ resolved, err := s.resolveProfileHandle(*msg.ProfileName, username)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := s.validateProfileOperation(resolved.ID, true); err != nil {
+ return nil, err
+ }
+
+ if err := s.logoutFromProfile(ctx, resolved); err != nil {
+ log.Errorf("failed to logout from profile %s: %v", resolved.ID, err)
+ // 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)
}
activeProf, _ := s.profileManager.GetActiveProfileState()
- if activeProf != nil && activeProf.Name == *msg.ProfileName {
+ if activeProf != nil && activeProf.ID == resolved.ID {
if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) {
log.Errorf("failed to cleanup connection: %v", err)
}
@@ -989,30 +1388,30 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof
return config, configExisted, nil
}
-func (s *Server) canRemoveProfile(profileName string) error {
- if profileName == profilemanager.DefaultProfileName {
+func (s *Server) canRemoveProfile(id profilemanager.ID) error {
+ if id == profilemanager.DefaultProfileName {
return fmt.Errorf("remove profile with reserved name: %s", profilemanager.DefaultProfileName)
}
activeProf, err := s.profileManager.GetActiveProfileState()
- if err == nil && activeProf.Name == profileName {
- return fmt.Errorf("remove active profile: %s", profileName)
+ if err == nil && activeProf.ID == id {
+ return fmt.Errorf("remove active profile: %s", id)
}
return nil
}
-func (s *Server) validateProfileOperation(profileName string, allowActiveProfile bool) error {
+func (s *Server) validateProfileOperation(id profilemanager.ID, allowActiveProfile bool) error {
if s.checkProfilesDisabled() {
return gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
}
- if profileName == "" {
+ if id == "" {
return gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
}
if !allowActiveProfile {
- if err := s.canRemoveProfile(profileName); err != nil {
+ if err := s.canRemoveProfile(id); err != nil {
return gstatus.Errorf(codes.InvalidArgument, "%v", err)
}
}
@@ -1020,25 +1419,20 @@ func (s *Server) validateProfileOperation(profileName string, allowActiveProfile
return nil
}
-// logoutFromProfile logs out from a specific profile by loading its config and sending logout request
-func (s *Server) logoutFromProfile(ctx context.Context, profileName, username string) error {
+func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile) error {
activeProf, err := s.profileManager.GetActiveProfileState()
- if err == nil && activeProf.Name == profileName && s.connectClient != nil {
+ if err == nil && activeProf.ID == profile.ID && s.connectClient != nil {
return s.sendLogoutRequest(ctx)
}
- profileState := &profilemanager.ActiveProfileState{
- Name: profileName,
- Username: username,
- }
- profilePath, err := profileState.FilePath()
- if err != nil {
- return fmt.Errorf("get profile path: %w", err)
+ cfgPath := profile.Path
+ if cfgPath == "" {
+ cfgPath = profilemanager.DefaultConfigPath
}
- config, err := profilemanager.GetConfig(profilePath)
+ config, err := profilemanager.GetConfig(cfgPath)
if err != nil {
- return fmt.Errorf("profile '%s' not found", profileName)
+ return fmt.Errorf("profile '%s' not found", profile.ID)
}
return s.sendLogoutRequestWithConfig(ctx, config)
@@ -1049,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)
@@ -1065,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
@@ -1074,10 +1487,14 @@ func (s *Server) Status(
msg *proto.StatusRequest,
) (*proto.StatusResponse, error) {
s.mutex.Lock()
- clientRunning := s.clientRunning
+ // Only wait if the retry-loop goroutine is alive and making
+ // progress. clientRunning=true with connectionGoroutineRunning=false means the
+ // backoff has given up — there is nothing to wait for; let the
+ // caller observe the failed status directly.
+ alive := s.connectionGoroutineRunning()
s.mutex.Unlock()
- if msg.WaitForReady != nil && *msg.WaitForReady && clientRunning {
+ if msg.WaitForReady != nil && *msg.WaitForReady && alive {
state := internal.CtxGetState(s.rootCtx)
status, err := state.Status()
if err != nil {
@@ -1114,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() {
@@ -1127,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
}
@@ -1356,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()
+ }
+
+ isDesktop := isUnixRunningDesktop()
+ oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, 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()
@@ -1422,7 +2007,7 @@ func isUnixRunningDesktop() bool {
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
}
@@ -1432,15 +2017,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.
@@ -1452,15 +2029,14 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
return nil, ctx.Err()
}
- prof := profilemanager.ActiveProfileState{
- Name: req.ProfileName,
- Username: req.Username,
- }
-
- cfgPath, err := prof.FilePath()
+ resolved, err := s.resolveProfileHandle(req.ProfileName, req.Username)
if err != nil {
- log.Errorf("failed to get active profile file path: %v", err)
- return nil, fmt.Errorf("failed to get active profile file path: %w", err)
+ log.Errorf("failed to resolve profile %q: %v", req.ProfileName, err)
+ return nil, err
+ }
+ cfgPath := resolved.Path
+ if cfgPath == "" {
+ cfgPath = profilemanager.DefaultConfigPath
}
cfg, err := profilemanager.GetConfig(cfgPath)
@@ -1533,7 +2109,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,
@@ -1548,6 +2123,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
EnableSSHRemotePortForwarding: enableSSHRemotePortForwarding,
DisableSSHAuth: disableSSHAuth,
SshJWTCacheTTL: sshJWTCacheTTL,
+ MDMManagedFields: cfg.Policy().ManagedKeys(),
}, nil
}
@@ -1564,12 +2140,43 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and username must be provided")
}
- if err := s.profileManager.AddProfile(msg.ProfileName, msg.Username); err != nil {
+ created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username)
+ if err != nil {
log.Errorf("failed to create profile: %v", err)
return nil, fmt.Errorf("failed to create profile: %w", err)
}
- return &proto.AddProfileResponse{}, nil
+ s.publishProfileListChanged(msg.ProfileName)
+
+ return &proto.AddProfileResponse{Id: created.ID.String()}, nil
+}
+
+func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequest) (*proto.RenameProfileResponse, error) {
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ if s.checkProfilesDisabled() {
+ return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
+ }
+
+ if msg.Handle == "" || msg.Username == "" || msg.NewProfileName == "" {
+ return nil, gstatus.Errorf(codes.InvalidArgument, "profile name, username and new profile name must be provided")
+ }
+
+ resolved, err := s.resolveProfileHandle(msg.Handle, msg.Username)
+ if err != nil {
+ return nil, err
+ }
+
+ err = s.profileManager.RenameProfile(resolved.ID, msg.Username, msg.NewProfileName)
+ if err != nil {
+ log.Errorf("failed to rename profile: %v", err)
+ return nil, fmt.Errorf("failed to rename profile: %w", err)
+ }
+
+ s.publishProfileListChanged(msg.NewProfileName)
+
+ return &proto.RenameProfileResponse{OldProfileName: resolved.Name}, nil
}
// RemoveProfile removes a profile from the daemon.
@@ -1577,20 +2184,74 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
s.mutex.Lock()
defer s.mutex.Unlock()
- if err := s.validateProfileOperation(msg.ProfileName, false); err != nil {
+ if s.checkProfilesDisabled() {
+ return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
+ }
+
+ if msg.ProfileName == "" {
+ return nil, gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
+ }
+
+ resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
+ if err != nil {
return nil, err
}
- if err := s.logoutFromProfile(ctx, msg.ProfileName, msg.Username); err != nil {
- log.Warnf("failed to logout from profile %s before removal: %v", msg.ProfileName, err)
+ if err := s.logoutFromProfile(ctx, resolved); err != nil {
+ // 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(msg.ProfileName, msg.Username); err != nil {
+ if err := s.profileManager.RemoveProfile(resolved.ID, msg.Username); err != nil {
log.Errorf("failed to remove profile: %v", err)
return nil, fmt.Errorf("failed to remove profile: %w", err)
}
- return &proto.RemoveProfileResponse{}, nil
+ 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.
@@ -1613,6 +2274,7 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques
}
for i, profile := range profiles {
response.Profiles[i] = &proto.Profile{
+ Id: profile.ID.String(),
Name: profile.Name,
IsActive: profile.IsActive,
}
@@ -1621,7 +2283,9 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques
return response, nil
}
-// GetActiveProfile returns the active profile in the daemon.
+// GetActiveProfile returns the active profile in the daemon. The ProfileName
+// field carries the display name for backwards compatibility with UI clients,
+// new callers should prefer Id.
func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfileRequest) (*proto.GetActiveProfileResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -1632,9 +2296,23 @@ func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfi
return nil, fmt.Errorf("failed to get active profile state: %w", err)
}
+ // Fallback to legacy name == ID
+ displayName := activeProfile.ID.String()
+ if activeProfile.ID != profilemanager.DefaultProfileName {
+ if profiles, lerr := s.profileManager.ListProfiles(activeProfile.Username); lerr == nil {
+ for _, p := range profiles {
+ if p.ID == activeProfile.ID {
+ displayName = p.Name
+ break
+ }
+ }
+ }
+ }
+
return &proto.GetActiveProfileResponse{
- ProfileName: activeProfile.Name,
+ ProfileName: displayName,
Username: activeProfile.Username,
+ Id: activeProfile.ID.String(),
}, nil
}
@@ -1646,12 +2324,34 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest)
features := &proto.GetFeaturesResponse{
DisableProfiles: s.checkProfilesDisabled(),
DisableUpdateSettings: s.checkUpdateSettingsDisabled(),
- DisableNetworks: s.networksDisabled,
+ 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)
@@ -1668,22 +2368,46 @@ func (s *Server) connect(ctx context.Context, config *profilemanager.Config, sta
return nil
}
+// MDM authority: when the platform-native MDM source sets a kill switch
+// key (regardless of true/false value), that value wins. The CLI flag
+// supplied at service install time is the fallback used only when the
+// MDM source is silent on the key. This honors the "MDM decides
+// everything" semantic agreed for NET-1214 — an admin pushing
+// disableX=false via MDM explicitly re-enables the feature even on a
+// box installed with --disable-X.
func (s *Server) checkProfilesDisabled() bool {
- // Check if the environment variable is set to disable profiles
- if s.profilesDisabled {
- return true
+ if s.config != nil {
+ if v, ok := s.config.Policy().GetBool(mdm.KeyDisableProfiles); ok {
+ return v
+ }
}
+ return s.profilesDisabled
+}
- return false
+// checkNetworksDisabled reports whether the networks/exit-node feature
+// is disabled on this daemon instance. Resolved MDM-first: when the
+// active policy declares mdm.KeyDisableNetworks the policy value wins
+// (regardless of true/false), so an admin can re-enable the feature
+// via MDM even on a host that was installed with --disable-networks.
+// Falls back to the s.networksDisabled CLI flag when the policy is
+// silent on the key. Mirrors checkProfilesDisabled and
+// checkUpdateSettingsDisabled.
+func (s *Server) checkNetworksDisabled() bool {
+ if s.config != nil {
+ if v, ok := s.config.Policy().GetBool(mdm.KeyDisableNetworks); ok {
+ return v
+ }
+ }
+ return s.networksDisabled
}
func (s *Server) checkUpdateSettingsDisabled() bool {
- // Check if the environment variable is set to disable profiles
- if s.updateSettingsDisabled {
- return true
+ if s.config != nil {
+ if v, ok := s.config.Policy().GetBool(mdm.KeyDisableUpdateSettings); ok {
+ return v
+ }
}
-
- return false
+ return s.updateSettingsDisabled
}
func (s *Server) startUpdateManagerForGUI() {
@@ -1774,6 +2498,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
@@ -1797,3 +2584,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_connect_test.go b/client/server/server_connect_test.go
index faea7da39..0c6e03a4a 100644
--- a/client/server/server_connect_test.go
+++ b/client/server/server_connect_test.go
@@ -101,6 +101,7 @@ func TestCleanupConnection_ClearsConnectClient(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
+ assert.False(t, s.clientRunning, "clientRunning should be cleared after cleanup (intent = down)")
}
// TestCleanState_NilConnectClient validates that CleanState doesn't panic
@@ -144,17 +145,20 @@ func TestDownThenUp_StaleRunningChan(t *testing.T) {
_, cancel := context.WithCancel(context.Background())
s.actCancel = cancel
- // Simulate Down(): cleanupConnection sets connectClient = nil
+ // Simulate Down(): cleanupConnection sets connectClient = nil and
+ // flips clientRunning to false (intent = down). The connectionGoroutineRunning state
+ // remains independent of intent — derived from clientGiveUpChan.
s.mutex.Lock()
err := s.cleanupConnection()
s.mutex.Unlock()
require.NoError(t, err)
- // After cleanup: connectClient is nil, clientRunning still true
- // (goroutine hasn't exited yet)
+ // After cleanup: connectClient is nil, clientRunning is false (intent
+ // cleared by cleanupConnection), connectionGoroutineRunning may still be true
+ // (goroutine teardown is independent of the intent flag).
s.mutex.Lock()
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
- assert.True(t, s.clientRunning, "clientRunning still true until goroutine exits")
+ assert.False(t, s.clientRunning, "clientRunning should be cleared by cleanupConnection (intent = down)")
s.mutex.Unlock()
// waitForUp() returns immediately due to stale closed clientRunningChan
diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go
new file mode 100644
index 000000000..8b6f78f04
--- /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"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
+
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+ "github.com/netbirdio/netbird/management/server/job"
+
+ "github.com/netbirdio/netbird/management/internals/server/config"
+ "github.com/netbirdio/netbird/management/server/groups"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/keepalive"
+
+ "github.com/netbirdio/netbird/client/internal"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/management/server"
+ "github.com/netbirdio/netbird/management/server/activity"
+ nbcache "github.com/netbirdio/netbird/management/server/cache"
+ "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/settings"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/telemetry"
+ mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
+ "github.com/netbirdio/netbird/shared/signal/proto"
+ signalServer "github.com/netbirdio/netbird/signal/server"
+)
+
+var (
+ kaep = keepalive.EnforcementPolicy{
+ MinTime: 15 * time.Second,
+ PermitWithoutStream: true,
+ }
+
+ kasp = keepalive.ServerParameters{
+ MaxConnectionIdle: 15 * time.Second,
+ MaxConnectionAgeGrace: 5 * time.Second,
+ Time: 5 * time.Second,
+ Timeout: 2 * time.Second,
+ }
+)
+
+// 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 641cd85fe..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/management-integrations/integrations"
-
- "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
- "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
- "github.com/netbirdio/netbird/management/internals/modules/peers"
- "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
- nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
- "github.com/netbirdio/netbird/management/server/job"
-
- "github.com/netbirdio/netbird/management/internals/server/config"
- "github.com/netbirdio/netbird/management/server/groups"
-
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
"google.golang.org/grpc"
- "google.golang.org/grpc/keepalive"
"github.com/netbirdio/netbird/client/internal"
- "github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
daemonProto "github.com/netbirdio/netbird/client/proto"
- "github.com/netbirdio/netbird/management/server"
- "github.com/netbirdio/netbird/management/server/activity"
- nbcache "github.com/netbirdio/netbird/management/server/cache"
- "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
- "github.com/netbirdio/netbird/management/server/permissions"
- "github.com/netbirdio/netbird/management/server/settings"
- "github.com/netbirdio/netbird/management/server/store"
- "github.com/netbirdio/netbird/management/server/telemetry"
- mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
- "github.com/netbirdio/netbird/shared/signal/proto"
- signalServer "github.com/netbirdio/netbird/signal/server"
)
-var (
- kaep = keepalive.EnforcementPolicy{
- MinTime: 15 * time.Second,
- PermitWithoutStream: true,
- }
-
- kasp = keepalive.ServerParameters{
- MaxConnectionIdle: 15 * time.Second,
- MaxConnectionAgeGrace: 5 * time.Second,
- Time: 5 * time.Second,
- Timeout: 2 * time.Second,
- }
-)
-
-// TestConnectWithRetryRuns checks that the connectWithRetry function runs and runs the retries according to the times specified via environment variables
-// we will use a management server started via to simulate the server and capture the number of retries
-func TestConnectWithRetryRuns(t *testing.T) {
- // start the signal server
- _, signalAddr, err := startSignal(t)
- if err != nil {
- t.Fatalf("failed to start signal server: %v", err)
- }
-
- counter := 0
- // start the management server
- _, mgmtAddr, err := startManagement(t, signalAddr, &counter)
- if err != nil {
- t.Fatalf("failed to start management server: %v", err)
- }
-
- ctx := internal.CtxInitState(context.Background())
-
- ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second))
- defer cancel()
- // create new server
- ic := profilemanager.ConfigInput{
- ManagementURL: "http://" + mgmtAddr,
- ConfigPath: t.TempDir() + "/test-profile.json",
- }
-
- config, err := profilemanager.UpdateOrCreateConfig(ic)
- if err != nil {
- t.Fatalf("failed to create config: %v", err)
- }
-
- currUser, err := user.Current()
- require.NoError(t, err)
-
- pm := profilemanager.ServiceManager{}
- err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: "test-profile",
- Username: currUser.Username,
- })
- if err != nil {
- t.Fatalf("failed to set active profile state: %v", err)
- }
-
- s := New(ctx, "debug", "", false, false, false, false)
-
- s.config = config
-
- s.statusRecorder = peer.NewRecorder(config.ManagementURL.String())
- t.Setenv(retryInitialIntervalVar, "1s")
- t.Setenv(maxRetryIntervalVar, "2s")
- t.Setenv(maxRetryTimeVar, "5s")
- t.Setenv(retryMultiplierVar, "1")
-
- s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil)
- if counter < 3 {
- t.Fatalf("expected counter > 2, got %d", counter)
- }
-}
-
func TestServer_Up(t *testing.T) {
tempDir := t.TempDir()
origDefaultProfileDir := profilemanager.DefaultConfigPathDir
@@ -158,7 +56,7 @@ func TestServer_Up(t *testing.T) {
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: profName,
+ ID: profilemanager.ID(profName),
Username: currUser.Username,
})
if err != nil {
@@ -228,7 +126,7 @@ func TestServer_SubcribeEvents(t *testing.T) {
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: "default",
+ ID: "default",
Username: currUser.Username,
})
if err != nil {
@@ -259,119 +157,3 @@ func TestServer_SubcribeEvents(t *testing.T) {
assert.NoError(t, err)
}
-
-type mockServer struct {
- mgmtProto.ManagementServiceServer
- counter *int
-}
-
-func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) {
- *m.counter++
- return m.ManagementServiceServer.Login(ctx, req)
-}
-
-func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) {
- t.Helper()
- dataDir := t.TempDir()
-
- config := &config.Config{
- Stuns: []*config.Host{},
- TURNConfig: &config.TURNConfig{},
- Signal: &config.Host{
- Proto: "http",
- URI: signalAddr,
- },
- Datadir: dataDir,
- HttpConfig: nil,
- }
-
- lis, err := net.Listen("tcp", "localhost:0")
- if err != nil {
- return nil, "", err
- }
- s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
- store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir)
- if err != nil {
- return nil, "", err
- }
- t.Cleanup(cleanUp)
-
- eventStore := &activity.InMemoryEventStore{}
- if err != nil {
- return nil, "", err
- }
-
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
-
- permissionsManagerMock := permissions.NewMockManager(ctrl)
- peersManager := peers.NewManager(store, permissionsManagerMock)
- settingsManagerMock := settings.NewMockManager(ctrl)
-
- jobManager := job.NewJobManager(nil, store, peersManager)
-
- cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
- if err != nil {
- return nil, "", err
- }
-
- ia, _ := integrations.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
new file mode 100644
index 000000000..ae323ea8c
--- /dev/null
+++ b/client/server/setconfig_mdm_test.go
@@ -0,0 +1,239 @@
+package server
+
+import (
+ "context"
+ "os/user"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/mdm"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// withMDMPolicy temporarily overrides the server-package loadMDMPolicy hook
+// so SetConfig observes the supplied Policy. Restores the original loader
+// at test cleanup.
+func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
+ t.Helper()
+ prev := loadMDMPolicy
+ loadMDMPolicy = func() *mdm.Policy { return policy }
+ t.Cleanup(func() { loadMDMPolicy = prev })
+}
+
+// setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved:
+// overrides profilemanager paths to a temp dir, seeds a profile, sets it
+// active, and constructs a Server instance. Returns the constructed server
+// plus context + profile name + username + cfgPath for the seeded profile.
+func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profName, username, cfgPath string) {
+ t.Helper()
+ tempDir := t.TempDir()
+
+ origDefaultProfileDir := profilemanager.DefaultConfigPathDir
+ origDefaultConfigPath := profilemanager.DefaultConfigPath
+ origActiveProfileStatePath := profilemanager.ActiveProfileStatePath
+ profilemanager.ConfigDirOverride = tempDir
+ profilemanager.DefaultConfigPathDir = tempDir
+ profilemanager.ActiveProfileStatePath = 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 = ""
+ })
+
+ currUser, err := user.Current()
+ require.NoError(t, err)
+
+ profName = "test-profile-mdm"
+ cfgPath = filepath.Join(tempDir, profName+".json")
+
+ _, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: cfgPath,
+ ManagementURL: "https://api.netbird.io:443",
+ })
+ require.NoError(t, err)
+
+ pm := profilemanager.ServiceManager{}
+ require.NoError(t, pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
+ ID: profilemanager.ID(profName),
+ Username: currUser.Username,
+ }))
+
+ // 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
+}
+
+// extractViolation pulls the MDMManagedFieldsViolation detail from a
+// FailedPrecondition error. Fails the test if absent or malformed.
+func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation {
+ t.Helper()
+ require.Error(t, err)
+ st, ok := gstatus.FromError(err)
+ require.True(t, ok, "error must be a gRPC status: %v", err)
+ require.Equal(t, codes.FailedPrecondition, st.Code(), "expected FailedPrecondition, got %s", st.Code())
+ for _, d := range st.Details() {
+ if v, ok := d.(*proto.MDMManagedFieldsViolation); ok {
+ return v
+ }
+ }
+ t.Fatalf("MDMManagedFieldsViolation detail not found on status; details: %v", st.Details())
+ return nil
+}
+
+func TestSetConfig_MDMReject_SingleField(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ }))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.tried.this.com:443",
+ })
+
+ v := extractViolation(t, err)
+ assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields())
+}
+
+func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ mdm.KeyBlockInbound: true,
+ mdm.KeyRosenpassEnabled: true,
+ }))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ blockInbound := false
+ rosenpassEnabled := false
+ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.tried.this.com:443",
+ BlockInbound: &blockInbound,
+ RosenpassEnabled: &rosenpassEnabled,
+ })
+
+ v := extractViolation(t, err)
+ assert.ElementsMatch(t, []string{
+ mdm.KeyManagementURL,
+ mdm.KeyBlockInbound,
+ mdm.KeyRosenpassEnabled,
+ }, v.GetFields())
+}
+
+func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
+ // MDM enforces ManagementURL only; user request touches both the
+ // enforced field AND a non-enforced field (RosenpassEnabled).
+ // The whole request must be rejected — non-conflicting fields are not
+ // applied either.
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ }))
+
+ s, ctx, profName, username, cfgPath := setupServerWithProfile(t)
+
+ rosenpassEnabled := true
+ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.tried.this.com:443",
+ RosenpassEnabled: &rosenpassEnabled,
+ })
+
+ v := extractViolation(t, err)
+ assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields())
+
+ // Confirm RosenpassEnabled was NOT applied even though it was not
+ // in the conflict list: the request was rejected as a whole.
+ reloaded, err := profilemanager.GetConfig(cfgPath)
+ require.NoError(t, err)
+ assert.False(t, reloaded.RosenpassEnabled, "non-conflicting field must not be applied when request is rejected")
+}
+
+func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) {
+ // MDM enforces ManagementURL but the user only writes RosenpassEnabled.
+ // Request must succeed.
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ }))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ rosenpassEnabled := true
+ resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ RosenpassEnabled: &rosenpassEnabled,
+ })
+
+ require.NoError(t, err)
+ 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))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.changed.url.com:443",
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go
index 553d4ad71..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"
@@ -47,12 +46,16 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- Name: profName,
+ ID: profilemanager.ID(profName),
Username: currUser.Username,
})
require.NoError(t, err)
- 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,50 +72,48 @@ 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,
+ 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,
+ 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)
require.NoError(t, err)
profState := profilemanager.ActiveProfileState{
- Name: profName,
+ ID: profilemanager.ID(profName),
Username: currUser.Username,
}
cfgPath, err := profState.FilePath()
@@ -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..4180849cd 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)
}
@@ -410,12 +408,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/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/proxy/proxy.go b/client/ssh/proxy/proxy.go
index 73b50122c..721810edb 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)
}
diff --git a/client/ssh/proxy/proxy_privileged_test.go b/client/ssh/proxy/proxy_privileged_test.go
new file mode 100644
index 000000000..94495a3ae
--- /dev/null
+++ b/client/ssh/proxy/proxy_privileged_test.go
@@ -0,0 +1,423 @@
+//go:build privileged
+
+package proxy
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "math/big"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "runtime"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ cryptossh "golang.org/x/crypto/ssh"
+
+ nbssh "github.com/netbirdio/netbird/client/ssh"
+ sshauth "github.com/netbirdio/netbird/client/ssh/auth"
+ "github.com/netbirdio/netbird/client/ssh/server"
+ "github.com/netbirdio/netbird/client/ssh/testutil"
+ nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
+ sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
+)
+
+func (m *mockDaemon) setJWTToken(token string) {
+ m.impl.jwtToken = token
+}
+
+func TestSSHProxy_Connect(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test in short mode")
+ }
+
+ // TODO: Windows test times out - user switching and command execution tested on Linux
+ if runtime.GOOS == "windows" {
+ t.Skip("Skipping on Windows - covered by Linux tests")
+ }
+
+ const (
+ issuer = "https://test-issuer.example.com"
+ audience = "test-audience"
+ )
+
+ jwksServer, privateKey, jwksURL := setupJWKSServer(t)
+ defer jwksServer.Close()
+
+ hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ require.NoError(t, err)
+ hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
+ require.NoError(t, err)
+
+ serverConfig := &server.Config{
+ HostKeyPEM: hostKey,
+ JWT: &server.JWTConfig{
+ Issuer: issuer,
+ Audiences: []string{audience},
+ KeysLocation: jwksURL,
+ },
+ }
+ sshServer := server.New(serverConfig)
+ sshServer.SetAllowRootLogin(true)
+
+ // Configure SSH authorization for the test user
+ testUsername := testutil.GetTestUsername(t)
+ testJWTUser := "test-username"
+ testUserHash, err := sshuserhash.HashUserID(testJWTUser)
+ require.NoError(t, err)
+
+ authConfig := &sshauth.Config{
+ UserIDClaim: sshauth.DefaultUserIDClaim,
+ AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
+ MachineUsers: map[string][]uint32{
+ testUsername: {0}, // Index 0 in AuthorizedUsers
+ },
+ }
+ sshServer.UpdateSSHAuth(authConfig)
+
+ sshServerAddr := server.StartTestServer(t, sshServer)
+ defer func() { _ = sshServer.Stop() }()
+
+ mockDaemon := startMockDaemon(t)
+ defer mockDaemon.stop()
+
+ host, portStr, err := net.SplitHostPort(sshServerAddr)
+ require.NoError(t, err)
+ port, err := strconv.Atoi(portStr)
+ require.NoError(t, err)
+
+ mockDaemon.setHostKey(host, hostPubKey)
+
+ validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
+ mockDaemon.setJWTToken(validToken)
+
+ proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
+ require.NoError(t, err)
+
+ clientConn, proxyConn := net.Pipe()
+ defer func() { _ = clientConn.Close() }()
+
+ origStdin := os.Stdin
+ origStdout := os.Stdout
+ defer func() {
+ os.Stdin = origStdin
+ os.Stdout = origStdout
+ }()
+
+ stdinReader, stdinWriter, err := os.Pipe()
+ require.NoError(t, err)
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ require.NoError(t, err)
+
+ os.Stdin = stdinReader
+ os.Stdout = stdoutWriter
+
+ go func() {
+ _, _ = io.Copy(stdinWriter, proxyConn)
+ }()
+ go func() {
+ _, _ = io.Copy(proxyConn, stdoutReader)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ connectErrCh := make(chan error, 1)
+ go func() {
+ connectErrCh <- proxyInstance.Connect(ctx)
+ }()
+
+ sshConfig := &cryptossh.ClientConfig{
+ User: testutil.GetTestUsername(t),
+ Auth: []cryptossh.AuthMethod{},
+ HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
+ Timeout: 3 * time.Second,
+ }
+
+ sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
+ require.NoError(t, err, "Should connect to proxy server")
+ defer func() { _ = sshClientConn.Close() }()
+
+ sshClient := cryptossh.NewClient(sshClientConn, chans, reqs)
+
+ session, err := sshClient.NewSession()
+ require.NoError(t, err, "Should create session through full proxy to backend")
+
+ outputCh := make(chan []byte, 1)
+ errCh := make(chan error, 1)
+ go func() {
+ output, err := session.Output("echo hello-from-proxy")
+ outputCh <- output
+ errCh <- err
+ }()
+
+ select {
+ case output := <-outputCh:
+ err := <-errCh
+ require.NoError(t, err, "Command should execute successfully through proxy")
+ assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy")
+ case <-time.After(3 * time.Second):
+ t.Fatal("Command execution timed out")
+ }
+
+ _ = session.Close()
+ _ = sshClient.Close()
+ _ = clientConn.Close()
+ cancel()
+}
+
+// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting
+// when forwarding commands to the backend. This is critical for tools like
+// Ansible that send commands such as:
+//
+// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0'
+//
+// The single quotes must be preserved so the backend shell receives the
+// subshell expression as a single argument to -c.
+func TestSSHProxy_CommandQuoting(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test in short mode")
+ }
+
+ sshClient, cleanup := setupProxySSHClient(t)
+ defer cleanup()
+
+ // These commands simulate what the SSH protocol delivers as exec payloads.
+ // When a user types: ssh host '/bin/sh -c "( echo hello )"'
+ // the local shell strips the outer single quotes, and the SSH exec request
+ // contains the raw string: /bin/sh -c "( echo hello )"
+ //
+ // The proxy must forward this string verbatim. Using session.Command()
+ // (shlex.Split + strings.Join) strips the inner double quotes, breaking
+ // the command on the backend.
+ tests := []struct {
+ name string
+ command string
+ expect string
+ }{
+ {
+ name: "subshell_in_double_quotes",
+ command: `/bin/sh -c "( echo from-subshell ) && echo outer"`,
+ expect: "from-subshell\nouter\n",
+ },
+ {
+ name: "printf_with_special_chars",
+ command: `/bin/sh -c "printf '%s\n' 'hello world'"`,
+ expect: "hello world\n",
+ },
+ {
+ name: "nested_command_substitution",
+ command: `/bin/sh -c "echo $(echo nested)"`,
+ expect: "nested\n",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ session, err := sshClient.NewSession()
+ require.NoError(t, err)
+ defer func() { _ = session.Close() }()
+
+ var stderrBuf bytes.Buffer
+ session.Stderr = &stderrBuf
+
+ outputCh := make(chan []byte, 1)
+ errCh := make(chan error, 1)
+ go func() {
+ output, err := session.Output(tc.command)
+ outputCh <- output
+ errCh <- err
+ }()
+
+ select {
+ case output := <-outputCh:
+ err := <-errCh
+ if stderrBuf.Len() > 0 {
+ t.Logf("stderr: %s", stderrBuf.String())
+ }
+ require.NoError(t, err, "command should succeed: %s", tc.command)
+ assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command)
+ case <-time.After(5 * time.Second):
+ t.Fatalf("command timed out: %s", tc.command)
+ }
+ })
+ }
+}
+
+// setupProxySSHClient creates a full proxy test environment and returns
+// an SSH client connected through the proxy to a backend NetBird SSH server.
+func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) {
+ t.Helper()
+
+ const (
+ issuer = "https://test-issuer.example.com"
+ audience = "test-audience"
+ )
+
+ jwksServer, privateKey, jwksURL := setupJWKSServer(t)
+
+ hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ require.NoError(t, err)
+ hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
+ require.NoError(t, err)
+
+ serverConfig := &server.Config{
+ HostKeyPEM: hostKey,
+ JWT: &server.JWTConfig{
+ Issuer: issuer,
+ Audiences: []string{audience},
+ KeysLocation: jwksURL,
+ },
+ }
+ sshServer := server.New(serverConfig)
+ sshServer.SetAllowRootLogin(true)
+
+ testUsername := testutil.GetTestUsername(t)
+ testJWTUser := "test-username"
+ testUserHash, err := sshuserhash.HashUserID(testJWTUser)
+ require.NoError(t, err)
+
+ authConfig := &sshauth.Config{
+ UserIDClaim: sshauth.DefaultUserIDClaim,
+ AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
+ MachineUsers: map[string][]uint32{
+ testUsername: {0},
+ },
+ }
+ sshServer.UpdateSSHAuth(authConfig)
+
+ sshServerAddr := server.StartTestServer(t, sshServer)
+
+ mockDaemon := startMockDaemon(t)
+
+ host, portStr, err := net.SplitHostPort(sshServerAddr)
+ require.NoError(t, err)
+ port, err := strconv.Atoi(portStr)
+ require.NoError(t, err)
+
+ mockDaemon.setHostKey(host, hostPubKey)
+
+ validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
+ mockDaemon.setJWTToken(validToken)
+
+ proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
+ require.NoError(t, err)
+
+ origStdin := os.Stdin
+ origStdout := os.Stdout
+
+ stdinReader, stdinWriter, err := os.Pipe()
+ require.NoError(t, err)
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ require.NoError(t, err)
+
+ os.Stdin = stdinReader
+ os.Stdout = stdoutWriter
+
+ clientConn, proxyConn := net.Pipe()
+
+ go func() { _, _ = io.Copy(stdinWriter, proxyConn) }()
+ go func() { _, _ = io.Copy(proxyConn, stdoutReader) }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+
+ go func() {
+ _ = proxyInstance.Connect(ctx)
+ }()
+
+ sshConfig := &cryptossh.ClientConfig{
+ User: testutil.GetTestUsername(t),
+ Auth: []cryptossh.AuthMethod{},
+ HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
+ Timeout: 5 * time.Second,
+ }
+
+ sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
+ require.NoError(t, err)
+
+ client := cryptossh.NewClient(sshClientConn, chans, reqs)
+
+ cleanupFn := func() {
+ _ = client.Close()
+ _ = clientConn.Close()
+ cancel()
+ os.Stdin = origStdin
+ os.Stdout = origStdout
+ _ = sshServer.Stop()
+ mockDaemon.stop()
+ jwksServer.Close()
+ }
+
+ return client, cleanupFn
+}
+
+func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) {
+ t.Helper()
+ privateKey, jwksJSON := generateTestJWKS(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write(jwksJSON); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }))
+
+ return server, privateKey, server.URL
+}
+
+func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) {
+ t.Helper()
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ publicKey := &privateKey.PublicKey
+ n := publicKey.N.Bytes()
+ e := publicKey.E
+
+ jwk := nbjwt.JSONWebKey{
+ Kty: "RSA",
+ Kid: "test-key-id",
+ Use: "sig",
+ N: base64.RawURLEncoding.EncodeToString(n),
+ E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()),
+ }
+
+ jwks := nbjwt.Jwks{
+ Keys: []nbjwt.JSONWebKey{jwk},
+ }
+
+ jwksJSON, err := json.Marshal(jwks)
+ require.NoError(t, err)
+
+ return privateKey, jwksJSON
+}
+
+func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string {
+ t.Helper()
+ claims := jwt.MapClaims{
+ "iss": issuer,
+ "aud": audience,
+ "sub": user,
+ "exp": time.Now().Add(time.Hour).Unix(),
+ "iat": time.Now().Unix(),
+ }
+
+ token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
+ token.Header["kid"] = "test-key-id"
+
+ tokenString, err := token.SignedString(privateKey)
+ require.NoError(t, err)
+
+ return tokenString
+}
diff --git a/client/ssh/proxy/proxy_test.go b/client/ssh/proxy/proxy_test.go
index b33d5f8f4..2795c786b 100644
--- a/client/ssh/proxy/proxy_test.go
+++ b/client/ssh/proxy/proxy_test.go
@@ -1,25 +1,12 @@
package proxy
import (
- "bytes"
"context"
- "crypto/rand"
- "crypto/rsa"
- "encoding/base64"
- "encoding/json"
"fmt"
- "io"
- "math/big"
"net"
- "net/http"
- "net/http/httptest"
"os"
- "runtime"
- "strconv"
"testing"
- "time"
- "github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
cryptossh "golang.org/x/crypto/ssh"
@@ -28,11 +15,7 @@ import (
"github.com/netbirdio/netbird/client/proto"
nbssh "github.com/netbirdio/netbird/client/ssh"
- sshauth "github.com/netbirdio/netbird/client/ssh/auth"
- "github.com/netbirdio/netbird/client/ssh/server"
"github.com/netbirdio/netbird/client/ssh/testutil"
- nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
- sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
)
func TestMain(m *testing.M) {
@@ -106,331 +89,6 @@ func TestSSHProxy_verifyHostKey(t *testing.T) {
})
}
-func TestSSHProxy_Connect(t *testing.T) {
- if testing.Short() {
- t.Skip("Skipping integration test in short mode")
- }
-
- // TODO: Windows test times out - user switching and command execution tested on Linux
- if runtime.GOOS == "windows" {
- t.Skip("Skipping on Windows - covered by Linux tests")
- }
-
- const (
- issuer = "https://test-issuer.example.com"
- audience = "test-audience"
- )
-
- jwksServer, privateKey, jwksURL := setupJWKSServer(t)
- defer jwksServer.Close()
-
- hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
- require.NoError(t, err)
- hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
- require.NoError(t, err)
-
- serverConfig := &server.Config{
- HostKeyPEM: hostKey,
- JWT: &server.JWTConfig{
- Issuer: issuer,
- Audiences: []string{audience},
- KeysLocation: jwksURL,
- },
- }
- sshServer := server.New(serverConfig)
- sshServer.SetAllowRootLogin(true)
-
- // Configure SSH authorization for the test user
- testUsername := testutil.GetTestUsername(t)
- testJWTUser := "test-username"
- testUserHash, err := sshuserhash.HashUserID(testJWTUser)
- require.NoError(t, err)
-
- authConfig := &sshauth.Config{
- UserIDClaim: sshauth.DefaultUserIDClaim,
- AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
- MachineUsers: map[string][]uint32{
- testUsername: {0}, // Index 0 in AuthorizedUsers
- },
- }
- sshServer.UpdateSSHAuth(authConfig)
-
- sshServerAddr := server.StartTestServer(t, sshServer)
- defer func() { _ = sshServer.Stop() }()
-
- mockDaemon := startMockDaemon(t)
- defer mockDaemon.stop()
-
- host, portStr, err := net.SplitHostPort(sshServerAddr)
- require.NoError(t, err)
- port, err := strconv.Atoi(portStr)
- require.NoError(t, err)
-
- mockDaemon.setHostKey(host, hostPubKey)
-
- validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
- mockDaemon.setJWTToken(validToken)
-
- proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
- require.NoError(t, err)
-
- clientConn, proxyConn := net.Pipe()
- defer func() { _ = clientConn.Close() }()
-
- origStdin := os.Stdin
- origStdout := os.Stdout
- defer func() {
- os.Stdin = origStdin
- os.Stdout = origStdout
- }()
-
- stdinReader, stdinWriter, err := os.Pipe()
- require.NoError(t, err)
- stdoutReader, stdoutWriter, err := os.Pipe()
- require.NoError(t, err)
-
- os.Stdin = stdinReader
- os.Stdout = stdoutWriter
-
- go func() {
- _, _ = io.Copy(stdinWriter, proxyConn)
- }()
- go func() {
- _, _ = io.Copy(proxyConn, stdoutReader)
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- connectErrCh := make(chan error, 1)
- go func() {
- connectErrCh <- proxyInstance.Connect(ctx)
- }()
-
- sshConfig := &cryptossh.ClientConfig{
- User: testutil.GetTestUsername(t),
- Auth: []cryptossh.AuthMethod{},
- HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
- Timeout: 3 * time.Second,
- }
-
- sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
- require.NoError(t, err, "Should connect to proxy server")
- defer func() { _ = sshClientConn.Close() }()
-
- sshClient := cryptossh.NewClient(sshClientConn, chans, reqs)
-
- session, err := sshClient.NewSession()
- require.NoError(t, err, "Should create session through full proxy to backend")
-
- outputCh := make(chan []byte, 1)
- errCh := make(chan error, 1)
- go func() {
- output, err := session.Output("echo hello-from-proxy")
- outputCh <- output
- errCh <- err
- }()
-
- select {
- case output := <-outputCh:
- err := <-errCh
- require.NoError(t, err, "Command should execute successfully through proxy")
- assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy")
- case <-time.After(3 * time.Second):
- t.Fatal("Command execution timed out")
- }
-
- _ = session.Close()
- _ = sshClient.Close()
- _ = clientConn.Close()
- cancel()
-}
-
-// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting
-// when forwarding commands to the backend. This is critical for tools like
-// Ansible that send commands such as:
-//
-// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0'
-//
-// The single quotes must be preserved so the backend shell receives the
-// subshell expression as a single argument to -c.
-func TestSSHProxy_CommandQuoting(t *testing.T) {
- if testing.Short() {
- t.Skip("Skipping integration test in short mode")
- }
-
- sshClient, cleanup := setupProxySSHClient(t)
- defer cleanup()
-
- // These commands simulate what the SSH protocol delivers as exec payloads.
- // When a user types: ssh host '/bin/sh -c "( echo hello )"'
- // the local shell strips the outer single quotes, and the SSH exec request
- // contains the raw string: /bin/sh -c "( echo hello )"
- //
- // The proxy must forward this string verbatim. Using session.Command()
- // (shlex.Split + strings.Join) strips the inner double quotes, breaking
- // the command on the backend.
- tests := []struct {
- name string
- command string
- expect string
- }{
- {
- name: "subshell_in_double_quotes",
- command: `/bin/sh -c "( echo from-subshell ) && echo outer"`,
- expect: "from-subshell\nouter\n",
- },
- {
- name: "printf_with_special_chars",
- command: `/bin/sh -c "printf '%s\n' 'hello world'"`,
- expect: "hello world\n",
- },
- {
- name: "nested_command_substitution",
- command: `/bin/sh -c "echo $(echo nested)"`,
- expect: "nested\n",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- session, err := sshClient.NewSession()
- require.NoError(t, err)
- defer func() { _ = session.Close() }()
-
- var stderrBuf bytes.Buffer
- session.Stderr = &stderrBuf
-
- outputCh := make(chan []byte, 1)
- errCh := make(chan error, 1)
- go func() {
- output, err := session.Output(tc.command)
- outputCh <- output
- errCh <- err
- }()
-
- select {
- case output := <-outputCh:
- err := <-errCh
- if stderrBuf.Len() > 0 {
- t.Logf("stderr: %s", stderrBuf.String())
- }
- require.NoError(t, err, "command should succeed: %s", tc.command)
- assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command)
- case <-time.After(5 * time.Second):
- t.Fatalf("command timed out: %s", tc.command)
- }
- })
- }
-}
-
-// setupProxySSHClient creates a full proxy test environment and returns
-// an SSH client connected through the proxy to a backend NetBird SSH server.
-func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) {
- t.Helper()
-
- const (
- issuer = "https://test-issuer.example.com"
- audience = "test-audience"
- )
-
- jwksServer, privateKey, jwksURL := setupJWKSServer(t)
-
- hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
- require.NoError(t, err)
- hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
- require.NoError(t, err)
-
- serverConfig := &server.Config{
- HostKeyPEM: hostKey,
- JWT: &server.JWTConfig{
- Issuer: issuer,
- Audiences: []string{audience},
- KeysLocation: jwksURL,
- },
- }
- sshServer := server.New(serverConfig)
- sshServer.SetAllowRootLogin(true)
-
- testUsername := testutil.GetTestUsername(t)
- testJWTUser := "test-username"
- testUserHash, err := sshuserhash.HashUserID(testJWTUser)
- require.NoError(t, err)
-
- authConfig := &sshauth.Config{
- UserIDClaim: sshauth.DefaultUserIDClaim,
- AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
- MachineUsers: map[string][]uint32{
- testUsername: {0},
- },
- }
- sshServer.UpdateSSHAuth(authConfig)
-
- sshServerAddr := server.StartTestServer(t, sshServer)
-
- mockDaemon := startMockDaemon(t)
-
- host, portStr, err := net.SplitHostPort(sshServerAddr)
- require.NoError(t, err)
- port, err := strconv.Atoi(portStr)
- require.NoError(t, err)
-
- mockDaemon.setHostKey(host, hostPubKey)
-
- validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
- mockDaemon.setJWTToken(validToken)
-
- proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
- require.NoError(t, err)
-
- origStdin := os.Stdin
- origStdout := os.Stdout
-
- stdinReader, stdinWriter, err := os.Pipe()
- require.NoError(t, err)
- stdoutReader, stdoutWriter, err := os.Pipe()
- require.NoError(t, err)
-
- os.Stdin = stdinReader
- os.Stdout = stdoutWriter
-
- clientConn, proxyConn := net.Pipe()
-
- go func() { _, _ = io.Copy(stdinWriter, proxyConn) }()
- go func() { _, _ = io.Copy(proxyConn, stdoutReader) }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-
- go func() {
- _ = proxyInstance.Connect(ctx)
- }()
-
- sshConfig := &cryptossh.ClientConfig{
- User: testutil.GetTestUsername(t),
- Auth: []cryptossh.AuthMethod{},
- HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
- Timeout: 5 * time.Second,
- }
-
- sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
- require.NoError(t, err)
-
- client := cryptossh.NewClient(sshClientConn, chans, reqs)
-
- cleanupFn := func() {
- _ = client.Close()
- _ = clientConn.Close()
- cancel()
- os.Stdin = origStdin
- os.Stdout = origStdout
- _ = sshServer.Stop()
- mockDaemon.stop()
- jwksServer.Close()
- }
-
- return client, cleanupFn
-}
-
type mockDaemonServer struct {
proto.UnimplementedDaemonServiceServer
hostKeys map[string][]byte
@@ -492,10 +150,6 @@ func (m *mockDaemon) setHostKey(addr string, pubKey []byte) {
m.impl.hostKeys[addr] = pubKey
}
-func (m *mockDaemon) setJWTToken(token string) {
- m.impl.jwtToken = token
-}
-
func (m *mockDaemon) stop() {
if m.server != nil {
m.server.Stop()
@@ -508,63 +162,3 @@ func mustParsePublicKey(t *testing.T, pubKeyBytes []byte) cryptossh.PublicKey {
require.NoError(t, err)
return pubKey
}
-
-func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) {
- t.Helper()
- privateKey, jwksJSON := generateTestJWKS(t)
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- if _, err := w.Write(jwksJSON); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
- }))
-
- return server, privateKey, server.URL
-}
-
-func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) {
- t.Helper()
- privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
- require.NoError(t, err)
-
- publicKey := &privateKey.PublicKey
- n := publicKey.N.Bytes()
- e := publicKey.E
-
- jwk := nbjwt.JSONWebKey{
- Kty: "RSA",
- Kid: "test-key-id",
- Use: "sig",
- N: base64.RawURLEncoding.EncodeToString(n),
- E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()),
- }
-
- jwks := nbjwt.Jwks{
- Keys: []nbjwt.JSONWebKey{jwk},
- }
-
- jwksJSON, err := json.Marshal(jwks)
- require.NoError(t, err)
-
- return privateKey, jwksJSON
-}
-
-func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string {
- t.Helper()
- claims := jwt.MapClaims{
- "iss": issuer,
- "aud": audience,
- "sub": user,
- "exp": time.Now().Add(time.Hour).Unix(),
- "iat": time.Now().Unix(),
- }
-
- token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
- token.Header["kid"] = "test-key-id"
-
- tokenString, err := token.SignedString(privateKey)
- require.NoError(t, err)
-
- return tokenString
-}
diff --git a/client/ssh/server/executor_unix_privileged_test.go b/client/ssh/server/executor_unix_privileged_test.go
new file mode 100644
index 000000000..f1b0805d9
--- /dev/null
+++ b/client/ssh/server/executor_unix_privileged_test.go
@@ -0,0 +1,66 @@
+//go:build unix && privileged
+
+package server
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) {
+ pd := NewPrivilegeDropper()
+
+ config := ExecutorConfig{
+ UID: 1000,
+ GID: 1000,
+ Groups: []uint32{1000, 1001},
+ WorkingDir: "/home/testuser",
+ Shell: "/bin/bash",
+ Command: "ls -la",
+ }
+
+ cmd, err := pd.CreateExecutorCommand(context.Background(), config)
+ require.NoError(t, err)
+ require.NotNil(t, cmd)
+
+ // Verify the command is calling netbird ssh exec
+ assert.Contains(t, cmd.Args, "ssh")
+ assert.Contains(t, cmd.Args, "exec")
+ assert.Contains(t, cmd.Args, "--uid")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "--gid")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "--groups")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "1001")
+ assert.Contains(t, cmd.Args, "--working-dir")
+ assert.Contains(t, cmd.Args, "/home/testuser")
+ assert.Contains(t, cmd.Args, "--shell")
+ assert.Contains(t, cmd.Args, "/bin/bash")
+ assert.Contains(t, cmd.Args, "--cmd")
+ assert.Contains(t, cmd.Args, "ls -la")
+}
+
+func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) {
+ pd := NewPrivilegeDropper()
+
+ config := ExecutorConfig{
+ UID: 1000,
+ GID: 1000,
+ Groups: []uint32{1000},
+ WorkingDir: "/home/testuser",
+ Shell: "/bin/bash",
+ Command: "",
+ }
+
+ cmd, err := pd.CreateExecutorCommand(context.Background(), config)
+ require.NoError(t, err)
+ require.NotNil(t, cmd)
+
+ // Verify no command mode (command is empty so no --cmd flag)
+ assert.NotContains(t, cmd.Args, "--cmd")
+ assert.NotContains(t, cmd.Args, "--interactive")
+}
diff --git a/client/ssh/server/executor_unix_test.go b/client/ssh/server/executor_unix_test.go
index 0c5108f57..171e78b83 100644
--- a/client/ssh/server/executor_unix_test.go
+++ b/client/ssh/server/executor_unix_test.go
@@ -73,61 +73,6 @@ func TestPrivilegeDropper_ValidatePrivileges(t *testing.T) {
}
}
-func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) {
- pd := NewPrivilegeDropper()
-
- config := ExecutorConfig{
- UID: 1000,
- GID: 1000,
- Groups: []uint32{1000, 1001},
- WorkingDir: "/home/testuser",
- Shell: "/bin/bash",
- Command: "ls -la",
- }
-
- cmd, err := pd.CreateExecutorCommand(context.Background(), config)
- require.NoError(t, err)
- require.NotNil(t, cmd)
-
- // Verify the command is calling netbird ssh exec
- assert.Contains(t, cmd.Args, "ssh")
- assert.Contains(t, cmd.Args, "exec")
- assert.Contains(t, cmd.Args, "--uid")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "--gid")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "--groups")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "1001")
- assert.Contains(t, cmd.Args, "--working-dir")
- assert.Contains(t, cmd.Args, "/home/testuser")
- assert.Contains(t, cmd.Args, "--shell")
- assert.Contains(t, cmd.Args, "/bin/bash")
- assert.Contains(t, cmd.Args, "--cmd")
- assert.Contains(t, cmd.Args, "ls -la")
-}
-
-func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) {
- pd := NewPrivilegeDropper()
-
- config := ExecutorConfig{
- UID: 1000,
- GID: 1000,
- Groups: []uint32{1000},
- WorkingDir: "/home/testuser",
- Shell: "/bin/bash",
- Command: "",
- }
-
- cmd, err := pd.CreateExecutorCommand(context.Background(), config)
- require.NoError(t, err)
- require.NotNil(t, cmd)
-
- // Verify no command mode (command is empty so no --cmd flag)
- assert.NotContains(t, cmd.Args, "--cmd")
- assert.NotContains(t, cmd.Args, "--interactive")
-}
-
// TestPrivilegeDropper_ActualPrivilegeDrop tests actual privilege dropping
// This test requires root privileges and will be skipped if not running as root
func TestPrivilegeDropper_ActualPrivilegeDrop(t *testing.T) {
diff --git a/client/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/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/status/status.go b/client/status/status.go
index 11ed06c2d..e8276d0fa 100644
--- a/client/status/status.go
+++ b/client/status/status.go
@@ -55,6 +55,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 {
@@ -98,6 +102,7 @@ type RelayStateOutputDetail struct {
URI string `json:"uri" yaml:"uri"`
Available bool `json:"available" yaml:"available"`
Error string `json:"error" yaml:"error"`
+ Transport string `json:"transport,omitempty" yaml:"transport,omitempty"`
}
type RelayStateOutput struct {
@@ -143,6 +148,7 @@ type OutputOverview struct {
IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"`
PubKey string `json:"publicKey" yaml:"publicKey"`
KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"`
+ WgPort int `json:"wireguardPort" yaml:"wireguardPort"`
FQDN string `json:"fqdn" yaml:"fqdn"`
RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"`
RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"`
@@ -153,6 +159,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.
@@ -187,6 +198,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(),
PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(),
KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(),
+ WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()),
FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(),
RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(),
RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(),
@@ -198,6 +210,10 @@ 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())
@@ -217,7 +233,8 @@ func mapRelays(relays []*proto.RelayState) RelayStateOutput {
RelayStateOutputDetail{
URI: relay.URI,
Available: available,
- Error: relay.GetError(),
+ Error: relayErrorString(relay.GetError()),
+ Transport: relay.GetTransport(),
},
)
@@ -233,6 +250,12 @@ func mapRelays(relays []*proto.RelayState) RelayStateOutput {
}
}
+// relayErrorString flattens a newline-joined aggregated relay error onto a
+// single line for status output.
+func relayErrorString(s string) string {
+ return strings.ReplaceAll(s, "\n", "; ")
+}
+
func mapNSGroups(servers []*proto.NSGroupState) []NsServerGroupStateOutput {
mappedNSGroups := make([]NsServerGroupStateOutput, 0, len(servers))
for _, pbNsGroupServer := range servers {
@@ -439,6 +462,8 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
available = "Unavailable"
reason = fmt.Sprintf(", reason: %s", relay.Error)
}
+ } else if relay.Transport != "" {
+ available = fmt.Sprintf("%s via %s", available, relay.Transport)
}
relaysString += fmt.Sprintf("\n [%s] is %s%s", relay.URI, available, reason)
@@ -535,6 +560,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)
@@ -547,6 +581,21 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
goarm = fmt.Sprintf(" (ARMv%s)", os.Getenv("GOARM"))
}
+ daemonVersion := "N/A"
+ if o.DaemonVersion != "" {
+ daemonVersion = o.DaemonVersion
+ }
+
+ cliVersion := version.NetbirdVersion()
+ if o.CliVersion != "" {
+ cliVersion = o.CliVersion
+ }
+
+ wgPortString := "N/A"
+ if o.WgPort > 0 {
+ wgPortString = fmt.Sprintf("%d", o.WgPort)
+ }
+
summary := fmt.Sprintf(
"OS: %s\n"+
"Daemon version: %s\n"+
@@ -560,15 +609,17 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
"NetBird IP: %s\n"+
"%s"+
"Interface type: %s\n"+
+ "Wireguard port: %s\n"+
"Quantum resistance: %s\n"+
"Lazy connection: %s\n"+
"SSH Server: %s\n"+
"Networks: %s\n"+
"%s"+
+ "%s"+
"Peers count: %s\n",
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
- o.DaemonVersion,
- version.NetbirdVersion(),
+ daemonVersion,
+ cliVersion,
o.ProfileName,
managementConnString,
signalConnString,
@@ -578,11 +629,13 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
interfaceIP,
ipv6Line,
interfaceTypeString,
+ wgPortString,
rosenpassEnabledStatus,
lazyConnectionEnabledStatus,
sshServerStatus,
networks,
forwardingRulesString,
+ sessionExpiryString,
peersCountString,
)
return summary
@@ -693,6 +746,8 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus {
pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState)
}
+ pbFullStatus.Events = fullStatus.Events
+
return &pbFullStatus
}
@@ -996,3 +1051,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 0986bf0cd..2babd9342 100644
--- a/client/status/status_test.go
+++ b/client/status/status_test.go
@@ -94,6 +94,7 @@ var resp = &proto.StatusResponse{
Ipv6: "fd00::100",
PubKey: "Some-Pub-Key",
KernelInterface: true,
+ WgPort: 51820,
Fqdn: "some-localhost.awesome-domain.com",
Networks: []string{
"10.10.0.0/24",
@@ -210,6 +211,7 @@ var overview = OutputOverview{
IPv6: "fd00::100",
PubKey: "Some-Pub-Key",
KernelInterface: true,
+ WgPort: 51820,
FQDN: "some-localhost.awesome-domain.com",
NSServerGroups: []NsServerGroupStateOutput{
{
@@ -369,6 +371,7 @@ func TestParsingToJSON(t *testing.T) {
"netbirdIpv6": "fd00::100",
"publicKey": "Some-Pub-Key",
"usesKernelInterface": true,
+ "wireguardPort": 51820,
"fqdn": "some-localhost.awesome-domain.com",
"quantumResistance": false,
"quantumResistancePermissive": false,
@@ -487,6 +490,7 @@ netbirdIp: 192.168.178.100/16
netbirdIpv6: fd00::100
publicKey: Some-Pub-Key
usesKernelInterface: true
+wireguardPort: 51820
fqdn: some-localhost.awesome-domain.com
quantumResistance: false
quantumResistancePermissive: false
@@ -579,12 +583,13 @@ FQDN: some-localhost.awesome-domain.com
NetBird IP: 192.168.178.100/16
NetBird IPv6: fd00::100
Interface type: Kernel
+Wireguard port: %d
Quantum resistance: false
Lazy connection: false
SSH Server: Disabled
Networks: 10.10.0.0/24
Peers count: 2/2 Connected
-`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion)
+`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion, overview.WgPort)
assert.Equal(t, expectedDetail, detail)
}
@@ -604,6 +609,7 @@ FQDN: some-localhost.awesome-domain.com
NetBird IP: 192.168.178.100/16
NetBird IPv6: fd00::100
Interface type: Kernel
+Wireguard port: 51820
Quantum resistance: false
Lazy connection: false
SSH Server: Disabled
@@ -641,3 +647,60 @@ 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"},
+ {URI: "rels://relay2.example:443", Available: true, Transport: "ws"},
+ })
+ require.Len(t, out.Details, 2)
+ assert.Equal(t, "quic", out.Details[0].Transport)
+ assert.Equal(t, "ws", out.Details[1].Transport)
+}
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..3c71573bb 100644
--- a/client/system/info_android.go
+++ b/client/system/info_android.go
@@ -50,7 +50,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..f32532881 100644
--- a/client/system/info_js.go
+++ b/client/system/info_js.go
@@ -103,7 +103,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_linux.go b/client/system/info_linux.go
index 6c7a23b95..de37a9f5b 100644
--- a/client/system/info_linux.go
+++ b/client/system/info_linux.go
@@ -3,15 +3,14 @@
package system
import (
- "bytes"
"context"
"os"
- "os/exec"
"regexp"
"runtime"
- "strings"
"time"
+ "golang.org/x/sys/unix"
+
log "github.com/sirupsen/logrus"
"github.com/zcalusic/sysinfo"
@@ -29,19 +28,11 @@ func UpdateStaticInfoAsync() {
// GetInfo retrieves and parses the system information
func GetInfo(ctx context.Context) *Info {
- info := _getInfo()
- for strings.Contains(info, "broken pipe") {
- info = _getInfo()
- time.Sleep(500 * time.Millisecond)
- }
-
- osStr := strings.ReplaceAll(info, "\n", "")
- osStr = strings.ReplaceAll(osStr, "\r\n", "")
- osInfo := strings.Split(osStr, " ")
+ kernelName, kernelVersion, kernelPlatform := kernelInfo()
osName, osVersion := readOsReleaseFile()
if osName == "" {
- osName = osInfo[3]
+ osName = kernelName
}
systemHostname, _ := os.Hostname()
@@ -58,8 +49,8 @@ func GetInfo(ctx context.Context) *Info {
}
gio := &Info{
- Kernel: osInfo[0],
- Platform: osInfo[2],
+ Kernel: kernelName,
+ Platform: kernelPlatform,
OS: osName,
OSVersion: osVersion,
Hostname: extractDeviceName(ctx, systemHostname),
@@ -67,7 +58,7 @@ func GetInfo(ctx context.Context) *Info {
CPUs: runtime.NumCPU(),
NetbirdVersion: version.NetbirdVersion(),
UIVersion: extractUserAgent(ctx),
- KernelVersion: osInfo[1],
+ KernelVersion: kernelVersion,
NetworkAddresses: addrs,
SystemSerialNumber: si.SystemSerialNumber,
SystemProductName: si.SystemProductName,
@@ -78,18 +69,12 @@ func GetInfo(ctx context.Context) *Info {
return gio
}
-func _getInfo() string {
- cmd := exec.Command("uname", "-srio")
- cmd.Stdin = strings.NewReader("some")
- var out bytes.Buffer
- var stderr bytes.Buffer
- cmd.Stdout = &out
- cmd.Stderr = &stderr
- err := cmd.Run()
- if err != nil {
- log.Warnf("getInfo: %s", err)
+func kernelInfo() (string, string, string) {
+ var uts unix.Utsname
+ if err := unix.Uname(&uts); err != nil {
+ return "", "", ""
}
- return out.String()
+ return unix.ByteSliceToString(uts.Sysname[:]), unix.ByteSliceToString(uts.Release[:]), unix.ByteSliceToString(uts.Machine[:])
}
func sysInfo() (string, string, string) {
diff --git a/client/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..44260a938 100644
--- a/client/system/network_addr.go
+++ b/client/system/network_addr.go
@@ -46,7 +46,9 @@ func toNetworkAddress(address net.Addr, mac string) (NetworkAddress, bool) {
if !ok {
return NetworkAddress{}, false
}
- if ipNet.IP.IsLoopback() {
+ // Skip link-local and multicast: they carry no routable peer info and the
+ // IPv6 link-local of a flapping NIC churns the meta on every up/down.
+ if ipNet.IP.IsLoopback() || ipNet.IP.IsLinkLocalUnicast() || ipNet.IP.IsMulticast() {
return NetworkAddress{}, false
}
prefix, err := netip.ParsePrefix(ipNet.String())
diff --git a/client/system/network_addr_test.go b/client/system/network_addr_test.go
new file mode 100644
index 000000000..a5f9c4279
--- /dev/null
+++ b/client/system/network_addr_test.go
@@ -0,0 +1,45 @@
+//go:build !ios
+
+package system
+
+import (
+ "net"
+ "testing"
+)
+
+func mustIPNet(t *testing.T, cidr string) *net.IPNet {
+ t.Helper()
+ ip, ipNet, err := net.ParseCIDR(cidr)
+ if err != nil {
+ t.Fatalf("parse %q: %v", cidr, err)
+ }
+ ipNet.IP = ip
+ return ipNet
+}
+
+func TestToNetworkAddress_Filtering(t *testing.T) {
+ const mac = "c8:4b:d6:b6:04:ac"
+
+ tests := []struct {
+ name string
+ cidr string
+ want bool
+ }{
+ {"ipv4 global", "10.65.16.181/23", true},
+ {"ipv6 global", "2620:52:0:4110:102d:6a98:ee75:8b92/64", true},
+ {"ipv4 loopback", "127.0.0.1/8", false},
+ {"ipv6 loopback", "::1/128", false},
+ {"ipv6 link-local", "fe80::871:4c25:23d7:2529/64", false},
+ {"ipv4 link-local", "169.254.1.2/16", false},
+ {"ipv6 multicast", "ff02::1/128", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, got := toNetworkAddress(mustIPNet(t, tt.cidr), mac)
+ if got != tt.want {
+ t.Errorf("toNetworkAddress(%s) ok = %v, want %v", tt.cidr, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/client/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..9d0a6b935 100644
--- a/client/system/process_test.go
+++ b/client/system/process_test.go
@@ -1,15 +1,16 @@
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 +30,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..28efe7cfd
--- /dev/null
+++ b/client/ui/authsession/service.go
@@ -0,0 +1,116 @@
+//go:build !android && !ios && !freebsd && !js
+
+package authsession
+
+import (
+ "context"
+ "time"
+
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "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
+ }
+
+ req := &proto.RequestExtendAuthSessionRequest{}
+ if p.Hint != "" {
+ h := p.Hint
+ req.Hint = &h
+ }
+
+ 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 c2129c7a2..000000000
--- a/client/ui/client_ui.go
+++ /dev/null
@@ -1,1781 +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/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
-)
-
-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
- settingsEnabled bool
- profilesEnabled bool
- networksEnabled 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
-}
-
-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() {
- // 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("Update settings are disabled by daemon")
- return
- }
-
- // 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},
- {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(s.iMngURL.Text)
-
- 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(s.iInterfacePort.Text, 10, 64)
- if err != nil {
- return 0, 0, errors.New("invalid interface port")
- }
- if port < 1 || port > 65535 {
- return 0, 0, errors.New("invalid interface port: out of range 1-65535")
- }
-
- 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.Name,
- 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
- }
-
- if 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)
- }
-
- loginReq := &proto.LoginRequest{
- IsUnixDesktopClient: runtime.GOOS == "linux" || runtime.GOOS == "freebsd",
- ProfileName: &activeProf.Name,
- Username: &currUser.Username,
- }
-
- profileState, err := s.profileManager.GetProfileState(activeProf.Name)
- 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)
-
- 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 settings menu 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 {
- s.setSettingsEnabled(false)
- }
- if features != nil && features.DisableProfiles {
- s.mProfile.setEnabled(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()
-
- go func() {
- s.getSrvConfig()
- time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon
- for {
- // Check features before status so menus respect disable flags before being enabled
- s.checkAndUpdateFeatures()
-
- 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)
- }
- })
-
- 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
-}
-
-// setSettingsEnabled enables or disables the settings menu based on the provided state
-func (s *serviceClient) setSettingsEnabled(enabled bool) {
- if s.mSettings != nil {
- if enabled {
- s.mSettings.Enable()
- } else {
- s.mSettings.Hide()
- s.mSettings.SetTooltip("Settings are disabled by daemon")
- }
- }
-}
-
-// 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()
-
- // Update settings menu based on current features
- settingsEnabled := features == nil || !features.DisableUpdateSettings
- if s.settingsEnabled != settingsEnabled {
- s.settingsEnabled = settingsEnabled
- s.setSettingsEnabled(settingsEnabled)
- }
-
- // 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
- s.networksEnabled = features == nil || !features.DisableNetworks
- if s.networksEnabled && s.connected {
- 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.Name,
- 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)
- s.iPreSharedKey.SetText(cfg.PreSharedKey)
- 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)
- if !cfg.RosenpassEnabled {
- 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))
- }
- }
-
- 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 {
- 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.Name,
- 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())
- }
-}
-
-// 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.Name,
- 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 cf5ac1a75..000000000
--- a/client/ui/debug.go
+++ /dev/null
@@ -1,727 +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"
-)
-
-// 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,
- }
-
- 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,
- }
-
- 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..3131b36cd
--- /dev/null
+++ b/client/ui/frontend/package.json
@@ -0,0 +1,69 @@
+{
+ "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"
+ },
+ "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 (
+
+ );
+});
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..a0a131fbf
--- /dev/null
+++ b/client/ui/frontend/src/contexts/DebugBundleContext.tsx
@@ -0,0 +1,314 @@
+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;
+ anonymize: boolean;
+ systemInfo: boolean;
+};
+
+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.anonymize,
+ 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 [anonymize, setAnonymize] = useState(false);
+ 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 : "",
+ anonymize,
+ 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 {
+ anonymize,
+ setAnonymize,
+ 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